Files
wehub-resource-sync 98e40dac97
CLI Smoke Test / smoke-test-linux (20) (push) Has been cancelled
CLI Smoke Test / smoke-test-linux (24) (push) Has been cancelled
CLI Smoke Test / smoke-test-windows (20) (push) Has been cancelled
CLI Smoke Test / smoke-test-windows (24) (push) Has been cancelled
Expo App TypeScript typecheck / typecheck (push) Has been cancelled
chore: import upstream snapshot with attribution
2026-07-13 12:40:49 +08:00

152 lines
5.4 KiB
TypeScript

import { atom, useAtomValue } from 'jotai'
import { useEffect, useState } from 'react'
import type {
LLMInferenceCapability,
ModelDescriptor,
Plugin,
PluginContext,
} from './types'
/* ─────────────────────────────────────────────────────────────────────────
* Plugin host
*
* Singleton registry. Plugins register at app startup; the UI subscribes via
* `pluginsAtom` (jotai) or the `usePlugins()` hook.
*
* Responsibilities the host owns:
* - keep the canonical list of registered plugins
* - notify subscribers when a plugin's auth or capabilities change
* - provide aggregate views (e.g. all models across plugins with the
* `llm-inference` capability) for the model picker
*
* Plugins own their own auth persistence (see plugins/anthropic, plugins/codex).
* ──────────────────────────────────────────────────────────────────────── */
interface HostState {
plugins: Plugin[]
/** Bumped on every state change so jotai re-renders subscribers. */
revision: number
}
let state: HostState = { plugins: [], revision: 0 }
const listeners = new Set<() => void>()
function emit() {
state = { ...state, revision: state.revision + 1 }
for (const fn of listeners) fn()
}
function subscribe(fn: () => void): () => void {
listeners.add(fn)
return () => listeners.delete(fn)
}
/** Internal — only plugins/index.ts should call this on startup. */
export async function registerPlugin(plugin: Plugin): Promise<void> {
if (state.plugins.some((p) => p.id === plugin.id)) {
// eslint-disable-next-line no-console
console.warn(`[plugins] duplicate id "${plugin.id}", skipping`)
return
}
state = { plugins: [...state.plugins, plugin], revision: state.revision }
const ctx: PluginContext = {
onAuthChanged: emit,
onCapabilitiesChanged: emit,
}
try {
await plugin.activate(ctx)
} catch (err) {
// eslint-disable-next-line no-console
console.error(`[plugins] activate failed for "${plugin.id}"`, err)
}
emit()
}
export const pluginHost = {
/** All registered plugins. */
all(): readonly Plugin[] {
return state.plugins
},
/** Look up by id. */
get(id: string): Plugin | undefined {
return state.plugins.find((p) => p.id === id)
},
/** Subscribe to state changes (returns an unsubscribe fn). */
subscribe,
/** Connect to a plugin by passing its credential. */
async connect(id: string, credential: string): Promise<void> {
const plugin = pluginHost.get(id)
if (!plugin) throw new Error(`unknown plugin: ${id}`)
const ctx: PluginContext = { onAuthChanged: emit, onCapabilitiesChanged: emit }
await plugin.connect(credential, ctx)
emit()
},
/** Disconnect a plugin. */
async disconnect(id: string): Promise<void> {
const plugin = pluginHost.get(id)
if (!plugin) return
const ctx: PluginContext = { onAuthChanged: emit, onCapabilitiesChanged: emit }
await plugin.disconnect(ctx)
emit()
},
/** Aggregate every model exposed by every connected llm-inference plugin. */
inferenceModels(): { plugin: Plugin; model: ModelDescriptor }[] {
const out: { plugin: Plugin; model: ModelDescriptor }[] = []
for (const plugin of state.plugins) {
for (const cap of plugin.getCapabilities()) {
if (cap.type !== 'llm-inference') continue
for (const model of cap.models) out.push({ plugin, model })
}
}
return out
},
/** Find the inference capability that owns a model id. */
inferenceFor(modelId: string): { plugin: Plugin; cap: LLMInferenceCapability } | undefined {
for (const plugin of state.plugins) {
for (const cap of plugin.getCapabilities()) {
if (cap.type !== 'llm-inference') continue
if (cap.models.some((m) => m.id === modelId)) {
return { plugin, cap }
}
}
}
return undefined
},
}
/* ─── jotai bridge ────────────────────────────────────────────────────── */
const subscribePluginHost = (cb: () => void) => subscribe(cb)
const baseAtom = atom(0)
baseAtom.onMount = (set) => {
const off = subscribe(() => set((n) => n + 1))
return off
}
export const pluginsAtom = atom((get) => {
get(baseAtom) // re-evaluate on revision bump
return state.plugins
})
/** React hook returning the current plugin list. Re-renders on host changes. */
export function usePlugins(): readonly Plugin[] {
const [, force] = useState(0)
useEffect(() => subscribe(() => force((n) => n + 1)), [])
return state.plugins
}
/** Convenience hook returning a plugin by id. */
export function usePlugin(id: string): Plugin | undefined {
const list = usePlugins()
return list.find((p) => p.id === id)
}
/** Hook returning all `(plugin, model)` pairs across connected inference plugins. */
export function useInferenceModels() {
useAtomValue(pluginsAtom) // subscribe
return pluginHost.inferenceModels()
}
export { subscribePluginHost }