chore: isolate shared dsh plugins into independent monorepo
This commit is contained in:
79
packages/web-search-searxng/src/index.ts
Normal file
79
packages/web-search-searxng/src/index.ts
Normal file
@@ -0,0 +1,79 @@
|
||||
/**
|
||||
* `@deepseek-ai/dsh-web-search-searxng`: registers a SearXNG-backed
|
||||
* `WebSearchProvider` with `ctx.web`. A function/namespace plugin (NOT a
|
||||
* default-export service): a search provider does not own the `ctx.web` key —
|
||||
* it registers INTO the seam's provider registry, exactly as
|
||||
* `@deepseek-ai/dsh-web-search-exa` does. The key is owned by
|
||||
* `@deepseek-ai/dsh-web`.
|
||||
*
|
||||
* @module @deepseek-ai/dsh-web-search-searxng
|
||||
*/
|
||||
|
||||
import type { Context } from '@deepseek-ai/cordis'
|
||||
import z from '@deepseek-ai/schemastery'
|
||||
import { installSettingsSection, settingsNamespace } from '@deepseek-ai/dsh-settings'
|
||||
import type {} from '@deepseek-ai/dsh-web'
|
||||
import { SearXngSearchProvider } from './provider.ts'
|
||||
|
||||
export {
|
||||
SEARXNG_DEFAULT_LANGUAGE,
|
||||
SEARXNG_NO_TIME_RANGE,
|
||||
SEARXNG_PROVIDER_ID,
|
||||
SEARXNG_TIME_RANGES,
|
||||
SearXngSearchProvider,
|
||||
} from './provider.ts'
|
||||
export type {
|
||||
SearXngSearchProviderOptions,
|
||||
SearXngSearchProviderSource,
|
||||
SearXngTimeRange,
|
||||
} from './provider.ts'
|
||||
|
||||
/** Cordis plugin name used by loader diagnostics. */
|
||||
export const name = 'web-search-searxng'
|
||||
|
||||
/** The web seam this provider registers into. */
|
||||
export const inject = ['web']
|
||||
|
||||
/** Plugin config. `baseURL` is optional in the schema so an omitted value
|
||||
* surfaces as an unavailable provider (the seam's `WEB_PROVIDER_CONFIGURED_UNAVAILABLE`)
|
||||
* rather than failing boot; `apply` fills constant defaults. */
|
||||
export interface Config {
|
||||
/** SearXNG instance base; `/search` is appended. Empty/unparseable makes the provider unavailable. */
|
||||
baseURL?: string
|
||||
/** SearXNG `language` to request. Defaults to `auto`. */
|
||||
language?: string
|
||||
/** SearXNG `time_range` recency filter. Omitted = no filter. */
|
||||
timeRange?: 'day' | 'week' | 'month' | 'year'
|
||||
}
|
||||
|
||||
export const Config: z<Config> = z.object({
|
||||
baseURL: z.string(),
|
||||
language: z.string(),
|
||||
timeRange: z.union(['day', 'week', 'month', 'year'] as const),
|
||||
})
|
||||
|
||||
/** Settings namespace carrying the SearXNG instance and any per-search filters. */
|
||||
export const SEARXNG_SETTINGS_NAMESPACE = settingsNamespace('web-search-searxng')
|
||||
|
||||
/** Register the SearXNG search provider with `ctx.web`, reading the live section
|
||||
* per search so an in-session settings edit applies without re-registration. */
|
||||
export function apply(ctx: Context, config: Config): void {
|
||||
let current: () => Config = () => config
|
||||
installSettingsSection(ctx, SEARXNG_SETTINGS_NAMESPACE, Config, config, {
|
||||
setSource: (source) => {
|
||||
current = source
|
||||
},
|
||||
// The registration carries no resolved value: the provider projects the
|
||||
// section per search, so a committed change needs no re-registration.
|
||||
onChange: () => {},
|
||||
})
|
||||
ctx.web.registerSearchProvider(new SearXngSearchProvider(() => ({
|
||||
// SearXNG exposes no uniform public endpoint and the product requires
|
||||
// evidence over an unsupported default, so `baseURL` has no constant
|
||||
// fallback: an omitted value surfaces as an unavailable provider (the
|
||||
// seam's `WEB_PROVIDER_CONFIGURED_UNAVAILABLE`), not a silent endpoint.
|
||||
baseURL: current().baseURL ?? '',
|
||||
...current().language !== undefined ? { language: current().language } : {},
|
||||
...current().timeRange !== undefined ? { timeRange: current().timeRange } : {},
|
||||
})))
|
||||
}
|
||||
30
packages/web-search-searxng/src/invariant.ts
Normal file
30
packages/web-search-searxng/src/invariant.ts
Normal file
@@ -0,0 +1,30 @@
|
||||
/**
|
||||
* Package-owned invariant companion for `@deepseek-ai/dsh-web-search-searxng`.
|
||||
* @module @deepseek-ai/dsh-web-search-searxng/invariant
|
||||
*/
|
||||
|
||||
/* jscpd:ignore-start */
|
||||
import type { Context } from '@deepseek-ai/cordis'
|
||||
import type { InvariantInstaller } from '@deepseek-ai/dsh-invariants'
|
||||
|
||||
const PACKAGE_NAME = '@deepseek-ai/dsh-web-search-searxng'
|
||||
|
||||
/** Cordis companion plugin name. */
|
||||
export const name = 'web-search-searxng-invariant'
|
||||
/** Service required before the companion can reserve package ownership. */
|
||||
export const inject = ['invariants']
|
||||
|
||||
/**
|
||||
* No runtime invariant: this package exposes no independent event sequence or mutable data relation
|
||||
* beyond contracts enforced at its owning seam.
|
||||
*/
|
||||
const install: InvariantInstaller = () => {}
|
||||
|
||||
/**
|
||||
* Register this package's invariant companion.
|
||||
* @param ctx - Cordis context carrying the invariant service.
|
||||
* @returns the installed registration's disposer after setup succeeds.
|
||||
*/
|
||||
export const apply = (ctx: Context): Promise<() => void> =>
|
||||
Promise.resolve(ctx.invariants.register(PACKAGE_NAME, install))
|
||||
/* jscpd:ignore-end */
|
||||
166
packages/web-search-searxng/src/provider.ts
Normal file
166
packages/web-search-searxng/src/provider.ts
Normal file
@@ -0,0 +1,166 @@
|
||||
/**
|
||||
* `SearXngSearchProvider`: a `WebSearchProvider` backed by a SearXNG instance's
|
||||
* JSON API (`GET /search?format=json`). It maps against the flat `results[]`,
|
||||
* keeps entries even without a snippet (URL and title remain useful), and
|
||||
* omits `content` because SearXNG returns no generated answer. The provider
|
||||
* carries no credentials, so a request carries no `Authorization` header.
|
||||
* @module @deepseek-ai/dsh-web-search-searxng/provider
|
||||
*/
|
||||
|
||||
import { WebError } from '@deepseek-ai/dsh-web'
|
||||
import type {
|
||||
WebSearchProvider,
|
||||
WebSearchRequest,
|
||||
WebSearchResult,
|
||||
WebSearchSource,
|
||||
} from '@deepseek-ai/dsh-web'
|
||||
import type { SearXngError, SearXngResult, SearXngSearchResponse } from './types.ts'
|
||||
|
||||
/** Stable id this provider registers under. */
|
||||
export const SEARXNG_PROVIDER_ID = 'searxng'
|
||||
|
||||
/** Default language: let SearXNG decide per user preferences. */
|
||||
export const SEARXNG_DEFAULT_LANGUAGE = 'auto'
|
||||
|
||||
/** `time_range` sentinel meaning "no recency filter". */
|
||||
export const SEARXNG_NO_TIME_RANGE = 'none'
|
||||
|
||||
/** Valid `time_range` values SearXNG accepts. */
|
||||
export const SEARXNG_TIME_RANGES = ['day', 'week', 'month', 'year'] as const
|
||||
|
||||
/** One of SearXNG's `time_range` values, passed through to the endpoint. */
|
||||
export type SearXngTimeRange = (typeof SEARXNG_TIME_RANGES)[number]
|
||||
|
||||
/** Attribution header sent on every request. Bump with the package version. */
|
||||
const USER_AGENT = 'deepseek-harness/0.0.1'
|
||||
|
||||
/** Resolved provider options (the plugin's `apply` supplies env-var and constant defaults). */
|
||||
export interface SearXngSearchProviderOptions {
|
||||
/** SearXNG instance base; `/search` is appended. Empty/unparseable makes the provider unavailable. */
|
||||
baseURL: string
|
||||
/** SearXNG `language` to request. `auto` lets the instance decide. */
|
||||
language?: string
|
||||
/** SearXNG `time_range` recency filter. */
|
||||
timeRange?: SearXngTimeRange
|
||||
}
|
||||
|
||||
/** A snapshot or a per-search resolver (settings live-reload hands the latter). */
|
||||
export type SearXngSearchProviderSource = SearXngSearchProviderOptions | (() => SearXngSearchProviderOptions)
|
||||
|
||||
/**
|
||||
* Map one SearXNG result to a normalized source. The URL is always carried; a
|
||||
* blank title or snippet is omitted rather than emitted as empty.
|
||||
*
|
||||
* @param result - one entry of SearXNG's `results[]`.
|
||||
* @returns the normalized source.
|
||||
*/
|
||||
export function mapSearXngResult(result: SearXngResult): WebSearchSource {
|
||||
return {
|
||||
url: result.url,
|
||||
...result.title != null && result.title.trim().length > 0 ? { title: result.title } : {},
|
||||
...result.content != null && result.content.trim().length > 0 ? { snippet: result.content } : {},
|
||||
...result.publishedDate != null && result.publishedDate.length > 0 ? { publishedAt: result.publishedDate } : {},
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Map a SearXNG response envelope to a normalized search result.
|
||||
*
|
||||
* @param response - the parsed `format=json` response body.
|
||||
* @returns the normalized result; `content` is omitted (no generated answer).
|
||||
*/
|
||||
export function mapSearXngResponse(response: SearXngSearchResponse): WebSearchResult {
|
||||
// SearXNG cannot bound per-request result count (page size is instance
|
||||
// configuration), so the request carries no count and the web service owns
|
||||
// the final `maxResults` truncation; this provider reports `truncated: false`.
|
||||
return { sources: (response.results ?? []).map(mapSearXngResult), truncated: false }
|
||||
}
|
||||
|
||||
/** The SearXNG-backed search provider; HTTP redirects fail as `WEB_PROVIDER_ERROR`. */
|
||||
export class SearXngSearchProvider implements WebSearchProvider {
|
||||
readonly id = SEARXNG_PROVIDER_ID
|
||||
|
||||
constructor(source: SearXngSearchProviderSource) {
|
||||
this.source = source
|
||||
}
|
||||
|
||||
private readonly source: SearXngSearchProviderSource
|
||||
|
||||
/** Read the current options: a snapshot stays fixed, a resolver reads live settings. */
|
||||
private resolveOptions(): SearXngSearchProviderOptions {
|
||||
return typeof this.source === 'function' ? this.source() : this.source
|
||||
}
|
||||
|
||||
available(): boolean {
|
||||
const options = this.resolveOptions()
|
||||
return isValidBaseUrl(options.baseURL)
|
||||
&& (options.language === undefined || options.language.length > 0)
|
||||
&& (options.timeRange === undefined || SEARXNG_TIME_RANGES.includes(options.timeRange))
|
||||
}
|
||||
|
||||
async search(request: WebSearchRequest, signal?: AbortSignal): Promise<WebSearchResult> {
|
||||
const options = this.resolveOptions()
|
||||
const base = trimTrailingSlashes(options.baseURL)
|
||||
const params = new URLSearchParams({ q: request.query, format: 'json' })
|
||||
if (options.language !== undefined) params.set('language', options.language)
|
||||
if (options.timeRange !== undefined) params.set('time_range', options.timeRange)
|
||||
|
||||
let response: Response
|
||||
try {
|
||||
response = await fetch(`${base}/search?${params}`, {
|
||||
method: 'GET',
|
||||
redirect: 'error',
|
||||
headers: {
|
||||
'accept': 'application/json',
|
||||
'user-agent': USER_AGENT,
|
||||
},
|
||||
...signal !== undefined ? { signal } : {},
|
||||
})
|
||||
} catch (error: unknown) {
|
||||
if (isAbortError(error)) throw new WebError('SearXNG search aborted', 'WEB_ABORTED', { cause: error })
|
||||
throw new WebError(`SearXNG search request failed: ${String(error)}`, 'WEB_PROVIDER_ERROR', { cause: error })
|
||||
}
|
||||
|
||||
if (!response.ok) {
|
||||
const status = response.status
|
||||
let message = `SearXNG API error (HTTP ${status})`
|
||||
try {
|
||||
const parsed = await response.json() as SearXngError
|
||||
const detail = parsed.error ?? parsed.message ?? parsed.content
|
||||
if (detail !== undefined && detail.length > 0) message = detail
|
||||
} catch (error: unknown) {
|
||||
// An abort fired mid-body must surface as WEB_ABORTED, not be swallowed
|
||||
// into a generic HTTP-error message — cancellation is not a provider
|
||||
// error (the seam's cancellation contract).
|
||||
if (isAbortError(error)) throw new WebError('SearXNG search aborted', 'WEB_ABORTED', { cause: error })
|
||||
// Otherwise: the HTTP status is already captured in `message` above; a
|
||||
// malformed/non-JSON error body (normal for gateway 5xx/429s) can only
|
||||
// cost a richer provider message, never the real error.
|
||||
}
|
||||
throw new WebError(message, 'WEB_PROVIDER_ERROR')
|
||||
}
|
||||
|
||||
try {
|
||||
const payload = await response.json() as SearXngSearchResponse
|
||||
return mapSearXngResponse(payload)
|
||||
} catch (error: unknown) {
|
||||
if (isAbortError(error)) throw new WebError('SearXNG search aborted', 'WEB_ABORTED', { cause: error })
|
||||
throw new WebError(`SearXNG returned an unprocessable response body: ${String(error)}`, 'WEB_PROVIDER_ERROR', { cause: error })
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** True when `baseURL` parses as an absolute URL (a cheap local config check). */
|
||||
function isValidBaseUrl(baseURL: string): boolean {
|
||||
return URL.canParse(baseURL)
|
||||
}
|
||||
|
||||
/** Strip a trailing slash so `${base}/search` never doubles a separator. */
|
||||
function trimTrailingSlashes(baseURL: string): string {
|
||||
return baseURL.replace(/\/+$/, '')
|
||||
}
|
||||
|
||||
/** True for a fetch/`AbortSignal` abort, surfaced as `WEB_ABORTED`. */
|
||||
function isAbortError(error: unknown): boolean {
|
||||
return error instanceof DOMException && error.name === 'AbortError'
|
||||
}
|
||||
30
packages/web-search-searxng/src/types.ts
Normal file
30
packages/web-search-searxng/src/types.ts
Normal file
@@ -0,0 +1,30 @@
|
||||
/**
|
||||
* Wire types for the SearXNG JSON API (`GET {base}/search?q=...&format=json`).
|
||||
* Types only — no runtime code. SearXNG returns a flat `results[]`; each entry
|
||||
* carries a URL, an optional title, an optional `content` snippet, and an
|
||||
* optional `publishedDate`.
|
||||
*
|
||||
* @module @deepseek-ai/dsh-web-search-searxng/types
|
||||
*/
|
||||
|
||||
/** One entry of SearXNG's flat `results[]`. */
|
||||
export interface SearXngResult {
|
||||
url: string
|
||||
title?: string | null
|
||||
/** The page snippet SearXNG derives for the result. */
|
||||
content?: string | null
|
||||
publishedDate?: string | null
|
||||
}
|
||||
|
||||
/** SearXNG's JSON search response envelope. */
|
||||
export interface SearXngSearchResponse {
|
||||
results?: SearXngResult[]
|
||||
}
|
||||
|
||||
/** SearXNG's error response envelope (best-effort; fields vary by failure). */
|
||||
export interface SearXngError {
|
||||
error?: string
|
||||
message?: string
|
||||
/** Some SearXNG error responses carry the message under `content`. */
|
||||
content?: string
|
||||
}
|
||||
Reference in New Issue
Block a user