mirror of
https://github.com/openclaw/openclaw.git
synced 2026-03-16 12:30:49 +00:00
feat: improve pluggable web search onboarding
This commit is contained in:
@@ -28,6 +28,7 @@ When you touch tests or want extra confidence:
|
||||
|
||||
- Coverage gate: `pnpm test:coverage`
|
||||
- E2E suite: `pnpm test:e2e`
|
||||
- Targeted local repro: `pnpm test -- <path/to/test>` or `pnpm test:macmini -- <path/to/test>` on lower-memory hosts
|
||||
|
||||
When debugging real providers/models (requires real creds):
|
||||
|
||||
|
||||
@@ -28,7 +28,7 @@ For local PR land/gate checks, run:
|
||||
- `pnpm test`
|
||||
- `pnpm check:docs`
|
||||
|
||||
If `pnpm test` flakes on a loaded host, rerun once before treating it as a regression, then isolate with `pnpm vitest run <path/to/test>`. For memory-constrained hosts, use:
|
||||
If `pnpm test` flakes on a loaded host, rerun once before treating it as a regression, then isolate with `pnpm test -- <path/to/test>` so the repo test wrapper still applies the intended config/profile logic. On lower-memory hosts, prefer `pnpm test:macmini -- <path/to/test>`. For memory-constrained full-suite runs, use:
|
||||
|
||||
- `OPENCLAW_TEST_PROFILE=low OPENCLAW_TEST_SERIAL_GATEWAY=1 pnpm test`
|
||||
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import {
|
||||
CacheEntry,
|
||||
createLegacySearchProviderMetadata,
|
||||
createMissingSearchKeyPayload,
|
||||
formatCliCommand,
|
||||
normalizeCacheKey,
|
||||
@@ -125,10 +126,7 @@ function resolveBraveConfig(search?: WebSearchConfig): BraveConfig {
|
||||
return {};
|
||||
}
|
||||
const brave = "brave" in search ? search.brave : undefined;
|
||||
if (!brave || typeof brave !== "object") {
|
||||
return {};
|
||||
}
|
||||
return brave as BraveConfig;
|
||||
return brave && typeof brave === "object" ? (brave as BraveConfig) : {};
|
||||
}
|
||||
|
||||
function resolveBraveMode(brave: BraveConfig): "web" | "llm-context" {
|
||||
@@ -397,16 +395,16 @@ async function runBraveWebSearch(params: {
|
||||
);
|
||||
}
|
||||
|
||||
export const BRAVE_SEARCH_PROVIDER_METADATA: SearchProviderLegacyUiMetadata = {
|
||||
label: "Brave Search",
|
||||
hint: "Structured results · country/language/time filters",
|
||||
envKeys: ["BRAVE_API_KEY"],
|
||||
placeholder: "BSA...",
|
||||
signupUrl: "https://brave.com/search/api/",
|
||||
apiKeyConfigPath: "tools.web.search.apiKey",
|
||||
readApiKeyValue: (search) => readSearchProviderApiKeyValue(search, "brave"),
|
||||
writeApiKeyValue: (search, value) => void ((search.apiKey = value) as unknown),
|
||||
};
|
||||
export const BRAVE_SEARCH_PROVIDER_METADATA: SearchProviderLegacyUiMetadata =
|
||||
createLegacySearchProviderMetadata({
|
||||
provider: "brave",
|
||||
label: "Brave Search",
|
||||
hint: "Structured results · country/language/time filters",
|
||||
envKeys: ["BRAVE_API_KEY"],
|
||||
placeholder: "BSA...",
|
||||
signupUrl: "https://brave.com/search/api/",
|
||||
apiKeyConfigPath: "tools.web.search.apiKey",
|
||||
});
|
||||
|
||||
export function createBundledBraveSearchProvider(): SearchProviderPlugin {
|
||||
return {
|
||||
|
||||
@@ -1,14 +1,15 @@
|
||||
import {
|
||||
buildSearchRequestCacheIdentity,
|
||||
createLegacySearchProviderMetadata,
|
||||
createMissingSearchKeyPayload,
|
||||
normalizeCacheKey,
|
||||
normalizeSecretInput,
|
||||
readCache,
|
||||
readResponseText,
|
||||
readSearchProviderApiKeyValue,
|
||||
rejectUnsupportedSearchFilters,
|
||||
resolveCitationRedirectUrl,
|
||||
resolveSearchConfig,
|
||||
resolveSearchProviderSectionConfig,
|
||||
type OpenClawConfig,
|
||||
type SearchProviderExecutionResult,
|
||||
type SearchProviderLegacyUiMetadata,
|
||||
@@ -16,7 +17,6 @@ import {
|
||||
withTrustedWebToolsEndpoint,
|
||||
wrapWebContent,
|
||||
writeCache,
|
||||
writeSearchProviderApiKeyValue,
|
||||
} from "openclaw/plugin-sdk/web-search";
|
||||
|
||||
const DEFAULT_GEMINI_MODEL = "gemini-2.5-flash";
|
||||
@@ -62,10 +62,10 @@ type GeminiGroundingResponse = {
|
||||
};
|
||||
|
||||
function resolveGeminiConfig(search?: WebSearchConfig): GeminiConfig {
|
||||
if (!search || typeof search !== "object") return {};
|
||||
const gemini = "gemini" in search ? search.gemini : undefined;
|
||||
if (!gemini || typeof gemini !== "object") return {};
|
||||
return gemini as GeminiConfig;
|
||||
return resolveSearchProviderSectionConfig<GeminiConfig>(
|
||||
search as Record<string, unknown> | undefined,
|
||||
"gemini",
|
||||
);
|
||||
}
|
||||
|
||||
function resolveGeminiApiKey(gemini?: GeminiConfig): string | undefined {
|
||||
@@ -156,17 +156,16 @@ async function runGeminiSearch(params: {
|
||||
);
|
||||
}
|
||||
|
||||
export const GEMINI_SEARCH_PROVIDER_METADATA: SearchProviderLegacyUiMetadata = {
|
||||
label: "Gemini (Google Search)",
|
||||
hint: "Google Search grounding · AI-synthesized",
|
||||
envKeys: ["GEMINI_API_KEY"],
|
||||
placeholder: "AIza...",
|
||||
signupUrl: "https://aistudio.google.com/apikey",
|
||||
apiKeyConfigPath: "tools.web.search.gemini.apiKey",
|
||||
readApiKeyValue: (search) => readSearchProviderApiKeyValue(search, "gemini"),
|
||||
writeApiKeyValue: (search, value) =>
|
||||
writeSearchProviderApiKeyValue({ search, provider: "gemini", value }),
|
||||
};
|
||||
export const GEMINI_SEARCH_PROVIDER_METADATA: SearchProviderLegacyUiMetadata =
|
||||
createLegacySearchProviderMetadata({
|
||||
provider: "gemini",
|
||||
label: "Gemini (Google Search)",
|
||||
hint: "Google Search grounding · AI-synthesized",
|
||||
envKeys: ["GEMINI_API_KEY"],
|
||||
placeholder: "AIza...",
|
||||
signupUrl: "https://aistudio.google.com/apikey",
|
||||
apiKeyConfigPath: "tools.web.search.gemini.apiKey",
|
||||
});
|
||||
|
||||
export function createBundledGeminiSearchProvider(): SearchProviderPlugin {
|
||||
return {
|
||||
|
||||
@@ -1,12 +1,13 @@
|
||||
import {
|
||||
buildSearchRequestCacheIdentity,
|
||||
createLegacySearchProviderMetadata,
|
||||
createMissingSearchKeyPayload,
|
||||
normalizeCacheKey,
|
||||
normalizeSecretInput,
|
||||
readCache,
|
||||
readSearchProviderApiKeyValue,
|
||||
rejectUnsupportedSearchFilters,
|
||||
resolveSearchConfig,
|
||||
resolveSearchProviderSectionConfig,
|
||||
throwWebSearchApiError,
|
||||
type OpenClawConfig,
|
||||
type SearchProviderExecutionResult,
|
||||
@@ -15,7 +16,6 @@ import {
|
||||
withTrustedWebToolsEndpoint,
|
||||
wrapWebContent,
|
||||
writeCache,
|
||||
writeSearchProviderApiKeyValue,
|
||||
} from "openclaw/plugin-sdk/web-search";
|
||||
|
||||
const XAI_API_ENDPOINT = "https://api.x.ai/v1/responses";
|
||||
@@ -67,10 +67,10 @@ type GrokSearchResponse = {
|
||||
};
|
||||
|
||||
function resolveGrokConfig(search?: WebSearchConfig): GrokConfig {
|
||||
if (!search || typeof search !== "object") return {};
|
||||
const grok = "grok" in search ? search.grok : undefined;
|
||||
if (!grok || typeof grok !== "object") return {};
|
||||
return grok as GrokConfig;
|
||||
return resolveSearchProviderSectionConfig<GrokConfig>(
|
||||
search as Record<string, unknown> | undefined,
|
||||
"grok",
|
||||
);
|
||||
}
|
||||
|
||||
function resolveGrokApiKey(grok?: GrokConfig): string | undefined {
|
||||
@@ -164,17 +164,16 @@ async function runGrokSearch(params: {
|
||||
);
|
||||
}
|
||||
|
||||
export const GROK_SEARCH_PROVIDER_METADATA: SearchProviderLegacyUiMetadata = {
|
||||
label: "Grok (xAI)",
|
||||
hint: "xAI web-grounded responses",
|
||||
envKeys: ["XAI_API_KEY"],
|
||||
placeholder: "xai-...",
|
||||
signupUrl: "https://console.x.ai/",
|
||||
apiKeyConfigPath: "tools.web.search.grok.apiKey",
|
||||
readApiKeyValue: (search) => readSearchProviderApiKeyValue(search, "grok"),
|
||||
writeApiKeyValue: (search, value) =>
|
||||
writeSearchProviderApiKeyValue({ search, provider: "grok", value }),
|
||||
};
|
||||
export const GROK_SEARCH_PROVIDER_METADATA: SearchProviderLegacyUiMetadata =
|
||||
createLegacySearchProviderMetadata({
|
||||
provider: "grok",
|
||||
label: "Grok (xAI)",
|
||||
hint: "xAI web-grounded responses",
|
||||
envKeys: ["XAI_API_KEY"],
|
||||
placeholder: "xai-...",
|
||||
signupUrl: "https://console.x.ai/",
|
||||
apiKeyConfigPath: "tools.web.search.grok.apiKey",
|
||||
});
|
||||
|
||||
export function createBundledGrokSearchProvider(): SearchProviderPlugin {
|
||||
return {
|
||||
|
||||
@@ -1,12 +1,13 @@
|
||||
import {
|
||||
buildSearchRequestCacheIdentity,
|
||||
createLegacySearchProviderMetadata,
|
||||
createMissingSearchKeyPayload,
|
||||
normalizeCacheKey,
|
||||
normalizeSecretInput,
|
||||
readCache,
|
||||
readSearchProviderApiKeyValue,
|
||||
rejectUnsupportedSearchFilters,
|
||||
resolveSearchConfig,
|
||||
resolveSearchProviderSectionConfig,
|
||||
type OpenClawConfig,
|
||||
type SearchProviderExecutionResult,
|
||||
type SearchProviderLegacyUiMetadata,
|
||||
@@ -14,7 +15,6 @@ import {
|
||||
withTrustedWebToolsEndpoint,
|
||||
wrapWebContent,
|
||||
writeCache,
|
||||
writeSearchProviderApiKeyValue,
|
||||
} from "openclaw/plugin-sdk/web-search";
|
||||
|
||||
const DEFAULT_KIMI_BASE_URL = "https://api.moonshot.ai/v1";
|
||||
@@ -67,10 +67,10 @@ type KimiSearchResponse = {
|
||||
};
|
||||
|
||||
function resolveKimiConfig(search?: WebSearchConfig): KimiConfig {
|
||||
if (!search || typeof search !== "object") return {};
|
||||
const kimi = "kimi" in search ? search.kimi : undefined;
|
||||
if (!kimi || typeof kimi !== "object") return {};
|
||||
return kimi as KimiConfig;
|
||||
return resolveSearchProviderSectionConfig<KimiConfig>(
|
||||
search as Record<string, unknown> | undefined,
|
||||
"kimi",
|
||||
);
|
||||
}
|
||||
|
||||
function resolveKimiApiKey(kimi?: KimiConfig): string | undefined {
|
||||
@@ -216,17 +216,16 @@ async function runKimiSearch(params: {
|
||||
};
|
||||
}
|
||||
|
||||
export const KIMI_SEARCH_PROVIDER_METADATA: SearchProviderLegacyUiMetadata = {
|
||||
label: "Kimi (Moonshot)",
|
||||
hint: "Moonshot web search",
|
||||
envKeys: ["KIMI_API_KEY", "MOONSHOT_API_KEY"],
|
||||
placeholder: "sk-...",
|
||||
signupUrl: "https://platform.moonshot.cn/",
|
||||
apiKeyConfigPath: "tools.web.search.kimi.apiKey",
|
||||
readApiKeyValue: (search) => readSearchProviderApiKeyValue(search, "kimi"),
|
||||
writeApiKeyValue: (search, value) =>
|
||||
writeSearchProviderApiKeyValue({ search, provider: "kimi", value }),
|
||||
};
|
||||
export const KIMI_SEARCH_PROVIDER_METADATA: SearchProviderLegacyUiMetadata =
|
||||
createLegacySearchProviderMetadata({
|
||||
provider: "kimi",
|
||||
label: "Kimi (Moonshot)",
|
||||
hint: "Moonshot web search",
|
||||
envKeys: ["KIMI_API_KEY", "MOONSHOT_API_KEY"],
|
||||
placeholder: "sk-...",
|
||||
signupUrl: "https://platform.moonshot.cn/",
|
||||
apiKeyConfigPath: "tools.web.search.kimi.apiKey",
|
||||
});
|
||||
|
||||
export function createBundledKimiSearchProvider(): SearchProviderPlugin {
|
||||
return {
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import {
|
||||
buildSearchRequestCacheIdentity,
|
||||
createLegacySearchProviderMetadata,
|
||||
createMissingSearchKeyPayload,
|
||||
createSearchProviderErrorResult,
|
||||
normalizeCacheKey,
|
||||
@@ -7,8 +8,8 @@ import {
|
||||
normalizeResolvedSecretInputString,
|
||||
normalizeSecretInput,
|
||||
readCache,
|
||||
readSearchProviderApiKeyValue,
|
||||
resolveSearchConfig,
|
||||
resolveSearchProviderSectionConfig,
|
||||
resolveSiteName,
|
||||
throwWebSearchApiError,
|
||||
type OpenClawConfig,
|
||||
@@ -18,7 +19,6 @@ import {
|
||||
withTrustedWebToolsEndpoint,
|
||||
wrapWebContent,
|
||||
writeCache,
|
||||
writeSearchProviderApiKeyValue,
|
||||
} from "openclaw/plugin-sdk/web-search";
|
||||
|
||||
const DEFAULT_PERPLEXITY_BASE_URL = "https://openrouter.ai/api/v1";
|
||||
@@ -112,14 +112,10 @@ function extractPerplexityCitations(data: PerplexitySearchResponse): string[] {
|
||||
}
|
||||
|
||||
function resolvePerplexityConfig(search?: WebSearchConfig): PerplexityConfig {
|
||||
if (!search || typeof search !== "object") {
|
||||
return {};
|
||||
}
|
||||
const perplexity = "perplexity" in search ? search.perplexity : undefined;
|
||||
if (!perplexity || typeof perplexity !== "object") {
|
||||
return {};
|
||||
}
|
||||
return perplexity as PerplexityConfig;
|
||||
return resolveSearchProviderSectionConfig<PerplexityConfig>(
|
||||
search as Record<string, unknown> | undefined,
|
||||
"perplexity",
|
||||
);
|
||||
}
|
||||
|
||||
function resolvePerplexityApiKey(perplexity?: PerplexityConfig): {
|
||||
@@ -380,22 +376,21 @@ function createPerplexityPayload(params: {
|
||||
return payload;
|
||||
}
|
||||
|
||||
export const PERPLEXITY_SEARCH_PROVIDER_METADATA: SearchProviderLegacyUiMetadata = {
|
||||
label: "Perplexity Search",
|
||||
hint: "Structured results · domain/country/language/time filters",
|
||||
envKeys: ["PERPLEXITY_API_KEY"],
|
||||
placeholder: "pplx-...",
|
||||
signupUrl: "https://www.perplexity.ai/settings/api",
|
||||
apiKeyConfigPath: "tools.web.search.perplexity.apiKey",
|
||||
readApiKeyValue: (search) => readSearchProviderApiKeyValue(search, "perplexity"),
|
||||
writeApiKeyValue: (search, value) =>
|
||||
writeSearchProviderApiKeyValue({ search, provider: "perplexity", value }),
|
||||
resolveRuntimeMetadata: (params) => ({
|
||||
perplexityTransport: resolvePerplexityTransport(
|
||||
resolvePerplexityConfig(resolveSearchConfig<WebSearchConfig>(params.search)),
|
||||
).transport,
|
||||
}),
|
||||
};
|
||||
export const PERPLEXITY_SEARCH_PROVIDER_METADATA: SearchProviderLegacyUiMetadata =
|
||||
createLegacySearchProviderMetadata({
|
||||
provider: "perplexity",
|
||||
label: "Perplexity Search",
|
||||
hint: "Structured results · domain/country/language/time filters",
|
||||
envKeys: ["PERPLEXITY_API_KEY"],
|
||||
placeholder: "pplx-...",
|
||||
signupUrl: "https://www.perplexity.ai/settings/api",
|
||||
apiKeyConfigPath: "tools.web.search.perplexity.apiKey",
|
||||
resolveRuntimeMetadata: (params) => ({
|
||||
perplexityTransport: resolvePerplexityTransport(
|
||||
resolvePerplexityConfig(resolveSearchConfig<WebSearchConfig>(params.search)),
|
||||
).transport,
|
||||
}),
|
||||
});
|
||||
|
||||
export function createBundledPerplexitySearchProvider(): SearchProviderPlugin {
|
||||
return {
|
||||
|
||||
12
pnpm-lock.yaml
generated
12
pnpm-lock.yaml
generated
@@ -456,6 +456,16 @@ importers:
|
||||
|
||||
extensions/sglang: {}
|
||||
|
||||
extensions/search-brave: {}
|
||||
|
||||
extensions/search-gemini: {}
|
||||
|
||||
extensions/search-grok: {}
|
||||
|
||||
extensions/search-kimi: {}
|
||||
|
||||
extensions/search-perplexity: {}
|
||||
|
||||
extensions/signal: {}
|
||||
|
||||
extensions/slack: {}
|
||||
@@ -466,6 +476,8 @@ importers:
|
||||
specifier: ^4.3.6
|
||||
version: 4.3.6
|
||||
|
||||
extensions/tavily-search: {}
|
||||
|
||||
extensions/telegram: {}
|
||||
|
||||
extensions/tlon:
|
||||
|
||||
@@ -1513,7 +1513,7 @@ async function _runBraveLlmContextSearch(params: {
|
||||
}>;
|
||||
sources?: BraveLlmContextResponse["sources"];
|
||||
}> {
|
||||
const url = new URL(BRAVE_LLM_CONTEXT_ENDPOINT);
|
||||
const url = new URL(_BRAVE_LLM_CONTEXT_ENDPOINT);
|
||||
url.searchParams.set("q", params.query);
|
||||
if (params.country) {
|
||||
url.searchParams.set("country", params.country);
|
||||
|
||||
@@ -29,6 +29,9 @@ const loadPluginManifestRegistry = vi.hoisted(() =>
|
||||
const ensureOnboardingPluginInstalled = vi.hoisted(() =>
|
||||
vi.fn(async ({ cfg }: { cfg: OpenClawConfig }) => ({ cfg, installed: false })),
|
||||
);
|
||||
const ensureGenericOnboardingPluginInstalled = vi.hoisted(() =>
|
||||
vi.fn(async ({ cfg }: { cfg: OpenClawConfig }) => ({ cfg, installed: false })),
|
||||
);
|
||||
const reloadOnboardingPluginRegistry = vi.hoisted(() => vi.fn());
|
||||
|
||||
vi.mock("@clack/prompts", () => ({
|
||||
@@ -63,6 +66,7 @@ vi.mock("../plugins/manifest-registry.js", () => ({
|
||||
}));
|
||||
|
||||
vi.mock("./onboarding/plugin-install.js", () => ({
|
||||
ensureGenericOnboardingPluginInstalled,
|
||||
ensureOnboardingPluginInstalled,
|
||||
reloadOnboardingPluginRegistry,
|
||||
}));
|
||||
@@ -160,6 +164,13 @@ describe("runConfigureWizard", () => {
|
||||
installed: false,
|
||||
}),
|
||||
);
|
||||
ensureGenericOnboardingPluginInstalled.mockReset();
|
||||
ensureGenericOnboardingPluginInstalled.mockImplementation(
|
||||
async ({ cfg }: { cfg: OpenClawConfig }) => ({
|
||||
cfg,
|
||||
installed: false,
|
||||
}),
|
||||
);
|
||||
reloadOnboardingPluginRegistry.mockReset();
|
||||
});
|
||||
|
||||
@@ -226,7 +237,7 @@ describe("runConfigureWizard", () => {
|
||||
mocks.clackOutro.mockResolvedValue(undefined);
|
||||
mocks.clackConfirm.mockResolvedValueOnce(true).mockResolvedValueOnce(true);
|
||||
mocks.clackSelect.mockImplementation(async (params: { message: string }) => {
|
||||
if (params.message === "Choose active web search provider") {
|
||||
if (params.message === "Choose web search provider") {
|
||||
return "tavily";
|
||||
}
|
||||
if (params.message.startsWith("Search depth")) {
|
||||
@@ -251,6 +262,7 @@ describe("runConfigureWizard", () => {
|
||||
web: expect.objectContaining({
|
||||
search: expect.objectContaining({
|
||||
enabled: true,
|
||||
provider: "tavily",
|
||||
}),
|
||||
}),
|
||||
}),
|
||||
@@ -305,10 +317,7 @@ describe("runConfigureWizard", () => {
|
||||
mocks.clackOutro.mockResolvedValue(undefined);
|
||||
mocks.clackConfirm.mockResolvedValueOnce(true).mockResolvedValueOnce(false);
|
||||
mocks.clackSelect.mockImplementation(async (params: { message: string }) => {
|
||||
if (params.message === "Web search setup") {
|
||||
return "__configure_provider__";
|
||||
}
|
||||
if (params.message === "Choose provider to configure") {
|
||||
if (params.message === "Choose web search provider") {
|
||||
return "brave";
|
||||
}
|
||||
return "__continue";
|
||||
@@ -402,7 +411,7 @@ describe("runConfigureWizard", () => {
|
||||
mocks.clackOutro.mockResolvedValue(undefined);
|
||||
mocks.clackConfirm.mockResolvedValueOnce(true).mockResolvedValueOnce(true);
|
||||
mocks.clackSelect.mockImplementation(async (params: { message: string }) => {
|
||||
if (params.message === "Choose active web search provider") {
|
||||
if (params.message === "Choose web search provider") {
|
||||
return "tavily";
|
||||
}
|
||||
if (params.message.startsWith("Search depth")) {
|
||||
@@ -497,6 +506,7 @@ describe("runConfigureWizard", () => {
|
||||
id: "tavily-search",
|
||||
name: "Tavily Search",
|
||||
description: "Search the web using Tavily.",
|
||||
provides: ["providers.search.tavily"],
|
||||
origin: "bundled",
|
||||
source: "/tmp/bundled/tavily-search",
|
||||
configSchema: {
|
||||
@@ -561,7 +571,7 @@ describe("runConfigureWizard", () => {
|
||||
mocks.clackOutro.mockResolvedValue(undefined);
|
||||
mocks.clackConfirm.mockResolvedValueOnce(true).mockResolvedValueOnce(true);
|
||||
mocks.clackSelect.mockImplementation(async (params: { message: string }) => {
|
||||
if (params.message === "Choose active web search provider") {
|
||||
if (params.message === "Choose web search provider") {
|
||||
return "tavily";
|
||||
}
|
||||
if (params.message.startsWith("Search depth")) {
|
||||
@@ -777,11 +787,11 @@ describe("runConfigureWizard", () => {
|
||||
if (params.message === "Choose web search provider") {
|
||||
expect(params.options?.[0]).toMatchObject({
|
||||
value: "tavily",
|
||||
hint: "Plugin search · External plugin · Configured · current",
|
||||
hint: "Plugin search · External plugin",
|
||||
});
|
||||
expect(params.options?.[1]).toMatchObject({
|
||||
value: "brave",
|
||||
hint: "Structured results · country/language/time filters · Configured",
|
||||
value: "__install_plugin__",
|
||||
hint: "Add an external web search plugin",
|
||||
});
|
||||
return "tavily";
|
||||
}
|
||||
|
||||
@@ -196,7 +196,7 @@ async function promptWebToolsConfig(
|
||||
note(
|
||||
[
|
||||
"Web search lets your agent look things up online using the `web_search` tool.",
|
||||
"Choose a provider and enter the required built-in or plugin settings.",
|
||||
"Choose a provider and enter the required settings.",
|
||||
"Docs: https://docs.openclaw.ai/tools/web",
|
||||
].join("\n"),
|
||||
"Web search",
|
||||
|
||||
@@ -12,6 +12,9 @@ const loadPluginManifestRegistry = vi.hoisted(() =>
|
||||
const ensureOnboardingPluginInstalled = vi.hoisted(() =>
|
||||
vi.fn(async ({ cfg }: { cfg: OpenClawConfig }) => ({ cfg, installed: false })),
|
||||
);
|
||||
const ensureGenericOnboardingPluginInstalled = vi.hoisted(() =>
|
||||
vi.fn(async ({ cfg }: { cfg: OpenClawConfig }) => ({ cfg, installed: false })),
|
||||
);
|
||||
const reloadOnboardingPluginRegistry = vi.hoisted(() => vi.fn());
|
||||
|
||||
vi.mock("../plugins/loader.js", () => ({
|
||||
@@ -23,6 +26,7 @@ vi.mock("../plugins/manifest-registry.js", () => ({
|
||||
}));
|
||||
|
||||
vi.mock("./onboarding/plugin-install.js", () => ({
|
||||
ensureGenericOnboardingPluginInstalled,
|
||||
ensureOnboardingPluginInstalled,
|
||||
reloadOnboardingPluginRegistry,
|
||||
}));
|
||||
@@ -37,11 +41,7 @@ const runtime: RuntimeEnv = {
|
||||
}) as RuntimeEnv["exit"],
|
||||
};
|
||||
|
||||
function createPrompter(params: {
|
||||
selectValue?: string;
|
||||
actionValue?: string;
|
||||
textValue?: string;
|
||||
}): {
|
||||
function createPrompter(params: { selectValue?: string; textValue?: string }): {
|
||||
prompter: WizardPrompter;
|
||||
notes: Array<{ title?: string; message: string }>;
|
||||
} {
|
||||
@@ -52,12 +52,9 @@ function createPrompter(params: {
|
||||
note: vi.fn(async (message: string, title?: string) => {
|
||||
notes.push({ title, message });
|
||||
}),
|
||||
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"],
|
||||
select: vi.fn(
|
||||
async () => 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),
|
||||
@@ -126,6 +123,13 @@ describe("setupSearch", () => {
|
||||
installed: false,
|
||||
}),
|
||||
);
|
||||
ensureGenericOnboardingPluginInstalled.mockReset();
|
||||
ensureGenericOnboardingPluginInstalled.mockImplementation(
|
||||
async ({ cfg }: { cfg: OpenClawConfig }) => ({
|
||||
cfg,
|
||||
installed: false,
|
||||
}),
|
||||
);
|
||||
reloadOnboardingPluginRegistry.mockReset();
|
||||
});
|
||||
|
||||
@@ -162,7 +166,7 @@ describe("setupSearch", () => {
|
||||
await setupSearch(cfg, runtime, prompter);
|
||||
|
||||
const providerSelectCall = (prompter.select as ReturnType<typeof vi.fn>).mock.calls.find(
|
||||
(call) => call[0]?.message === "Choose active web search provider",
|
||||
(call) => call[0]?.message === "Choose web search provider",
|
||||
);
|
||||
expect(providerSelectCall?.[0]).toEqual(
|
||||
expect.objectContaining({
|
||||
@@ -182,7 +186,7 @@ describe("setupSearch", () => {
|
||||
);
|
||||
});
|
||||
|
||||
it("shows bundled plugin providers directly in the picker instead of the external install path", async () => {
|
||||
it("shows bundled plugin providers directly in the picker while keeping the external install path available", async () => {
|
||||
loadOpenClawPlugins.mockReturnValue({
|
||||
searchProviders: [],
|
||||
plugins: [],
|
||||
@@ -194,6 +198,7 @@ describe("setupSearch", () => {
|
||||
id: "tavily-search",
|
||||
name: "Tavily Search",
|
||||
description: "Search the web using Tavily.",
|
||||
provides: ["providers.search.tavily"],
|
||||
origin: "bundled",
|
||||
source: "/tmp/bundled/tavily-search",
|
||||
configSchema: {
|
||||
@@ -220,7 +225,7 @@ describe("setupSearch", () => {
|
||||
await setupSearch(cfg, runtime, prompter);
|
||||
|
||||
const providerSelectCall = (prompter.select as ReturnType<typeof vi.fn>).mock.calls.find(
|
||||
(call) => call[0]?.message === "Choose active web search provider",
|
||||
(call) => call[0]?.message === "Choose web search provider",
|
||||
);
|
||||
expect(providerSelectCall?.[0]).toEqual(
|
||||
expect.objectContaining({
|
||||
@@ -233,10 +238,11 @@ describe("setupSearch", () => {
|
||||
]),
|
||||
}),
|
||||
);
|
||||
expect(providerSelectCall?.[0]?.options).not.toEqual(
|
||||
expect(providerSelectCall?.[0]?.options).toEqual(
|
||||
expect.arrayContaining([
|
||||
expect.objectContaining({
|
||||
value: "__install_plugin__",
|
||||
label: "Install external provider plugin",
|
||||
}),
|
||||
]),
|
||||
);
|
||||
@@ -288,7 +294,7 @@ describe("setupSearch", () => {
|
||||
await setupSearch(cfg, runtime, prompter);
|
||||
|
||||
const providerSelectCall = (prompter.select as ReturnType<typeof vi.fn>).mock.calls.find(
|
||||
(call) => call[0]?.message === "Choose active web search provider",
|
||||
(call) => call[0]?.message === "Choose web search provider",
|
||||
);
|
||||
const matchingOptions =
|
||||
providerSelectCall?.[0]?.options?.filter(
|
||||
@@ -346,7 +352,6 @@ describe("setupSearch", () => {
|
||||
},
|
||||
};
|
||||
const { prompter } = createPrompter({
|
||||
actionValue: "__skip__",
|
||||
selectValue: "__skip__",
|
||||
});
|
||||
|
||||
@@ -354,11 +359,11 @@ describe("setupSearch", () => {
|
||||
|
||||
expect(prompter.select).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
message: "Web search setup",
|
||||
message: "Choose web search provider",
|
||||
options: expect.arrayContaining([
|
||||
expect.objectContaining({
|
||||
value: "__configure_provider__",
|
||||
label: "Configure or install a provider",
|
||||
value: "__install_plugin__",
|
||||
label: "Install external provider plugin",
|
||||
}),
|
||||
]),
|
||||
}),
|
||||
@@ -457,7 +462,7 @@ describe("setupSearch", () => {
|
||||
await setupSearch(cfg, runtime, prompter);
|
||||
|
||||
const options = (prompter.select as ReturnType<typeof vi.fn>).mock.calls.find(
|
||||
(call) => call[0]?.message === "Choose active web search provider",
|
||||
(call) => call[0]?.message === "Choose web search provider",
|
||||
)?.[0]?.options;
|
||||
expect(options[0]).toMatchObject({
|
||||
value: "tavily",
|
||||
@@ -517,14 +522,13 @@ describe("setupSearch", () => {
|
||||
},
|
||||
};
|
||||
const { prompter } = createPrompter({
|
||||
actionValue: "__configure_provider__",
|
||||
selectValue: "__skip__",
|
||||
});
|
||||
|
||||
await setupSearch(cfg, runtime, prompter);
|
||||
|
||||
const configurePickerCall = (prompter.select as ReturnType<typeof vi.fn>).mock.calls.find(
|
||||
(call) => call[0]?.message === "Choose provider to configure",
|
||||
(call) => call[0]?.message === "Choose web search provider",
|
||||
);
|
||||
expect(configurePickerCall?.[0]).toEqual(
|
||||
expect.objectContaining({
|
||||
@@ -726,7 +730,6 @@ describe("setupSearch", () => {
|
||||
});
|
||||
|
||||
const { prompter, notes } = createPrompter({
|
||||
actionValue: "__configure_provider__",
|
||||
selectValue: "tavily",
|
||||
textValue: "tvly-test-key",
|
||||
});
|
||||
@@ -811,7 +814,6 @@ describe("setupSearch", () => {
|
||||
};
|
||||
|
||||
const { prompter } = createPrompter({
|
||||
actionValue: "__switch_active__",
|
||||
selectValue: "tavily",
|
||||
});
|
||||
|
||||
@@ -894,7 +896,6 @@ describe("setupSearch", () => {
|
||||
};
|
||||
|
||||
const { prompter } = createPrompter({
|
||||
actionValue: "__switch_active__",
|
||||
selectValue: "tavily",
|
||||
});
|
||||
|
||||
@@ -1046,7 +1047,6 @@ describe("setupSearch", () => {
|
||||
};
|
||||
|
||||
const { prompter, notes } = createPrompter({
|
||||
actionValue: "__switch_active__",
|
||||
selectValue: "tavily",
|
||||
textValue: "tvly-valid-key",
|
||||
});
|
||||
@@ -1146,17 +1146,17 @@ describe("setupSearch", () => {
|
||||
});
|
||||
});
|
||||
|
||||
it("installs a search plugin from the shared catalog and continues provider setup", async () => {
|
||||
it("installs an external search plugin and continues provider setup for the discovered provider", async () => {
|
||||
loadOpenClawPlugins.mockImplementation(({ config }: { config: OpenClawConfig }) => {
|
||||
const enabled = config.plugins?.entries?.["tavily-search"]?.enabled === true;
|
||||
const enabled = config.plugins?.entries?.["external-search"]?.enabled === true;
|
||||
return enabled
|
||||
? {
|
||||
searchProviders: [
|
||||
{
|
||||
pluginId: "tavily-search",
|
||||
pluginId: "external-search",
|
||||
provider: {
|
||||
id: "tavily",
|
||||
name: "Tavily Search",
|
||||
id: "external-search",
|
||||
name: "External Search",
|
||||
description: "Plugin search",
|
||||
configFieldOrder: ["apiKey", "searchDepth"],
|
||||
search: async () => ({ content: "ok" }),
|
||||
@@ -1165,11 +1165,11 @@ describe("setupSearch", () => {
|
||||
],
|
||||
plugins: [
|
||||
{
|
||||
id: "tavily-search",
|
||||
name: "Tavily Search",
|
||||
description: "External Tavily plugin",
|
||||
id: "external-search",
|
||||
name: "External Search",
|
||||
description: "External search plugin",
|
||||
origin: "workspace",
|
||||
source: "/tmp/tavily-search",
|
||||
source: "/tmp/external-search",
|
||||
configJsonSchema: {
|
||||
type: "object",
|
||||
properties: {
|
||||
@@ -1193,7 +1193,7 @@ describe("setupSearch", () => {
|
||||
}
|
||||
: { searchProviders: [], plugins: [], typedHooks: [] };
|
||||
});
|
||||
ensureOnboardingPluginInstalled.mockImplementation(
|
||||
ensureGenericOnboardingPluginInstalled.mockImplementation(
|
||||
async ({ cfg }: { cfg: OpenClawConfig }) => ({
|
||||
cfg: {
|
||||
...cfg,
|
||||
@@ -1201,14 +1201,17 @@ describe("setupSearch", () => {
|
||||
...cfg.plugins,
|
||||
entries: {
|
||||
...cfg.plugins?.entries,
|
||||
"tavily-search": {
|
||||
...(cfg.plugins?.entries?.["tavily-search"] as Record<string, unknown> | undefined),
|
||||
"external-search": {
|
||||
...(cfg.plugins?.entries?.["external-search"] as
|
||||
| Record<string, unknown>
|
||||
| undefined),
|
||||
enabled: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
installed: true,
|
||||
pluginId: "external-search",
|
||||
}),
|
||||
);
|
||||
|
||||
@@ -1224,15 +1227,8 @@ describe("setupSearch", () => {
|
||||
workspaceDir: "/tmp/workspace-search",
|
||||
});
|
||||
|
||||
expect(ensureOnboardingPluginInstalled).toHaveBeenCalledWith(
|
||||
expect(ensureGenericOnboardingPluginInstalled).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
entry: expect.objectContaining({
|
||||
id: "tavily-search",
|
||||
install: expect.objectContaining({
|
||||
npmSpec: "@openclaw/tavily-search",
|
||||
localPath: "extensions/tavily-search",
|
||||
}),
|
||||
}),
|
||||
workspaceDir: "/tmp/workspace-search",
|
||||
}),
|
||||
);
|
||||
@@ -1241,9 +1237,9 @@ describe("setupSearch", () => {
|
||||
workspaceDir: "/tmp/workspace-search",
|
||||
}),
|
||||
);
|
||||
expect(result.tools?.web?.search?.provider).toBe("tavily");
|
||||
expect(result.plugins?.entries?.["tavily-search"]?.enabled).toBe(true);
|
||||
expect(result.plugins?.entries?.["tavily-search"]?.config).toEqual({
|
||||
expect(result.tools?.web?.search?.provider).toBe("external-search");
|
||||
expect(result.plugins?.entries?.["external-search"]?.enabled).toBe(true);
|
||||
expect(result.plugins?.entries?.["external-search"]?.config).toEqual({
|
||||
apiKey: "tvly-installed-key",
|
||||
searchDepth: "advanced",
|
||||
});
|
||||
@@ -1251,15 +1247,15 @@ 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);
|
||||
const hasApiKey = Boolean(config.plugins?.entries?.["external-search"]?.config?.apiKey);
|
||||
return hasApiKey
|
||||
? {
|
||||
searchProviders: [
|
||||
{
|
||||
pluginId: "tavily-search",
|
||||
pluginId: "external-search",
|
||||
provider: {
|
||||
id: "tavily",
|
||||
name: "Tavily Search",
|
||||
id: "external-search",
|
||||
name: "External Search",
|
||||
description: "Plugin search",
|
||||
configFieldOrder: ["apiKey", "searchDepth"],
|
||||
search: async () => ({ content: "ok" }),
|
||||
@@ -1268,11 +1264,11 @@ describe("setupSearch", () => {
|
||||
],
|
||||
plugins: [
|
||||
{
|
||||
id: "tavily-search",
|
||||
name: "Tavily Search",
|
||||
description: "External Tavily plugin",
|
||||
id: "external-search",
|
||||
name: "External Search",
|
||||
description: "External search plugin",
|
||||
origin: "workspace",
|
||||
source: "/tmp/tavily-search",
|
||||
source: "/tmp/external-search",
|
||||
configJsonSchema: {
|
||||
type: "object",
|
||||
required: ["apiKey"],
|
||||
@@ -1299,11 +1295,11 @@ describe("setupSearch", () => {
|
||||
searchProviders: [],
|
||||
plugins: [
|
||||
{
|
||||
id: "tavily-search",
|
||||
name: "Tavily Search",
|
||||
description: "External Tavily plugin",
|
||||
id: "external-search",
|
||||
name: "External Search",
|
||||
description: "External search plugin",
|
||||
origin: "workspace",
|
||||
source: "/tmp/tavily-search",
|
||||
source: "/tmp/external-search",
|
||||
configJsonSchema: {
|
||||
type: "object",
|
||||
required: ["apiKey"],
|
||||
@@ -1330,11 +1326,12 @@ describe("setupSearch", () => {
|
||||
loadPluginManifestRegistry.mockReturnValue({
|
||||
plugins: [
|
||||
{
|
||||
id: "tavily-search",
|
||||
name: "Tavily Search",
|
||||
description: "External Tavily plugin",
|
||||
id: "external-search",
|
||||
name: "External Search",
|
||||
description: "External search plugin",
|
||||
origin: "workspace",
|
||||
source: "/tmp/tavily-search",
|
||||
source: "/tmp/external-search",
|
||||
provides: ["providers.search.external-search"],
|
||||
configSchema: {
|
||||
type: "object",
|
||||
required: ["apiKey"],
|
||||
@@ -1357,7 +1354,7 @@ describe("setupSearch", () => {
|
||||
],
|
||||
diagnostics: [],
|
||||
});
|
||||
ensureOnboardingPluginInstalled.mockImplementation(
|
||||
ensureGenericOnboardingPluginInstalled.mockImplementation(
|
||||
async ({ cfg }: { cfg: OpenClawConfig }) => ({
|
||||
cfg: {
|
||||
...cfg,
|
||||
@@ -1365,14 +1362,17 @@ describe("setupSearch", () => {
|
||||
...cfg.plugins,
|
||||
entries: {
|
||||
...cfg.plugins?.entries,
|
||||
"tavily-search": {
|
||||
...(cfg.plugins?.entries?.["tavily-search"] as Record<string, unknown> | undefined),
|
||||
"external-search": {
|
||||
...(cfg.plugins?.entries?.["external-search"] as
|
||||
| Record<string, unknown>
|
||||
| undefined),
|
||||
enabled: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
installed: true,
|
||||
pluginId: "external-search",
|
||||
}),
|
||||
);
|
||||
|
||||
@@ -1391,8 +1391,8 @@ describe("setupSearch", () => {
|
||||
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({
|
||||
expect(result.tools?.web?.search?.provider).toBe("external-search");
|
||||
expect(result.plugins?.entries?.["external-search"]?.config).toEqual({
|
||||
apiKey: "tvly-installed-key",
|
||||
searchDepth: "advanced",
|
||||
});
|
||||
|
||||
@@ -1,10 +1,4 @@
|
||||
import {
|
||||
BUILTIN_WEB_SEARCH_PROVIDER_OPTIONS,
|
||||
MIGRATED_BUNDLED_WEB_SEARCH_PROVIDER_IDS,
|
||||
type BuiltinWebSearchProviderEntry,
|
||||
type BuiltinWebSearchProviderId,
|
||||
isBuiltinWebSearchProviderId,
|
||||
} from "../agents/tools/web-search-provider-catalog.js";
|
||||
import { isBuiltinWebSearchProviderId } from "../agents/tools/web-search-provider-catalog.js";
|
||||
import type { OpenClawConfig } from "../config/config.js";
|
||||
import {
|
||||
DEFAULT_SECRET_PROVIDER_ALIAS,
|
||||
@@ -13,10 +7,6 @@ import {
|
||||
hasConfiguredSecretInput,
|
||||
normalizeSecretInputString,
|
||||
} from "../config/types.secrets.js";
|
||||
import {
|
||||
readSearchProviderApiKeyValue,
|
||||
writeSearchProviderApiKeyValue,
|
||||
} from "../plugin-sdk/web-search.js";
|
||||
import {
|
||||
applyCapabilitySlotSelection,
|
||||
resolveCapabilitySlotSelection,
|
||||
@@ -35,12 +25,11 @@ import type { RuntimeEnv } from "../runtime.js";
|
||||
import type { WizardPrompter } from "../wizard/prompts.js";
|
||||
import type { SecretInputMode } from "./onboard-types.js";
|
||||
import {
|
||||
ensureOnboardingPluginInstalled,
|
||||
ensureGenericOnboardingPluginInstalled,
|
||||
reloadOnboardingPluginRegistry,
|
||||
} from "./onboarding/plugin-install.js";
|
||||
import {
|
||||
buildProviderSelectionOptions,
|
||||
promptProviderManagementIntent,
|
||||
type ProviderManagementIntent,
|
||||
} from "./provider-management.js";
|
||||
import {
|
||||
@@ -48,19 +37,11 @@ import {
|
||||
type InstallableSearchProviderPluginCatalogEntry,
|
||||
} from "./search-provider-plugin-catalog.js";
|
||||
|
||||
export type SearchProvider = BuiltinWebSearchProviderId;
|
||||
type SearchProviderEntry = BuiltinWebSearchProviderEntry;
|
||||
export const SEARCH_PROVIDER_OPTIONS = BUILTIN_WEB_SEARCH_PROVIDER_OPTIONS;
|
||||
export type SearchProvider = string;
|
||||
|
||||
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;
|
||||
|
||||
const MIGRATED_BUNDLED_WEB_SEARCH_PROVIDER_ID_SET = new Set<string>(
|
||||
MIGRATED_BUNDLED_WEB_SEARCH_PROVIDER_IDS,
|
||||
);
|
||||
|
||||
type PluginSearchProviderEntry = {
|
||||
kind: "plugin";
|
||||
@@ -78,9 +59,7 @@ type PluginSearchProviderEntry = {
|
||||
legacyConfig?: SearchProviderLegacyConfigMetadata;
|
||||
};
|
||||
|
||||
export type SearchProviderPickerEntry =
|
||||
| (SearchProviderEntry & { kind: "builtin"; configured: boolean })
|
||||
| PluginSearchProviderEntry;
|
||||
export type SearchProviderPickerEntry = PluginSearchProviderEntry;
|
||||
|
||||
type SearchProviderPickerChoice = string;
|
||||
type SearchProviderFlowIntent = ProviderManagementIntent;
|
||||
@@ -119,21 +98,6 @@ type SearchProviderHookDetails = {
|
||||
configured: boolean;
|
||||
};
|
||||
|
||||
function legacyConfigFromBuiltinEntry(
|
||||
entry: SearchProviderEntry,
|
||||
): SearchProviderLegacyConfigMetadata {
|
||||
return {
|
||||
hint: entry.hint,
|
||||
envKeys: entry.envKeys,
|
||||
placeholder: entry.placeholder,
|
||||
signupUrl: entry.signupUrl,
|
||||
apiKeyConfigPath: entry.apiKeyConfigPath,
|
||||
readApiKeyValue: (search) => readSearchProviderApiKeyValue(search, entry.value),
|
||||
writeApiKeyValue: (search, value) =>
|
||||
writeSearchProviderApiKeyValue({ search, provider: entry.value, value }),
|
||||
};
|
||||
}
|
||||
|
||||
const HOOK_RUNNER_LOGGER = {
|
||||
warn: () => {},
|
||||
error: () => {},
|
||||
@@ -546,14 +510,6 @@ export async function resolveSearchProviderPickerEntries(
|
||||
config: OpenClawConfig,
|
||||
workspaceDir?: string,
|
||||
): Promise<SearchProviderPickerEntry[]> {
|
||||
const builtins: SearchProviderPickerEntry[] = SEARCH_PROVIDER_OPTIONS.filter(
|
||||
(entry) => !MIGRATED_BUNDLED_WEB_SEARCH_PROVIDER_ID_SET.has(entry.value),
|
||||
).map((entry) => ({
|
||||
...entry,
|
||||
kind: "builtin",
|
||||
configured: hasExistingKey(config, legacyConfigFromBuiltinEntry(entry)) || hasKeyInEnv(entry),
|
||||
}));
|
||||
|
||||
let pluginEntries: PluginSearchProviderEntry[] = [];
|
||||
try {
|
||||
const registry = loadOpenClawPlugins({
|
||||
@@ -601,15 +557,7 @@ export async function resolveSearchProviderPickerEntries(
|
||||
legacyConfig: registration.provider.legacyConfig,
|
||||
};
|
||||
})
|
||||
.filter((entry) => {
|
||||
if (!entry) {
|
||||
return false;
|
||||
}
|
||||
return !(
|
||||
entry.origin === "bundled" &&
|
||||
!MIGRATED_BUNDLED_WEB_SEARCH_PROVIDER_ID_SET.has(entry.value)
|
||||
);
|
||||
})
|
||||
.filter(Boolean)
|
||||
.filter(Boolean) as PluginSearchProviderEntry[];
|
||||
pluginEntries = resolvedPluginEntries.toSorted((left, right) =>
|
||||
left.label.localeCompare(right.label),
|
||||
@@ -651,7 +599,7 @@ export async function resolveSearchProviderPickerEntries(
|
||||
// Ignore manifest lookup failures and fall back to loaded entries only.
|
||||
}
|
||||
|
||||
return [...builtins, ...pluginEntries];
|
||||
return pluginEntries;
|
||||
}
|
||||
|
||||
export async function resolveSearchProviderPickerEntry(
|
||||
@@ -663,6 +611,48 @@ export async function resolveSearchProviderPickerEntry(
|
||||
return entries.find((entry) => entry.value === providerId);
|
||||
}
|
||||
|
||||
function searchProviderIdFromProvides(provides: string[]): string | undefined {
|
||||
return provides
|
||||
.find((capability) => capability.startsWith("providers.search."))
|
||||
?.slice("providers.search.".length);
|
||||
}
|
||||
|
||||
function buildPluginSearchProviderEntryFromManifestRecord(pluginRecord: {
|
||||
id: string;
|
||||
name?: string;
|
||||
description?: string;
|
||||
origin: PluginOrigin;
|
||||
configSchema?: Record<string, unknown>;
|
||||
configUiHints?: Record<string, PluginConfigUiHint>;
|
||||
provides: string[];
|
||||
}): PluginSearchProviderEntry | undefined {
|
||||
const providerId = searchProviderIdFromProvides(pluginRecord.provides);
|
||||
if (!providerId) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
const hintParts = [
|
||||
pluginRecord.description || "Plugin-provided web search",
|
||||
formatPluginSourceHint(pluginRecord.origin),
|
||||
];
|
||||
|
||||
return {
|
||||
kind: "plugin",
|
||||
value: providerId,
|
||||
label: pluginRecord.name || providerId,
|
||||
hint: hintParts.join(" · "),
|
||||
configured: false,
|
||||
pluginId: pluginRecord.id,
|
||||
origin: pluginRecord.origin,
|
||||
description: pluginRecord.description,
|
||||
docsUrl: undefined,
|
||||
configFieldOrder: undefined,
|
||||
configJsonSchema: pluginRecord.configSchema,
|
||||
configUiHints: pluginRecord.configUiHints,
|
||||
legacyConfig: undefined,
|
||||
};
|
||||
}
|
||||
|
||||
function buildPluginSearchProviderEntryFromManifest(params: {
|
||||
config: OpenClawConfig;
|
||||
installEntry: InstallableSearchProviderPluginCatalogEntry;
|
||||
@@ -677,74 +667,23 @@ function buildPluginSearchProviderEntryFromManifest(params: {
|
||||
if (!pluginRecord) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
return {
|
||||
kind: "plugin",
|
||||
value: params.installEntry.providerId,
|
||||
label: params.installEntry.meta.label,
|
||||
hint: [
|
||||
pluginRecord.description || "Plugin-provided web search",
|
||||
formatPluginSourceHint(pluginRecord.origin),
|
||||
].join(" · "),
|
||||
configured: false,
|
||||
pluginId: pluginRecord.id,
|
||||
origin: pluginRecord.origin,
|
||||
description: pluginRecord.description,
|
||||
docsUrl: undefined,
|
||||
configFieldOrder: undefined,
|
||||
configJsonSchema: pluginRecord.configSchema,
|
||||
configUiHints: pluginRecord.configUiHints,
|
||||
};
|
||||
}
|
||||
|
||||
async function promptSearchProviderPluginInstallChoice(
|
||||
installableEntries: InstallableSearchProviderPluginCatalogEntry[],
|
||||
prompter: WizardPrompter,
|
||||
): Promise<InstallableSearchProviderPluginCatalogEntry | undefined> {
|
||||
if (installableEntries.length === 0) {
|
||||
return undefined;
|
||||
}
|
||||
if (installableEntries.length === 1) {
|
||||
return installableEntries[0];
|
||||
}
|
||||
const choice = await prompter.select<string>({
|
||||
message: "Choose provider plugin to install",
|
||||
options: [
|
||||
...installableEntries.map((entry) => ({
|
||||
value: entry.providerId,
|
||||
label: entry.meta.label,
|
||||
hint: entry.description,
|
||||
})),
|
||||
{
|
||||
value: SEARCH_PROVIDER_SKIP_SENTINEL,
|
||||
label: "Skip for now",
|
||||
hint: "Keep the current search setup unchanged",
|
||||
},
|
||||
],
|
||||
initialValue: installableEntries[0]?.providerId ?? SEARCH_PROVIDER_SKIP_SENTINEL,
|
||||
});
|
||||
if (choice === SEARCH_PROVIDER_SKIP_SENTINEL) {
|
||||
return undefined;
|
||||
}
|
||||
return installableEntries.find((entry) => entry.providerId === choice);
|
||||
return buildPluginSearchProviderEntryFromManifestRecord(pluginRecord);
|
||||
}
|
||||
|
||||
async function installSearchProviderPlugin(params: {
|
||||
config: OpenClawConfig;
|
||||
entry: InstallableSearchProviderPluginCatalogEntry;
|
||||
runtime: RuntimeEnv;
|
||||
prompter: WizardPrompter;
|
||||
workspaceDir?: string;
|
||||
}): Promise<OpenClawConfig> {
|
||||
const result = await ensureOnboardingPluginInstalled({
|
||||
}): Promise<{ config: OpenClawConfig; installed: boolean; pluginId?: string }> {
|
||||
const result = await ensureGenericOnboardingPluginInstalled({
|
||||
cfg: params.config,
|
||||
entry: params.entry,
|
||||
prompter: params.prompter,
|
||||
runtime: params.runtime,
|
||||
workspaceDir: params.workspaceDir,
|
||||
});
|
||||
if (!result.installed) {
|
||||
return params.config;
|
||||
return { config: params.config, installed: false };
|
||||
}
|
||||
reloadOnboardingPluginRegistry({
|
||||
cfg: result.cfg,
|
||||
@@ -752,27 +691,39 @@ async function installSearchProviderPlugin(params: {
|
||||
workspaceDir: params.workspaceDir,
|
||||
suppressOpenAllowlistWarning: true,
|
||||
});
|
||||
return result.cfg;
|
||||
return { config: result.cfg, installed: true, pluginId: result.pluginId };
|
||||
}
|
||||
|
||||
async function resolveInstalledSearchProviderEntry(params: {
|
||||
config: OpenClawConfig;
|
||||
installEntry: InstallableSearchProviderPluginCatalogEntry;
|
||||
pluginId?: string;
|
||||
workspaceDir?: string;
|
||||
}): Promise<PluginSearchProviderEntry | undefined> {
|
||||
const installedProvider = await resolveSearchProviderPickerEntry(
|
||||
const providerEntries = await resolveSearchProviderPickerEntries(
|
||||
params.config,
|
||||
params.installEntry.providerId,
|
||||
params.workspaceDir,
|
||||
);
|
||||
if (installedProvider?.kind === "plugin") {
|
||||
return installedProvider;
|
||||
if (params.pluginId) {
|
||||
const loadedProvider = providerEntries.find(
|
||||
(entry) => entry.kind === "plugin" && entry.pluginId === params.pluginId,
|
||||
);
|
||||
if (loadedProvider?.kind === "plugin") {
|
||||
return loadedProvider;
|
||||
}
|
||||
}
|
||||
return buildPluginSearchProviderEntryFromManifest({
|
||||
if (!params.pluginId) {
|
||||
return undefined;
|
||||
}
|
||||
const manifestRegistry = loadPluginManifestRegistry({
|
||||
config: params.config,
|
||||
installEntry: params.installEntry,
|
||||
workspaceDir: params.workspaceDir,
|
||||
cache: false,
|
||||
});
|
||||
const manifestRecord = manifestRegistry.plugins.find((plugin) => plugin.id === params.pluginId);
|
||||
if (!manifestRecord) {
|
||||
return undefined;
|
||||
}
|
||||
return buildPluginSearchProviderEntryFromManifestRecord(manifestRecord);
|
||||
}
|
||||
|
||||
export async function applySearchProviderChoice(params: {
|
||||
@@ -792,44 +743,31 @@ export async function applySearchProviderChoice(params: {
|
||||
}
|
||||
|
||||
if (params.choice === SEARCH_PROVIDER_INSTALL_SENTINEL) {
|
||||
const providerEntries = await resolveSearchProviderPickerEntries(
|
||||
params.config,
|
||||
params.opts?.workspaceDir,
|
||||
);
|
||||
const installableEntries = resolveInstallableSearchProviderPlugins(providerEntries);
|
||||
const selectedInstallEntry = await promptSearchProviderPluginInstallChoice(
|
||||
installableEntries,
|
||||
params.prompter,
|
||||
);
|
||||
if (!selectedInstallEntry) {
|
||||
return params.config;
|
||||
}
|
||||
const installedConfig = await installSearchProviderPlugin({
|
||||
config: params.config,
|
||||
entry: selectedInstallEntry,
|
||||
runtime: params.runtime,
|
||||
prompter: params.prompter,
|
||||
workspaceDir: params.opts?.workspaceDir,
|
||||
});
|
||||
if (installedConfig === params.config) {
|
||||
if (!installedConfig.installed) {
|
||||
return params.config;
|
||||
}
|
||||
const installedProvider = await resolveInstalledSearchProviderEntry({
|
||||
config: installedConfig,
|
||||
installEntry: selectedInstallEntry,
|
||||
config: installedConfig.config,
|
||||
pluginId: installedConfig.pluginId,
|
||||
workspaceDir: params.opts?.workspaceDir,
|
||||
});
|
||||
if (!installedProvider) {
|
||||
await params.prompter.note(
|
||||
[
|
||||
`Installed ${selectedInstallEntry.meta.label}, but OpenClaw could not load its web search provider yet.`,
|
||||
"Restart the gateway and try configure again.",
|
||||
"Installed plugin, but it did not register a web search provider yet.",
|
||||
"Restart the gateway and try configure again if this plugin should provide web search.",
|
||||
].join("\n"),
|
||||
"Plugin install",
|
||||
);
|
||||
return installedConfig;
|
||||
return installedConfig.config;
|
||||
}
|
||||
const enabled = enablePluginInConfig(installedConfig, installedProvider.pluginId);
|
||||
const enabled = enablePluginInConfig(installedConfig.config, installedProvider.pluginId);
|
||||
const hookRunner = createSearchProviderHookRunner(enabled.config, params.opts?.workspaceDir);
|
||||
const providerDetails: SearchProviderHookDetails = {
|
||||
providerId: installedProvider.value,
|
||||
@@ -857,20 +795,20 @@ export async function applySearchProviderChoice(params: {
|
||||
);
|
||||
const result = pluginConfigResult.valid
|
||||
? preserveSearchProviderIntent(
|
||||
installedConfig,
|
||||
installedConfig.config,
|
||||
pluginConfigResult.config,
|
||||
intent,
|
||||
installedProvider.value,
|
||||
)
|
||||
: preserveSearchProviderIntent(
|
||||
installedConfig,
|
||||
installedConfig.config,
|
||||
enabled.config,
|
||||
"configure-provider",
|
||||
installedProvider.value,
|
||||
);
|
||||
await runAfterSearchProviderHooks({
|
||||
hookRunner,
|
||||
originalConfig: installedConfig,
|
||||
originalConfig: installedConfig.config,
|
||||
resultConfig: result,
|
||||
provider: providerDetails,
|
||||
intent,
|
||||
@@ -971,7 +909,7 @@ export function buildSearchProviderPickerModel(
|
||||
(configuredCount === 1 ? configuredEntries[0]?.value : undefined) ??
|
||||
configuredEntries[0]?.value ??
|
||||
sortedEntries[0]?.value ??
|
||||
SEARCH_PROVIDER_OPTIONS[0].value;
|
||||
SEARCH_PROVIDER_SKIP_SENTINEL;
|
||||
|
||||
const installableEntries = resolveInstallableSearchProviderPlugins(providerEntries);
|
||||
const options: Array<{ value: SearchProviderPickerChoice; label: string; hint?: string }> = [
|
||||
@@ -993,15 +931,14 @@ export function buildSearchProviderPickerModel(
|
||||
configuredCount,
|
||||
}),
|
||||
})),
|
||||
...(installableEntries.length > 0
|
||||
? [
|
||||
{
|
||||
value: SEARCH_PROVIDER_INSTALL_SENTINEL as const,
|
||||
label: "Install external provider plugin",
|
||||
hint: "Add an external web search plugin",
|
||||
},
|
||||
]
|
||||
: []),
|
||||
{
|
||||
value: SEARCH_PROVIDER_INSTALL_SENTINEL as const,
|
||||
label: "Install external provider plugin",
|
||||
hint:
|
||||
installableEntries.length > 0
|
||||
? "Add an external web search plugin"
|
||||
: "Install an external web search plugin from npm or a local path",
|
||||
},
|
||||
...(includeSkipOption
|
||||
? [
|
||||
{
|
||||
@@ -1056,8 +993,8 @@ export async function configureSearchProviderSelection(
|
||||
|
||||
if (legacyConfig && intent === "switch-active" && (keyConfigured || envAvailable)) {
|
||||
const result = existingKey
|
||||
? applySearchKey(config, selectedEntry.value as SearchProvider, legacyConfig, existingKey)
|
||||
: applyProviderOnly(config, selectedEntry.value as SearchProvider);
|
||||
? applySearchKey(config, selectedEntry.value, legacyConfig, existingKey)
|
||||
: applyProviderOnly(config, selectedEntry.value);
|
||||
const nextConfig = preserveSearchProviderIntent(config, result, intent, selectedEntry.value);
|
||||
await runAfterSearchProviderHooks({
|
||||
hookRunner,
|
||||
@@ -1107,7 +1044,7 @@ export async function configureSearchProviderSelection(
|
||||
if (keyConfigured) {
|
||||
return preserveSearchProviderIntent(
|
||||
config,
|
||||
applyProviderOnly(config, selectedEntry.value as SearchProvider),
|
||||
applyProviderOnly(config, selectedEntry.value),
|
||||
intent,
|
||||
selectedEntry.value,
|
||||
);
|
||||
@@ -1124,7 +1061,7 @@ export async function configureSearchProviderSelection(
|
||||
);
|
||||
const result = preserveSearchProviderIntent(
|
||||
config,
|
||||
applySearchKey(config, selectedEntry.value as SearchProvider, legacyConfig, ref),
|
||||
applySearchKey(config, selectedEntry.value, legacyConfig, ref),
|
||||
intent,
|
||||
selectedEntry.value,
|
||||
);
|
||||
@@ -1151,14 +1088,14 @@ export async function configureSearchProviderSelection(
|
||||
const key = keyInput?.trim() ?? "";
|
||||
if (key) {
|
||||
const secretInput = resolveSearchSecretInput(
|
||||
selectedEntry.value as SearchProvider,
|
||||
selectedEntry.value,
|
||||
legacyConfig,
|
||||
key,
|
||||
opts?.secretInputMode,
|
||||
);
|
||||
const result = preserveSearchProviderIntent(
|
||||
config,
|
||||
applySearchKey(config, selectedEntry.value as SearchProvider, legacyConfig, secretInput),
|
||||
applySearchKey(config, selectedEntry.value, legacyConfig, secretInput),
|
||||
intent,
|
||||
selectedEntry.value,
|
||||
);
|
||||
@@ -1176,7 +1113,7 @@ export async function configureSearchProviderSelection(
|
||||
if (existingKey) {
|
||||
const result = preserveSearchProviderIntent(
|
||||
config,
|
||||
applySearchKey(config, selectedEntry.value as SearchProvider, legacyConfig, existingKey),
|
||||
applySearchKey(config, selectedEntry.value, legacyConfig, existingKey),
|
||||
intent,
|
||||
selectedEntry.value,
|
||||
);
|
||||
@@ -1194,7 +1131,7 @@ export async function configureSearchProviderSelection(
|
||||
if (keyConfigured || envAvailable) {
|
||||
const result = preserveSearchProviderIntent(
|
||||
config,
|
||||
applyProviderOnly(config, selectedEntry.value as SearchProvider),
|
||||
applyProviderOnly(config, selectedEntry.value),
|
||||
intent,
|
||||
selectedEntry.value,
|
||||
);
|
||||
@@ -1245,195 +1182,7 @@ export async function configureSearchProviderSelection(
|
||||
});
|
||||
return result;
|
||||
}
|
||||
|
||||
const builtinChoice = choice as SearchProvider;
|
||||
const entry = SEARCH_PROVIDER_OPTIONS.find((e) => e.value === builtinChoice);
|
||||
if (!entry) {
|
||||
return config;
|
||||
}
|
||||
const hookRunner = createSearchProviderHookRunner(config, opts?.workspaceDir);
|
||||
const builtinLegacyConfig = legacyConfigFromBuiltinEntry(entry);
|
||||
const providerDetails: SearchProviderHookDetails = {
|
||||
providerId: builtinChoice,
|
||||
providerLabel: entry.label,
|
||||
providerSource: "builtin",
|
||||
configured: hasExistingKey(config, builtinLegacyConfig) || hasKeyInEnv(entry),
|
||||
};
|
||||
const existingKey = resolveExistingKey(config, builtinLegacyConfig);
|
||||
const keyConfigured = hasExistingKey(config, builtinLegacyConfig);
|
||||
const envAvailable = hasKeyInEnv(entry);
|
||||
|
||||
if (intent === "switch-active" && (keyConfigured || envAvailable)) {
|
||||
const result = existingKey
|
||||
? applySearchKey(config, builtinChoice, builtinLegacyConfig, existingKey)
|
||||
: applyProviderOnly(config, builtinChoice);
|
||||
const next = preserveSearchProviderIntent(config, result, intent, builtinChoice);
|
||||
await runAfterSearchProviderHooks({
|
||||
hookRunner,
|
||||
originalConfig: config,
|
||||
resultConfig: next,
|
||||
provider: providerDetails,
|
||||
intent,
|
||||
workspaceDir: opts?.workspaceDir,
|
||||
});
|
||||
return next;
|
||||
}
|
||||
|
||||
if (opts?.quickstartDefaults && (keyConfigured || envAvailable)) {
|
||||
const result = existingKey
|
||||
? applySearchKey(config, builtinChoice, builtinLegacyConfig, existingKey)
|
||||
: applyProviderOnly(config, builtinChoice);
|
||||
const next = preserveSearchProviderIntent(config, result, intent, builtinChoice);
|
||||
await runAfterSearchProviderHooks({
|
||||
hookRunner,
|
||||
originalConfig: config,
|
||||
resultConfig: next,
|
||||
provider: providerDetails,
|
||||
intent,
|
||||
workspaceDir: opts?.workspaceDir,
|
||||
});
|
||||
return next;
|
||||
}
|
||||
|
||||
await maybeNoteBeforeSearchProviderConfigure({
|
||||
hookRunner,
|
||||
config,
|
||||
provider: providerDetails,
|
||||
intent,
|
||||
prompter,
|
||||
workspaceDir: opts?.workspaceDir,
|
||||
});
|
||||
|
||||
const useSecretRefMode = opts?.secretInputMode === "ref"; // pragma: allowlist secret
|
||||
if (useSecretRefMode) {
|
||||
if (keyConfigured) {
|
||||
return preserveDisabledState(config, applyProviderOnly(config, builtinChoice));
|
||||
}
|
||||
const ref = buildSearchEnvRef(builtinLegacyConfig);
|
||||
await prompter.note(
|
||||
[
|
||||
"Secret references enabled — OpenClaw will store a reference instead of the API key.",
|
||||
`Env var: ${ref.id}${envAvailable ? " (detected)" : ""}.`,
|
||||
...(envAvailable ? [] : [`Set ${ref.id} in the Gateway environment.`]),
|
||||
"Docs: https://docs.openclaw.ai/tools/web",
|
||||
].join("\n"),
|
||||
"Web search",
|
||||
);
|
||||
const result = preserveSearchProviderIntent(
|
||||
config,
|
||||
applySearchKey(config, builtinChoice, builtinLegacyConfig, ref),
|
||||
intent,
|
||||
builtinChoice,
|
||||
);
|
||||
await runAfterSearchProviderHooks({
|
||||
hookRunner,
|
||||
originalConfig: config,
|
||||
resultConfig: result,
|
||||
provider: providerDetails,
|
||||
intent,
|
||||
workspaceDir: opts?.workspaceDir,
|
||||
});
|
||||
return result;
|
||||
}
|
||||
|
||||
const keyInput = await prompter.text({
|
||||
message: keyConfigured
|
||||
? `${entry.label} API key (leave blank to keep current)`
|
||||
: envAvailable
|
||||
? `${entry.label} API key (leave blank to use env var)`
|
||||
: `${entry.label} API key`,
|
||||
placeholder: keyConfigured ? "Leave blank to keep current" : entry.placeholder,
|
||||
});
|
||||
|
||||
const key = keyInput?.trim() ?? "";
|
||||
if (key) {
|
||||
const secretInput = resolveSearchSecretInput(
|
||||
builtinChoice,
|
||||
builtinLegacyConfig,
|
||||
key,
|
||||
opts?.secretInputMode,
|
||||
);
|
||||
const result = preserveSearchProviderIntent(
|
||||
config,
|
||||
applySearchKey(config, builtinChoice, builtinLegacyConfig, secretInput),
|
||||
intent,
|
||||
builtinChoice,
|
||||
);
|
||||
await runAfterSearchProviderHooks({
|
||||
hookRunner,
|
||||
originalConfig: config,
|
||||
resultConfig: result,
|
||||
provider: providerDetails,
|
||||
intent,
|
||||
workspaceDir: opts?.workspaceDir,
|
||||
});
|
||||
return result;
|
||||
}
|
||||
|
||||
if (existingKey) {
|
||||
const result = preserveSearchProviderIntent(
|
||||
config,
|
||||
applySearchKey(config, builtinChoice, builtinLegacyConfig, existingKey),
|
||||
intent,
|
||||
builtinChoice,
|
||||
);
|
||||
await runAfterSearchProviderHooks({
|
||||
hookRunner,
|
||||
originalConfig: config,
|
||||
resultConfig: result,
|
||||
provider: providerDetails,
|
||||
intent,
|
||||
workspaceDir: opts?.workspaceDir,
|
||||
});
|
||||
return result;
|
||||
}
|
||||
|
||||
if (keyConfigured || envAvailable) {
|
||||
const result = preserveSearchProviderIntent(
|
||||
config,
|
||||
applyProviderOnly(config, builtinChoice),
|
||||
intent,
|
||||
builtinChoice,
|
||||
);
|
||||
await runAfterSearchProviderHooks({
|
||||
hookRunner,
|
||||
originalConfig: config,
|
||||
resultConfig: result,
|
||||
provider: providerDetails,
|
||||
intent,
|
||||
workspaceDir: opts?.workspaceDir,
|
||||
});
|
||||
return result;
|
||||
}
|
||||
|
||||
await prompter.note(
|
||||
[
|
||||
"No API key stored — web_search won't work until a key is available.",
|
||||
`Get your key at: ${entry.signupUrl}`,
|
||||
"Docs: https://docs.openclaw.ai/tools/web",
|
||||
].join("\n"),
|
||||
"Web search",
|
||||
);
|
||||
|
||||
const result = preserveSearchProviderIntent(
|
||||
config,
|
||||
applyCapabilitySlotSelection({
|
||||
config,
|
||||
slot: "providers.search",
|
||||
selectedId: builtinChoice,
|
||||
}),
|
||||
intent,
|
||||
builtinChoice,
|
||||
);
|
||||
await runAfterSearchProviderHooks({
|
||||
hookRunner,
|
||||
originalConfig: config,
|
||||
resultConfig: result,
|
||||
provider: providerDetails,
|
||||
intent,
|
||||
workspaceDir: opts?.workspaceDir,
|
||||
});
|
||||
return result;
|
||||
return config;
|
||||
}
|
||||
|
||||
function preserveSearchProviderIntent(
|
||||
@@ -1448,7 +1197,13 @@ function preserveSearchProviderIntent(
|
||||
|
||||
const currentProvider = resolveCapabilitySlotSelection(original, "providers.search");
|
||||
let next = result;
|
||||
if (currentProvider && currentProvider !== selectedProvider) {
|
||||
if (!currentProvider) {
|
||||
next = applyCapabilitySlotSelection({
|
||||
config: next,
|
||||
slot: "providers.search",
|
||||
selectedId: selectedProvider,
|
||||
});
|
||||
} else if (currentProvider !== selectedProvider) {
|
||||
next = applyCapabilitySlotSelection({
|
||||
config: next,
|
||||
slot: "providers.search",
|
||||
@@ -1476,45 +1231,33 @@ export async function promptSearchProviderFlow(params: {
|
||||
includeSkipOption: params.includeSkipOption,
|
||||
skipHint: params.skipHint,
|
||||
});
|
||||
const action = await promptProviderManagementIntent({
|
||||
prompter: params.prompter,
|
||||
message: "Web search setup",
|
||||
includeSkipOption: params.includeSkipOption,
|
||||
configuredCount: pickerModel.configuredCount,
|
||||
configureValue: SEARCH_PROVIDER_CONFIGURE_SENTINEL,
|
||||
switchValue: SEARCH_PROVIDER_SWITCH_ACTIVE_SENTINEL,
|
||||
skipValue: SEARCH_PROVIDER_SKIP_SENTINEL,
|
||||
configureLabel: "Configure or install a provider",
|
||||
configureHint:
|
||||
"Update keys, plugin settings, or install a provider without changing the active provider",
|
||||
switchLabel: "Switch active provider",
|
||||
switchHint: "Change which provider web_search uses right now",
|
||||
skipHint: "Configure later with openclaw configure --section web",
|
||||
});
|
||||
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",
|
||||
message: "Choose web search provider",
|
||||
options: buildProviderSelectionOptions({
|
||||
intent,
|
||||
intent: "configure-provider",
|
||||
options: pickerModel.options,
|
||||
activeValue: pickerModel.activeProvider,
|
||||
hiddenValues: intent === "configure-provider" ? [SEARCH_PROVIDER_KEEP_CURRENT_SENTINEL] : [],
|
||||
}),
|
||||
initialValue:
|
||||
intent === "switch-active"
|
||||
? pickerModel.initialValue
|
||||
: (pickerModel.options.find(
|
||||
(option) => option.value !== SEARCH_PROVIDER_KEEP_CURRENT_SENTINEL,
|
||||
)?.value ?? pickerModel.initialValue),
|
||||
initialValue: pickerModel.initialValue,
|
||||
});
|
||||
|
||||
if (
|
||||
choice === SEARCH_PROVIDER_SKIP_SENTINEL ||
|
||||
choice === SEARCH_PROVIDER_KEEP_CURRENT_SENTINEL
|
||||
) {
|
||||
return params.config;
|
||||
}
|
||||
|
||||
const selectedEntry = providerEntries.find((entry) => entry.value === choice);
|
||||
const intent: SearchProviderFlowIntent =
|
||||
choice === SEARCH_PROVIDER_INSTALL_SENTINEL
|
||||
? "configure-provider"
|
||||
: choice === pickerModel.activeProvider
|
||||
? "configure-provider"
|
||||
: selectedEntry?.configured
|
||||
? "switch-active"
|
||||
: "configure-provider";
|
||||
|
||||
return applySearchProviderChoice({
|
||||
config: params.config,
|
||||
choice,
|
||||
@@ -1525,8 +1268,8 @@ export async function promptSearchProviderFlow(params: {
|
||||
});
|
||||
}
|
||||
|
||||
export function hasKeyInEnv(entry: SearchProviderEntry): boolean {
|
||||
return entry.envKeys.some((k) => Boolean(process.env[k]?.trim()));
|
||||
export function hasKeyInEnv(metadata: SearchProviderLegacyConfigMetadata): boolean {
|
||||
return metadata.envKeys.some((key) => Boolean(process.env[key]?.trim()));
|
||||
}
|
||||
|
||||
function rawKeyValue(
|
||||
@@ -1645,7 +1388,7 @@ export async function setupSearch(
|
||||
await prompter.note(
|
||||
[
|
||||
"Web search lets your agent look things up online.",
|
||||
"Choose a provider and enter the required built-in or plugin settings.",
|
||||
"Choose a provider and enter the required settings.",
|
||||
"Docs: https://docs.openclaw.ai/tools/web",
|
||||
].join("\n"),
|
||||
"Web search",
|
||||
|
||||
@@ -18,6 +18,10 @@ const installPluginFromNpmSpec = vi.fn();
|
||||
vi.mock("../../plugins/install.js", () => ({
|
||||
installPluginFromNpmSpec: (...args: unknown[]) => installPluginFromNpmSpec(...args),
|
||||
}));
|
||||
const loadPluginManifest = vi.fn();
|
||||
vi.mock("../../plugins/manifest.js", () => ({
|
||||
loadPluginManifest: (...args: unknown[]) => loadPluginManifest(...args),
|
||||
}));
|
||||
|
||||
const resolveBundledPluginSources = vi.fn();
|
||||
vi.mock("../../plugins/bundled-sources.js", () => ({
|
||||
@@ -61,6 +65,7 @@ import { loadOpenClawPlugins } from "../../plugins/loader.js";
|
||||
import type { WizardPrompter } from "../../wizard/prompts.js";
|
||||
import { makePrompter, makeRuntime } from "./__tests__/test-utils.js";
|
||||
import {
|
||||
ensureGenericOnboardingPluginInstalled,
|
||||
ensureOnboardingPluginInstalled,
|
||||
reloadOnboardingPluginRegistry,
|
||||
} from "./plugin-install.js";
|
||||
@@ -84,6 +89,7 @@ const baseEntry: ChannelPluginCatalogEntry = {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
resolveBundledPluginSources.mockReturnValue(new Map());
|
||||
loadPluginManifest.mockReset();
|
||||
});
|
||||
|
||||
function mockRepoLocalPathExists() {
|
||||
@@ -461,3 +467,87 @@ describe("ensureOnboardingPluginInstalled", () => {
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe("ensureGenericOnboardingPluginInstalled", () => {
|
||||
it("installs an arbitrary scoped npm package", async () => {
|
||||
const runtime = makeRuntime();
|
||||
const prompter = makePrompter({
|
||||
text: vi.fn(async () => "@other/provider") as WizardPrompter["text"],
|
||||
});
|
||||
const cfg: OpenClawConfig = {};
|
||||
vi.mocked(fs.existsSync).mockReturnValue(false);
|
||||
installPluginFromNpmSpec.mockResolvedValue({
|
||||
ok: true,
|
||||
pluginId: "external-search",
|
||||
targetDir: "/tmp/external-search",
|
||||
extensions: [],
|
||||
});
|
||||
|
||||
const result = await ensureGenericOnboardingPluginInstalled({
|
||||
cfg,
|
||||
prompter,
|
||||
runtime,
|
||||
});
|
||||
|
||||
expect(result.installed).toBe(true);
|
||||
expect(result.pluginId).toBe("external-search");
|
||||
expect(installPluginFromNpmSpec).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ spec: "@other/provider" }),
|
||||
);
|
||||
});
|
||||
|
||||
it("links an arbitrary existing local plugin path and derives its plugin id from the manifest", async () => {
|
||||
const runtime = makeRuntime();
|
||||
const note = vi.fn(async () => {});
|
||||
const prompter = makePrompter({
|
||||
text: vi.fn(async () => "extensions/external-search") as WizardPrompter["text"],
|
||||
note,
|
||||
});
|
||||
const cfg: OpenClawConfig = {};
|
||||
vi.mocked(fs.existsSync).mockImplementation((value) => {
|
||||
const raw = String(value);
|
||||
return (
|
||||
raw.endsWith(`${path.sep}.git`) ||
|
||||
raw.endsWith(`${path.sep}extensions${path.sep}external-search`)
|
||||
);
|
||||
});
|
||||
loadPluginManifest.mockReturnValue({
|
||||
ok: true,
|
||||
manifest: { id: "external-search" },
|
||||
});
|
||||
|
||||
const result = await ensureGenericOnboardingPluginInstalled({
|
||||
cfg,
|
||||
prompter,
|
||||
runtime,
|
||||
});
|
||||
|
||||
expect(result.installed).toBe(true);
|
||||
expect(result.pluginId).toBe("external-search");
|
||||
expect(result.cfg.plugins?.load?.paths).toContain(
|
||||
path.resolve(process.cwd(), "extensions/external-search"),
|
||||
);
|
||||
expect(note).toHaveBeenCalledWith(
|
||||
`Using existing local plugin at ${path.resolve(process.cwd(), "extensions/external-search")}.\nNo download needed.`,
|
||||
"Plugin install",
|
||||
);
|
||||
});
|
||||
|
||||
it("skips cleanly when the generic install input is blank", async () => {
|
||||
const runtime = makeRuntime();
|
||||
const prompter = makePrompter({
|
||||
text: vi.fn(async () => "") as WizardPrompter["text"],
|
||||
});
|
||||
const cfg: OpenClawConfig = {};
|
||||
|
||||
const result = await ensureGenericOnboardingPluginInstalled({
|
||||
cfg,
|
||||
prompter,
|
||||
runtime,
|
||||
});
|
||||
|
||||
expect(result.installed).toBe(false);
|
||||
expect(result.cfg).toBe(cfg);
|
||||
expect(installPluginFromNpmSpec).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -14,6 +14,7 @@ import { installPluginFromNpmSpec } from "../../plugins/install.js";
|
||||
import { buildNpmResolutionInstallFields, recordPluginInstall } from "../../plugins/installs.js";
|
||||
import { loadOpenClawPlugins } from "../../plugins/loader.js";
|
||||
import { createPluginLoaderLogger } from "../../plugins/logger.js";
|
||||
import { loadPluginManifest } from "../../plugins/manifest.js";
|
||||
import type { RuntimeEnv } from "../../runtime.js";
|
||||
import type { WizardPrompter } from "../../wizard/prompts.js";
|
||||
|
||||
@@ -32,6 +33,7 @@ export type InstallablePluginCatalogEntry = {
|
||||
type InstallResult = {
|
||||
cfg: OpenClawConfig;
|
||||
installed: boolean;
|
||||
pluginId?: string;
|
||||
};
|
||||
|
||||
function hasGitWorkspace(workspaceDir?: string): boolean {
|
||||
@@ -114,12 +116,12 @@ function addPluginLoadPath(cfg: OpenClawConfig, pluginPath: string): OpenClawCon
|
||||
}
|
||||
|
||||
async function promptInstallChoice(params: {
|
||||
entry: InstallablePluginCatalogEntry;
|
||||
prompter: WizardPrompter;
|
||||
workspaceDir?: string;
|
||||
allowLocal: boolean;
|
||||
expectedNpmSpec?: string;
|
||||
}): Promise<string | null> {
|
||||
const { entry, prompter, workspaceDir, allowLocal } = params;
|
||||
const { prompter, workspaceDir, allowLocal, expectedNpmSpec } = params;
|
||||
const message = allowLocal ? "npm package or local path" : "npm package";
|
||||
const placeholder = allowLocal
|
||||
? "@scope/plugin-name or extensions/plugin-name (leave blank to skip)"
|
||||
@@ -153,11 +155,11 @@ async function promptInstallChoice(params: {
|
||||
continue;
|
||||
}
|
||||
|
||||
if (!matchesCatalogNpmSpec(source, entry.install.npmSpec)) {
|
||||
if (expectedNpmSpec && !matchesCatalogNpmSpec(source, expectedNpmSpec)) {
|
||||
await prompter.note(
|
||||
allowLocal
|
||||
? `This flow installs ${entry.install.npmSpec}. Enter that npm package or a local plugin path.`
|
||||
: `This flow installs ${entry.install.npmSpec}. Enter that npm package.`,
|
||||
? `This flow installs ${expectedNpmSpec}. Enter that npm package or a local plugin path.`
|
||||
: `This flow installs ${expectedNpmSpec}. Enter that npm package.`,
|
||||
"Plugin install",
|
||||
);
|
||||
continue;
|
||||
@@ -225,10 +227,10 @@ export async function ensureOnboardingPluginInstalled(params: {
|
||||
})?.bundledSource.localPath ?? null;
|
||||
const localPath = bundledLocalPath ?? resolveLocalPath(entry, workspaceDir, allowLocal);
|
||||
const source = await promptInstallChoice({
|
||||
entry,
|
||||
prompter,
|
||||
workspaceDir,
|
||||
allowLocal,
|
||||
expectedNpmSpec: entry.install.npmSpec,
|
||||
});
|
||||
|
||||
if (!source) {
|
||||
@@ -242,7 +244,7 @@ export async function ensureOnboardingPluginInstalled(params: {
|
||||
);
|
||||
next = addPluginLoadPath(next, source);
|
||||
next = enablePluginInConfig(next, entry.id).config;
|
||||
return { cfg: next, installed: true };
|
||||
return { cfg: next, installed: true, pluginId: entry.id };
|
||||
}
|
||||
|
||||
const result = await installPluginFromNpmSpec({
|
||||
@@ -263,7 +265,7 @@ export async function ensureOnboardingPluginInstalled(params: {
|
||||
version: result.version,
|
||||
...buildNpmResolutionInstallFields(result.npmResolution),
|
||||
});
|
||||
return { cfg: next, installed: true };
|
||||
return { cfg: next, installed: true, pluginId: result.pluginId };
|
||||
}
|
||||
|
||||
await prompter.note(`Failed to install ${source}: ${result.error}`, "Plugin install");
|
||||
@@ -280,7 +282,7 @@ export async function ensureOnboardingPluginInstalled(params: {
|
||||
);
|
||||
next = addPluginLoadPath(next, localPath);
|
||||
next = enablePluginInConfig(next, entry.id).config;
|
||||
return { cfg: next, installed: true };
|
||||
return { cfg: next, installed: true, pluginId: entry.id };
|
||||
}
|
||||
}
|
||||
|
||||
@@ -288,6 +290,69 @@ export async function ensureOnboardingPluginInstalled(params: {
|
||||
return { cfg: next, installed: false };
|
||||
}
|
||||
|
||||
export async function ensureGenericOnboardingPluginInstalled(params: {
|
||||
cfg: OpenClawConfig;
|
||||
prompter: WizardPrompter;
|
||||
runtime: RuntimeEnv;
|
||||
workspaceDir?: string;
|
||||
}): Promise<InstallResult> {
|
||||
const { prompter, runtime, workspaceDir } = params;
|
||||
let next = params.cfg;
|
||||
const allowLocal = hasGitWorkspace(workspaceDir);
|
||||
const source = await promptInstallChoice({
|
||||
prompter,
|
||||
workspaceDir,
|
||||
allowLocal,
|
||||
});
|
||||
|
||||
if (!source) {
|
||||
return { cfg: next, installed: false };
|
||||
}
|
||||
|
||||
if (isLikelyLocalPath(source)) {
|
||||
const manifestRes = loadPluginManifest(source, false);
|
||||
if (!manifestRes.ok) {
|
||||
await prompter.note(
|
||||
`Failed to load plugin from ${source}: ${manifestRes.error}`,
|
||||
"Plugin install",
|
||||
);
|
||||
return { cfg: next, installed: false };
|
||||
}
|
||||
await prompter.note(
|
||||
[`Using existing local plugin at ${source}.`, "No download needed."].join("\n"),
|
||||
"Plugin install",
|
||||
);
|
||||
next = addPluginLoadPath(next, source);
|
||||
next = enablePluginInConfig(next, manifestRes.manifest.id).config;
|
||||
return { cfg: next, installed: true, pluginId: manifestRes.manifest.id };
|
||||
}
|
||||
|
||||
const result = await installPluginFromNpmSpec({
|
||||
spec: source,
|
||||
logger: {
|
||||
info: (msg) => runtime.log?.(msg),
|
||||
warn: (msg) => runtime.log?.(msg),
|
||||
},
|
||||
});
|
||||
|
||||
if (result.ok) {
|
||||
next = enablePluginInConfig(next, result.pluginId).config;
|
||||
next = recordPluginInstall(next, {
|
||||
pluginId: result.pluginId,
|
||||
source: "npm",
|
||||
spec: source,
|
||||
installPath: result.targetDir,
|
||||
version: result.version,
|
||||
...buildNpmResolutionInstallFields(result.npmResolution),
|
||||
});
|
||||
return { cfg: next, installed: true, pluginId: result.pluginId };
|
||||
}
|
||||
|
||||
await prompter.note(`Failed to install ${source}: ${result.error}`, "Plugin install");
|
||||
runtime.error?.(`Plugin install failed: ${result.error}`);
|
||||
return { cfg: next, installed: false };
|
||||
}
|
||||
|
||||
export function reloadOnboardingPluginRegistry(params: {
|
||||
cfg: OpenClawConfig;
|
||||
runtime: RuntimeEnv;
|
||||
|
||||
@@ -40,12 +40,50 @@ export type SearchProviderFilterSupport = {
|
||||
domainFilter?: boolean;
|
||||
};
|
||||
|
||||
export type SearchProviderLegacyUiMetadataParams = Omit<
|
||||
SearchProviderLegacyUiMetadata,
|
||||
"readApiKeyValue" | "writeApiKeyValue"
|
||||
> & {
|
||||
provider: string;
|
||||
};
|
||||
|
||||
const WEB_SEARCH_DOCS_URL = "https://docs.openclaw.ai/tools/web";
|
||||
|
||||
export function resolveSearchConfig<T>(search?: Record<string, unknown>): T {
|
||||
return search as T;
|
||||
}
|
||||
|
||||
export function resolveSearchProviderSectionConfig<T>(
|
||||
search: Record<string, unknown> | undefined,
|
||||
provider: string,
|
||||
): T {
|
||||
if (!search || typeof search !== "object") {
|
||||
return {} as T;
|
||||
}
|
||||
const scoped = search[provider];
|
||||
if (!scoped || typeof scoped !== "object" || Array.isArray(scoped)) {
|
||||
return {} as T;
|
||||
}
|
||||
return scoped as T;
|
||||
}
|
||||
|
||||
export function createLegacySearchProviderMetadata(
|
||||
params: SearchProviderLegacyUiMetadataParams,
|
||||
): SearchProviderLegacyUiMetadata {
|
||||
return {
|
||||
label: params.label,
|
||||
hint: params.hint,
|
||||
envKeys: params.envKeys,
|
||||
placeholder: params.placeholder,
|
||||
signupUrl: params.signupUrl,
|
||||
apiKeyConfigPath: params.apiKeyConfigPath,
|
||||
resolveRuntimeMetadata: params.resolveRuntimeMetadata,
|
||||
readApiKeyValue: (search) => readSearchProviderApiKeyValue(search, params.provider),
|
||||
writeApiKeyValue: (search, value) =>
|
||||
writeSearchProviderApiKeyValue({ search, provider: params.provider, value }),
|
||||
};
|
||||
}
|
||||
|
||||
export function createSearchProviderErrorResult(
|
||||
error: string,
|
||||
message: string,
|
||||
|
||||
Reference in New Issue
Block a user