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,
|
title: input.title,
|
||||||
...(input.metadata === undefined ? {} : { metadata: input.metadata }),
|
...(input.metadata === undefined ? {} : { metadata: input.metadata }),
|
||||||
}
|
}
|
||||||
const form: Info =
|
const form = makeInfo(input, base)
|
||||||
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 entry: Entry = {
|
const entry: Entry = {
|
||||||
form,
|
form,
|
||||||
state: { status: "pending" },
|
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) {
|
function isActive(field: Form.Field, answer: Answer) {
|
||||||
if (!field.when) return true
|
if (!field.when) return true
|
||||||
return field.when.every((when) => matches(when, answer[when.key]))
|
return field.when.every((when) => matches(when, answer[when.key]))
|
||||||
|
|
|
||||||
|
|
@ -15,13 +15,6 @@ const Args = Schema.Struct({
|
||||||
contextMaxCharacters: Schema.optional(Schema.Number),
|
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>({
|
export const Plugin = define<HttpClient.HttpClient | Scope.Scope>({
|
||||||
id: "opencode.search.exa",
|
id: "opencode.search.exa",
|
||||||
effect: Effect.fn("SearchExa.Plugin")(function* (ctx) {
|
effect: Effect.fn("SearchExa.Plugin")(function* (ctx) {
|
||||||
|
|
@ -33,20 +26,17 @@ export const Plugin = define<HttpClient.HttpClient | Scope.Scope>({
|
||||||
draft.capability.search.update({
|
draft.capability.search.update({
|
||||||
integrationID: "exa",
|
integrationID: "exa",
|
||||||
capability: { type: "search", connection: "optional" },
|
capability: { type: "search", connection: "optional" },
|
||||||
execute: (input, context) =>
|
execute: (input, context) => {
|
||||||
SearchMcp.call(
|
const url = new URL(endpoint)
|
||||||
http,
|
if (context.credential?.type === "key") url.searchParams.set("exaApiKey", context.credential.key)
|
||||||
url(context.credential?.type === "key" ? context.credential.key : undefined),
|
return SearchMcp.call(http, url.toString(), "web_search_exa", Args, {
|
||||||
"web_search_exa",
|
query: input.query,
|
||||||
Args,
|
type: input.type ?? "auto",
|
||||||
{
|
numResults: input.numResults ?? 8,
|
||||||
query: input.query,
|
livecrawl: input.livecrawl ?? "fallback",
|
||||||
type: input.type ?? "auto",
|
contextMaxCharacters: input.contextMaxCharacters,
|
||||||
numResults: input.numResults ?? 8,
|
}).pipe(Effect.map((text) => ({ text: text ?? "" })))
|
||||||
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 onboarding = Semaphore.makeUnsafe(1)
|
||||||
const decodeOutput = Schema.decodeUnknownEffect(ProviderOutput)
|
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 = (
|
const requireProvider = (
|
||||||
providers: Map<Integration.ID, Integration.SearchImplementation>,
|
providers: Map<Integration.ID, Integration.SearchImplementation>,
|
||||||
providerID: Integration.ID,
|
providerID: Integration.ID,
|
||||||
|
|
@ -78,7 +72,7 @@ const layer = Layer.effect(
|
||||||
return provider ? Effect.succeed(provider) : Effect.fail(new ProviderNotFoundError({ providerID }))
|
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
|
const providerID = Config.latest(yield* config.entries(), "search")?.provider
|
||||||
if (providerID) return providerID
|
if (providerID) return providerID
|
||||||
if (process.env.OPENCODE_WEBSEARCH_PROVIDER) {
|
if (process.env.OPENCODE_WEBSEARCH_PROVIDER) {
|
||||||
|
|
@ -115,17 +109,16 @@ const layer = Layer.effect(
|
||||||
options: Array.from(providers.values())
|
options: Array.from(providers.values())
|
||||||
.flatMap((provider) => {
|
.flatMap((provider) => {
|
||||||
const info = infos.get(provider.integrationID)
|
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))
|
.toSorted((a, b) => a.info.name.localeCompare(b.info.name))
|
||||||
.map(({ provider, info }) => ({
|
.map(({ info, description }) => ({
|
||||||
value: info.id,
|
value: info.id,
|
||||||
label: info.name,
|
label: info.name,
|
||||||
description: info.connections.length
|
description,
|
||||||
? "Connected"
|
|
||||||
: provider.capability.connection === "optional"
|
|
||||||
? "Keyless available"
|
|
||||||
: "Connection required",
|
|
||||||
})),
|
})),
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
|
|
@ -160,9 +153,11 @@ const layer = Layer.effect(
|
||||||
})
|
})
|
||||||
|
|
||||||
const select = Effect.fn("Search.select")(function* (input: QueryInput) {
|
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)
|
if (input.providerID) return yield* requireProvider(providers, input.providerID)
|
||||||
const override = yield* configured()
|
const override = yield* configuredProvider()
|
||||||
if (override) return yield* requireProvider(providers, override)
|
if (override) return yield* requireProvider(providers, override)
|
||||||
const selected = yield* integrations.capability.search.selected()
|
const selected = yield* integrations.capability.search.selected()
|
||||||
const provider = selected ? providers.get(selected) : undefined
|
const provider = selected ? providers.get(selected) : undefined
|
||||||
|
|
@ -182,22 +177,22 @@ const layer = Layer.effect(
|
||||||
)
|
)
|
||||||
})
|
})
|
||||||
|
|
||||||
const query = Effect.fn("Search.query")(function* (input: QueryInput) {
|
return Service.of({
|
||||||
const provider = yield* select(input)
|
query: Effect.fn("Search.query")(function* (input) {
|
||||||
const connection = yield* connect(provider, input.sessionID)
|
const provider = yield* select(input)
|
||||||
const credential = connection
|
const connection = yield* connect(provider, input.sessionID)
|
||||||
? yield* integrations.connection
|
const credential = connection
|
||||||
.resolve(connection)
|
? yield* integrations.connection
|
||||||
.pipe(Effect.mapError((cause) => new RequestError({ providerID: provider.integrationID, cause })))
|
.resolve(connection)
|
||||||
: undefined
|
.pipe(Effect.mapError((cause) => new RequestError({ providerID: provider.integrationID, cause })))
|
||||||
const output = yield* provider.execute(input, { credential, sessionID: input.sessionID }).pipe(
|
: undefined
|
||||||
Effect.flatMap(decodeOutput),
|
const output = yield* provider.execute(input, { credential, sessionID: input.sessionID }).pipe(
|
||||||
Effect.mapError((cause) => new RequestError({ providerID: provider.integrationID, cause })),
|
Effect.flatMap(decodeOutput),
|
||||||
)
|
Effect.mapError((cause) => new RequestError({ providerID: provider.integrationID, cause })),
|
||||||
return new Result({ providerID: provider.integrationID, ...output })
|
)
|
||||||
|
return new Result({ providerID: provider.integrationID, ...output })
|
||||||
|
}),
|
||||||
})
|
})
|
||||||
|
|
||||||
return Service.of({ query })
|
|
||||||
}),
|
}),
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -58,8 +58,6 @@ export function DialogIntegration(
|
||||||
) {
|
) {
|
||||||
const data = useData()
|
const data = useData()
|
||||||
const dialog = useDialog()
|
const dialog = useDialog()
|
||||||
const sdk = useSDK()
|
|
||||||
const toast = useToast()
|
|
||||||
const { theme } = useTheme()
|
const { theme } = useTheme()
|
||||||
const options = createMemo(() =>
|
const options = createMemo(() =>
|
||||||
integrationOptions(data.location.integration.list() ?? [])
|
integrationOptions(data.location.integration.list() ?? [])
|
||||||
|
|
@ -68,26 +66,28 @@ export function DialogIntegration(
|
||||||
const methods = connectMethods(integration)
|
const methods = connectMethods(integration)
|
||||||
const connected = integration.connections.length > 0
|
const connected = integration.connections.length > 0
|
||||||
const search = integration.capabilities.find((capability) => capability.type === "search")
|
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 {
|
return {
|
||||||
title: integration.name,
|
title: integration.name,
|
||||||
value: integration.id,
|
value: integration.id,
|
||||||
description:
|
description: description ?? (methods.length === 0 ? "Environment only" : undefined),
|
||||||
search?.connection === "optional" ? "API key optional" : methods.length ? undefined : "Environment only",
|
|
||||||
footer:
|
footer:
|
||||||
[connectionSummary(integration), search?.selected ? "Web search default" : undefined]
|
[connectionSummary(integration), search?.selected ? "Web search default" : undefined]
|
||||||
.filter((value) => value !== undefined && value.length > 0)
|
.filter((value) => value !== undefined && value.length > 0)
|
||||||
.join(" · ") || undefined,
|
.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,
|
disabled: methods.length === 0 && !search,
|
||||||
gutter: connected ? () => <text fg={theme.success}>✓</text> : undefined,
|
gutter: connected ? () => <text fg={theme.success}>✓</text> : undefined,
|
||||||
onSelect: () => {
|
onSelect: () => {
|
||||||
if (props.connectionOnly) {
|
if (props.connectionOnly) {
|
||||||
return credentialConnections(integration).length
|
return credentials.length
|
||||||
? manageConnections(integration, methods, dialog, props.onConnected)
|
? manageConnections(integration, methods, dialog, props.onConnected)
|
||||||
: selectMethod(integration, methods, dialog, props.onConnected)
|
: selectMethod(integration, methods, dialog, props.onConnected)
|
||||||
}
|
}
|
||||||
if (search) return manageIntegration(integration, methods, search, data, sdk, dialog, toast)
|
if (search) return manageIntegration(integration, methods, search, dialog)
|
||||||
return credentialConnections(integration).length
|
return credentials.length
|
||||||
? manageConnections(integration, methods, dialog, props.onConnected)
|
? manageConnections(integration, methods, dialog, props.onConnected)
|
||||||
: selectMethod(integration, methods, dialog, props.onConnected)
|
: selectMethod(integration, methods, dialog, props.onConnected)
|
||||||
},
|
},
|
||||||
|
|
@ -112,55 +112,58 @@ function manageIntegration(
|
||||||
integration: IntegrationInfo,
|
integration: IntegrationInfo,
|
||||||
methods: ConnectMethod[],
|
methods: ConnectMethod[],
|
||||||
search: IntegrationInfo["capabilities"][number],
|
search: IntegrationInfo["capabilities"][number],
|
||||||
data: ReturnType<typeof useData>,
|
|
||||||
sdk: ReturnType<typeof useSDK>,
|
|
||||||
dialog: ReturnType<typeof useDialog>,
|
dialog: ReturnType<typeof useDialog>,
|
||||||
toast: ReturnType<typeof useToast>,
|
|
||||||
) {
|
) {
|
||||||
const connected = integration.connections.length > 0
|
const connected = integration.connections.length > 0
|
||||||
const select = () => {
|
dialog.replace(() => {
|
||||||
void sdk.api.integration
|
const data = useData()
|
||||||
.selectCapability({
|
const sdk = useSDK()
|
||||||
integrationID: integration.id,
|
const toast = useToast()
|
||||||
capability: "search",
|
const credentials = credentialConnections(integration)
|
||||||
location: location(data),
|
const selectSearch = () => {
|
||||||
})
|
void sdk.api.integration
|
||||||
.then(async () => {
|
.selectCapability({
|
||||||
await data.location.integration.refresh()
|
integrationID: integration.id,
|
||||||
toast.show({ variant: "success", message: `${integration.name} is now the web search default` })
|
capability: "search",
|
||||||
dialog.clear()
|
location: location(data),
|
||||||
})
|
})
|
||||||
.catch(toast.error)
|
.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(() => (
|
return (
|
||||||
<DialogSelect
|
<DialogSelect
|
||||||
title={integration.name}
|
title={integration.name}
|
||||||
options={[
|
options={[
|
||||||
{
|
{
|
||||||
title: search.selected ? "Web search default" : "Use for web search",
|
title: search.selected ? "Web search default" : "Use for web search",
|
||||||
value: "search",
|
value: "search",
|
||||||
disabled: search.selected,
|
disabled: search.selected,
|
||||||
onSelect:
|
onSelect:
|
||||||
search.connection === "required" && !connected
|
search.connection === "required" && !connected
|
||||||
? () => selectMethod(integration, methods, dialog, select)
|
? () => selectMethod(integration, methods, dialog, selectSearch)
|
||||||
: select,
|
: selectSearch,
|
||||||
},
|
},
|
||||||
...(methods.length
|
...(methods.length
|
||||||
? [
|
? [
|
||||||
{
|
{
|
||||||
title: credentialConnections(integration).length ? "Manage connections" : "Connect",
|
title: credentials.length ? "Manage connections" : "Connect",
|
||||||
value: "connect",
|
value: "connect",
|
||||||
onSelect: () =>
|
onSelect: () =>
|
||||||
credentialConnections(integration).length
|
credentials.length
|
||||||
? manageConnections(integration, methods, dialog)
|
? manageConnections(integration, methods, dialog)
|
||||||
: selectMethod(integration, methods, dialog),
|
: selectMethod(integration, methods, dialog),
|
||||||
},
|
},
|
||||||
]
|
]
|
||||||
: []),
|
: []),
|
||||||
]}
|
]}
|
||||||
/>
|
/>
|
||||||
))
|
)
|
||||||
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
function manageConnections(
|
function manageConnections(
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue