refactor: simplify search integration flow
This commit is contained in:
parent
129012c3ee
commit
56f44dbcaa
4 changed files with 105 additions and 111 deletions
|
|
@ -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]))
|
||||
|
|
|
|||
|
|
@ -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 ?? "" })))
|
||||
},
|
||||
})
|
||||
})
|
||||
}),
|
||||
|
|
|
|||
|
|
@ -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 })
|
||||
}),
|
||||
)
|
||||
|
||||
|
|
|
|||
|
|
@ -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(
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue