refactor: simplify search integration flow

This commit is contained in:
Shoubhit Dash 2026-07-06 19:46:39 +05:30
commit 56f44dbcaa
4 changed files with 105 additions and 111 deletions

View file

@ -136,12 +136,7 @@ export const layer = Layer.effect(
title: input.title,
...(input.metadata === undefined ? {} : { metadata: input.metadata }),
}
const form: Info =
input.mode === "form"
? { ...base, mode: "form", fields: input.fields }
: input.mode === "url"
? { ...base, mode: "url", url: input.url }
: { ...base, mode: "integration", integrationID: input.integrationID }
const form = makeInfo(input, base)
const entry: Entry = {
form,
state: { status: "pending" },
@ -252,6 +247,17 @@ function validateAnswer(form: Info, answer: Answer) {
}
}
function makeInfo(input: CreateInput, base: Omit<Info, "mode" | "fields" | "url" | "integrationID">): Info {
switch (input.mode) {
case "form":
return { ...base, mode: "form", fields: input.fields }
case "url":
return { ...base, mode: "url", url: input.url }
case "integration":
return { ...base, mode: "integration", integrationID: input.integrationID }
}
}
function isActive(field: Form.Field, answer: Answer) {
if (!field.when) return true
return field.when.every((when) => matches(when, answer[when.key]))

View file

@ -15,13 +15,6 @@ const Args = Schema.Struct({
contextMaxCharacters: Schema.optional(Schema.Number),
})
const url = (apiKey: string | undefined) => {
if (!apiKey) return endpoint
const value = new URL(endpoint)
value.searchParams.set("exaApiKey", apiKey)
return value.toString()
}
export const Plugin = define<HttpClient.HttpClient | Scope.Scope>({
id: "opencode.search.exa",
effect: Effect.fn("SearchExa.Plugin")(function* (ctx) {
@ -33,20 +26,17 @@ export const Plugin = define<HttpClient.HttpClient | Scope.Scope>({
draft.capability.search.update({
integrationID: "exa",
capability: { type: "search", connection: "optional" },
execute: (input, context) =>
SearchMcp.call(
http,
url(context.credential?.type === "key" ? context.credential.key : undefined),
"web_search_exa",
Args,
{
query: input.query,
type: input.type ?? "auto",
numResults: input.numResults ?? 8,
livecrawl: input.livecrawl ?? "fallback",
contextMaxCharacters: input.contextMaxCharacters,
},
).pipe(Effect.map((text) => ({ text: text ?? "" }))),
execute: (input, context) => {
const url = new URL(endpoint)
if (context.credential?.type === "key") url.searchParams.set("exaApiKey", context.credential.key)
return SearchMcp.call(http, url.toString(), "web_search_exa", Args, {
query: input.query,
type: input.type ?? "auto",
numResults: input.numResults ?? 8,
livecrawl: input.livecrawl ?? "fallback",
contextMaxCharacters: input.contextMaxCharacters,
}).pipe(Effect.map((text) => ({ text: text ?? "" })))
},
})
})
}),

View file

@ -64,12 +64,6 @@ const layer = Layer.effect(
const onboarding = Semaphore.makeUnsafe(1)
const decodeOutput = Schema.decodeUnknownEffect(ProviderOutput)
const available = Effect.fn("Search.available")(function* () {
return new Map(
(yield* integrations.capability.search.list()).map((provider) => [provider.integrationID, provider]),
)
})
const requireProvider = (
providers: Map<Integration.ID, Integration.SearchImplementation>,
providerID: Integration.ID,
@ -78,7 +72,7 @@ const layer = Layer.effect(
return provider ? Effect.succeed(provider) : Effect.fail(new ProviderNotFoundError({ providerID }))
}
const configured = Effect.fn("Search.configured")(function* () {
const configuredProvider = Effect.fn("Search.configuredProvider")(function* () {
const providerID = Config.latest(yield* config.entries(), "search")?.provider
if (providerID) return providerID
if (process.env.OPENCODE_WEBSEARCH_PROVIDER) {
@ -115,17 +109,16 @@ const layer = Layer.effect(
options: Array.from(providers.values())
.flatMap((provider) => {
const info = infos.get(provider.integrationID)
return info ? [{ provider, info }] : []
if (!info) return []
const disconnected =
provider.capability.connection === "optional" ? "Keyless available" : "Connection required"
return [{ info, description: info.connections.length ? "Connected" : disconnected }]
})
.toSorted((a, b) => a.info.name.localeCompare(b.info.name))
.map(({ provider, info }) => ({
.map(({ info, description }) => ({
value: info.id,
label: info.name,
description: info.connections.length
? "Connected"
: provider.capability.connection === "optional"
? "Keyless available"
: "Connection required",
description,
})),
},
],
@ -160,9 +153,11 @@ const layer = Layer.effect(
})
const select = Effect.fn("Search.select")(function* (input: QueryInput) {
const providers = yield* available()
const providers = new Map(
(yield* integrations.capability.search.list()).map((provider) => [provider.integrationID, provider]),
)
if (input.providerID) return yield* requireProvider(providers, input.providerID)
const override = yield* configured()
const override = yield* configuredProvider()
if (override) return yield* requireProvider(providers, override)
const selected = yield* integrations.capability.search.selected()
const provider = selected ? providers.get(selected) : undefined
@ -182,22 +177,22 @@ const layer = Layer.effect(
)
})
const query = Effect.fn("Search.query")(function* (input: QueryInput) {
const provider = yield* select(input)
const connection = yield* connect(provider, input.sessionID)
const credential = connection
? yield* integrations.connection
.resolve(connection)
.pipe(Effect.mapError((cause) => new RequestError({ providerID: provider.integrationID, cause })))
: undefined
const output = yield* provider.execute(input, { credential, sessionID: input.sessionID }).pipe(
Effect.flatMap(decodeOutput),
Effect.mapError((cause) => new RequestError({ providerID: provider.integrationID, cause })),
)
return new Result({ providerID: provider.integrationID, ...output })
return Service.of({
query: Effect.fn("Search.query")(function* (input) {
const provider = yield* select(input)
const connection = yield* connect(provider, input.sessionID)
const credential = connection
? yield* integrations.connection
.resolve(connection)
.pipe(Effect.mapError((cause) => new RequestError({ providerID: provider.integrationID, cause })))
: undefined
const output = yield* provider.execute(input, { credential, sessionID: input.sessionID }).pipe(
Effect.flatMap(decodeOutput),
Effect.mapError((cause) => new RequestError({ providerID: provider.integrationID, cause })),
)
return new Result({ providerID: provider.integrationID, ...output })
}),
})
return Service.of({ query })
}),
)

View file

@ -58,8 +58,6 @@ export function DialogIntegration(
) {
const data = useData()
const dialog = useDialog()
const sdk = useSDK()
const toast = useToast()
const { theme } = useTheme()
const options = createMemo(() =>
integrationOptions(data.location.integration.list() ?? [])
@ -68,26 +66,28 @@ export function DialogIntegration(
const methods = connectMethods(integration)
const connected = integration.connections.length > 0
const search = integration.capabilities.find((capability) => capability.type === "search")
const credentials = credentialConnections(integration)
const description = search?.connection === "optional" ? "API key optional" : undefined
const category = search ? "Web search" : undefined
return {
title: integration.name,
value: integration.id,
description:
search?.connection === "optional" ? "API key optional" : methods.length ? undefined : "Environment only",
description: description ?? (methods.length === 0 ? "Environment only" : undefined),
footer:
[connectionSummary(integration), search?.selected ? "Web search default" : undefined]
.filter((value) => value !== undefined && value.length > 0)
.join(" · ") || undefined,
category: search ? "Web search" : integration.id in INTEGRATION_PRIORITY ? "Popular" : "Services",
category: category ?? (integration.id in INTEGRATION_PRIORITY ? "Popular" : "Services"),
disabled: methods.length === 0 && !search,
gutter: connected ? () => <text fg={theme.success}></text> : undefined,
onSelect: () => {
if (props.connectionOnly) {
return credentialConnections(integration).length
return credentials.length
? manageConnections(integration, methods, dialog, props.onConnected)
: selectMethod(integration, methods, dialog, props.onConnected)
}
if (search) return manageIntegration(integration, methods, search, data, sdk, dialog, toast)
return credentialConnections(integration).length
if (search) return manageIntegration(integration, methods, search, dialog)
return credentials.length
? manageConnections(integration, methods, dialog, props.onConnected)
: selectMethod(integration, methods, dialog, props.onConnected)
},
@ -112,55 +112,58 @@ function manageIntegration(
integration: IntegrationInfo,
methods: ConnectMethod[],
search: IntegrationInfo["capabilities"][number],
data: ReturnType<typeof useData>,
sdk: ReturnType<typeof useSDK>,
dialog: ReturnType<typeof useDialog>,
toast: ReturnType<typeof useToast>,
) {
const connected = integration.connections.length > 0
const select = () => {
void sdk.api.integration
.selectCapability({
integrationID: integration.id,
capability: "search",
location: location(data),
})
.then(async () => {
await data.location.integration.refresh()
toast.show({ variant: "success", message: `${integration.name} is now the web search default` })
dialog.clear()
})
.catch(toast.error)
}
dialog.replace(() => {
const data = useData()
const sdk = useSDK()
const toast = useToast()
const credentials = credentialConnections(integration)
const selectSearch = () => {
void sdk.api.integration
.selectCapability({
integrationID: integration.id,
capability: "search",
location: location(data),
})
.then(async () => {
await data.location.integration.refresh()
toast.show({ variant: "success", message: `${integration.name} is now the web search default` })
dialog.clear()
})
.catch(toast.error)
}
dialog.replace(() => (
<DialogSelect
title={integration.name}
options={[
{
title: search.selected ? "Web search default" : "Use for web search",
value: "search",
disabled: search.selected,
onSelect:
search.connection === "required" && !connected
? () => selectMethod(integration, methods, dialog, select)
: select,
},
...(methods.length
? [
{
title: credentialConnections(integration).length ? "Manage connections" : "Connect",
value: "connect",
onSelect: () =>
credentialConnections(integration).length
? manageConnections(integration, methods, dialog)
: selectMethod(integration, methods, dialog),
},
]
: []),
]}
/>
))
return (
<DialogSelect
title={integration.name}
options={[
{
title: search.selected ? "Web search default" : "Use for web search",
value: "search",
disabled: search.selected,
onSelect:
search.connection === "required" && !connected
? () => selectMethod(integration, methods, dialog, selectSearch)
: selectSearch,
},
...(methods.length
? [
{
title: credentials.length ? "Manage connections" : "Connect",
value: "connect",
onSelect: () =>
credentials.length
? manageConnections(integration, methods, dialog)
: selectMethod(integration, methods, dialog),
},
]
: []),
]}
/>
)
})
}
function manageConnections(