feat(core): add pluggable web search (#35558)
Co-authored-by: Dax Raad <d@ironbay.co>
This commit is contained in:
parent
2ddc91a0e8
commit
efb629a33a
57 changed files with 1700 additions and 587 deletions
144
packages/core/src/websearch.ts
Normal file
144
packages/core/src/websearch.ts
Normal file
|
|
@ -0,0 +1,144 @@
|
|||
export * as WebSearch from "./websearch"
|
||||
|
||||
import { WebSearch } from "@opencode-ai/schema/websearch"
|
||||
import { Context, Effect, Layer, Schema } from "effect"
|
||||
import { makeLocationNode } from "@opencode-ai/util/effect/app-node"
|
||||
import { EventV2 } from "./event"
|
||||
import { KV } from "./kv"
|
||||
import { State } from "./state"
|
||||
|
||||
export const ID = WebSearch.ID
|
||||
export type ID = WebSearch.ID
|
||||
|
||||
export const Provider = WebSearch.Provider
|
||||
export type Provider = WebSearch.Provider
|
||||
|
||||
export const Event = WebSearch.Event
|
||||
|
||||
export const Input = WebSearch.Input
|
||||
export type Input = WebSearch.Input
|
||||
export type ProviderInput = WebSearch.ProviderInput
|
||||
|
||||
export const Result = WebSearch.Result
|
||||
export type Result = WebSearch.Result
|
||||
|
||||
export const Response = WebSearch.Response
|
||||
export type Response = WebSearch.Response
|
||||
|
||||
export interface ProviderImplementation extends Provider {
|
||||
readonly execute: (input: ProviderInput) => Effect.Effect<readonly Result[], unknown>
|
||||
}
|
||||
|
||||
export class ProviderRequiredError extends Schema.TaggedErrorClass<ProviderRequiredError>()(
|
||||
"WebSearch.ProviderRequired",
|
||||
{},
|
||||
) {}
|
||||
|
||||
export class ProviderNotFoundError extends Schema.TaggedErrorClass<ProviderNotFoundError>()(
|
||||
"WebSearch.ProviderNotFound",
|
||||
{
|
||||
providerID: ID,
|
||||
},
|
||||
) {}
|
||||
|
||||
export class DisabledError extends Schema.TaggedErrorClass<DisabledError>()("WebSearch.Disabled", {}) {}
|
||||
|
||||
export class RequestError extends Schema.TaggedErrorClass<RequestError>()("WebSearch.Request", {
|
||||
providerID: ID,
|
||||
cause: Schema.Defect(),
|
||||
}) {}
|
||||
|
||||
export type Error = ProviderRequiredError | ProviderNotFoundError | DisabledError | RequestError
|
||||
|
||||
export interface Interface extends State.Transformable<Draft> {
|
||||
readonly providers: () => Effect.Effect<readonly Provider[]>
|
||||
readonly default: () => Effect.Effect<Provider | undefined, DisabledError>
|
||||
readonly query: (input: Input) => Effect.Effect<Response, Error>
|
||||
}
|
||||
|
||||
export class Service extends Context.Service<Service, Interface>()("@opencode/v2/WebSearch") {}
|
||||
|
||||
type Data = {
|
||||
readonly providers: Map<ID, ProviderImplementation>
|
||||
defaultProviderID?: ID
|
||||
}
|
||||
|
||||
export type Draft = {
|
||||
add: (provider: ProviderImplementation) => void
|
||||
default: {
|
||||
get: () => ID | undefined
|
||||
set: (providerID: ID) => void
|
||||
}
|
||||
}
|
||||
|
||||
const layer = Layer.effect(
|
||||
Service,
|
||||
Effect.gen(function* () {
|
||||
const events = yield* EventV2.Service
|
||||
const kv = yield* KV.Service
|
||||
const decodeResults = Schema.decodeUnknownEffect(Schema.Array(Result))
|
||||
const state = State.create<Data, Draft>({
|
||||
initial: () => ({ providers: new Map() }),
|
||||
draft: (draft) => ({
|
||||
add: (provider) => draft.providers.set(provider.id, provider),
|
||||
default: {
|
||||
get: () => draft.defaultProviderID,
|
||||
set: (providerID) => (draft.defaultProviderID = providerID),
|
||||
},
|
||||
}),
|
||||
finalize: () => events.publish(Event.Updated, {}).pipe(Effect.asVoid),
|
||||
})
|
||||
|
||||
const requireProvider = (providers: Map<ID, ProviderImplementation>, providerID: ID) => {
|
||||
const provider = providers.get(providerID)
|
||||
return provider ? Effect.succeed(provider) : Effect.fail(new ProviderNotFoundError({ providerID }))
|
||||
}
|
||||
|
||||
const defaultProvider = Effect.fn("WebSearch.default")(function* () {
|
||||
const data = state.get()
|
||||
const configured = data.defaultProviderID ? data.providers.get(data.defaultProviderID) : undefined
|
||||
if (configured) return configured
|
||||
const stored = yield* kv.get("websearch:provider")
|
||||
if (stored === false) return yield* new DisabledError()
|
||||
if (typeof stored !== "string") return
|
||||
return data.providers.get(ID.make(stored))
|
||||
})
|
||||
|
||||
const resolve = Effect.fn("WebSearch.resolve")(function* (input: Input) {
|
||||
const providers = state.get().providers
|
||||
if (input.providerID) return yield* requireProvider(providers, input.providerID)
|
||||
const provider = yield* defaultProvider()
|
||||
if (!provider) return yield* new ProviderRequiredError()
|
||||
return provider
|
||||
})
|
||||
|
||||
return Service.of({
|
||||
transform: state.transform,
|
||||
reload: state.reload,
|
||||
providers: Effect.fn("WebSearch.providers")(function* () {
|
||||
return Array.from(state.get().providers.values(), (provider) => ({
|
||||
id: provider.id,
|
||||
name: provider.name,
|
||||
})).toSorted((a, b) => a.name.localeCompare(b.name))
|
||||
}),
|
||||
default: Effect.fn("WebSearch.defaultInfo")(function* () {
|
||||
const provider = yield* defaultProvider()
|
||||
return provider && { id: provider.id, name: provider.name }
|
||||
}),
|
||||
query: Effect.fn("WebSearch.query")(function* (input) {
|
||||
const provider = yield* resolve(input)
|
||||
const results = yield* provider.execute({ query: input.query }).pipe(
|
||||
Effect.flatMap(decodeResults),
|
||||
Effect.mapError((cause) => new RequestError({ providerID: provider.id, cause })),
|
||||
)
|
||||
return new Response({ providerID: provider.id, results })
|
||||
}),
|
||||
})
|
||||
}),
|
||||
)
|
||||
|
||||
export const node = makeLocationNode({
|
||||
service: Service,
|
||||
layer,
|
||||
deps: [EventV2.node, KV.node],
|
||||
})
|
||||
Loading…
Add table
Add a link
Reference in a new issue