| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146 |
- // Codex 插件目录(ADR-0018):OpenAI 官方 openai-curated 目录中的插件,经 app-server 的 plugin/* 方法安装和管理。
- // 插件可能带应用连接器(Gmail、Google Drive 等,使用登录账号的授权),所以只在后台 Playground 的会话中开放,
- // 经 API 调用的会话始终关闭(providers/codex/permissions.ts)。
- import type { AppSettings, CodexPluginCatalogDto, CodexPluginDetailDto, CodexPluginDto, CodexPluginInstallResultDto } from '@agent-server/shared'
- import { AppError, badRequest, notFound } from '../lib/errors'
- import type {
- PluginInstallResponse, PluginInstalledResponse, PluginListResponse, PluginMarketplaceEntry, PluginReadResponse, PluginSummary,
- } from '../providers/codex/protocol/v2'
- import { RpcRequestError } from '../providers/codex/rpc'
- const CACHE_MS = 10 * 60_000
- const PAGE_MAX = 100
- /** 只管理 OpenAI 官方目录(远程目录 openai-curated-remote) */
- const isCurated = (marketplace: string) => marketplace.startsWith('openai-curated')
- /** CodexProvider 中本服务用到的部分 */
- export interface CodexRpc {
- request<T>(settings: AppSettings, method: string, params: unknown, timeoutMs?: number): Promise<T>
- }
- export class CodexPluginService {
- private cache: { at: number; items: CodexPluginDto[] } | null = null
- constructor(
- private codex: () => CodexRpc | null,
- private settings: () => AppSettings,
- private audit: (summary: string) => void,
- ) {}
- private rpc(): CodexRpc {
- const c = this.codex()
- if (!c) throw new AppError(503, 'codex_unavailable', 'Codex 引擎未启用')
- return c
- }
- /** Codex 返回的错误(如远程目录请求失败)原样转给后台,而不是笼统的"服务器内部错误" */
- private async call<T>(method: string, params: unknown, timeoutMs = 60_000): Promise<T> {
- try {
- return await this.rpc().request<T>(this.settings(), method, params, timeoutMs)
- } catch (err) {
- if (err instanceof RpcRequestError) throw new AppError(502, 'codex_error', `Codex:${err.rpc.message}`)
- throw err
- }
- }
- async catalog(q: { query?: string; category?: string; installed?: boolean; offset?: number; limit?: number; refresh?: boolean }): Promise<CodexPluginCatalogDto> {
- let all: CodexPluginDto[]
- try {
- all = await this.load(q.refresh ?? false)
- } catch (err) {
- return { items: [], total: 0, categories: [], fetchedAt: null, error: (err as Error).message }
- }
- const text = q.query?.trim().toLowerCase()
- const filtered = all.filter(p =>
- (!text || `${p.name} ${p.displayName} ${p.description} ${p.developer ?? ''}`.toLowerCase().includes(text))
- && (!q.category || p.category === q.category)
- && (!q.installed || p.installed))
- const offset = Math.max(0, q.offset ?? 0)
- const limit = Math.min(Math.max(1, q.limit ?? 50), PAGE_MAX)
- return {
- items: filtered.slice(offset, offset + limit),
- total: filtered.length,
- categories: [...new Set(all.map(p => p.category).filter((c): c is string => !!c))].sort(),
- fetchedAt: this.cache ? new Date(this.cache.at).toISOString() : null,
- error: null,
- }
- }
- async detail(name: string): Promise<CodexPluginDetailDto> {
- const p = await this.find(name)
- const r = await this.call<PluginReadResponse>('plugin/read', { remoteMarketplaceName: p.marketplace, pluginName: p.name })
- const d = r.plugin
- const i = d.summary.interface
- return {
- ...toDto(d.summary, d.marketplaceName),
- // plugin/read 对远程插件不返回安装状态,以目录为准
- installed: p.installed, enabled: p.enabled,
- description: d.description ?? i?.shortDescription ?? '',
- longDescription: i?.longDescription ?? null,
- skills: d.skills.map(s => ({ name: s.name, description: s.shortDescription ?? s.description })),
- apps: d.apps.map(a => ({ id: a.id, name: a.name, description: a.description, installUrl: a.installUrl })),
- mcpServers: d.mcpServers,
- websiteUrl: i?.websiteUrl ?? null, privacyPolicyUrl: i?.privacyPolicyUrl ?? null, termsOfServiceUrl: i?.termsOfServiceUrl ?? null,
- }
- }
- async install(name: string): Promise<CodexPluginInstallResultDto> {
- const p = await this.find(name)
- if (p.availability !== 'AVAILABLE' || p.installPolicy === 'NOT_AVAILABLE') throw badRequest(`插件 ${p.displayName} 当前不可安装`)
- // 远程插件按 remotePluginId 安装:目录里的名称(如 clickup)在远程安装接口上会返回 404 Plugin not found
- const read = await this.call<PluginReadResponse>('plugin/read', { remoteMarketplaceName: p.marketplace, pluginName: p.name })
- const pluginName = read.plugin.summary.remotePluginId ?? p.name
- const r = await this.call<PluginInstallResponse>('plugin/install', { remoteMarketplaceName: p.marketplace, pluginName }, 300_000)
- this.cache = null
- this.audit(`安装 Codex 插件 ${p.displayName}(${p.id})`)
- return { appsNeedingAuth: r.appsNeedingAuth.map(a => ({ id: a.id, name: a.name, installUrl: a.installUrl })) }
- }
- async uninstall(name: string): Promise<{ ok: true }> {
- const p = await this.find(name)
- if (!p.installed) throw badRequest(`插件 ${p.displayName} 没有安装`)
- await this.call('plugin/uninstall', { pluginId: p.id }, 120_000)
- this.cache = null
- this.audit(`卸载 Codex 插件 ${p.displayName}(${p.id})`)
- return { ok: true }
- }
- private async find(name: string): Promise<CodexPluginDto> {
- if (!/^[\w.-]{1,120}$/.test(name)) throw badRequest('插件名称格式不正确')
- const p = (await this.load(false)).find(x => x.name === name)
- if (!p) throw notFound(`Codex 插件 ${name}`)
- return p
- }
- /** 目录(7000+ 项)缓存 10 分钟;已安装状态以 plugin/installed 为准 */
- private async load(refresh: boolean): Promise<CodexPluginDto[]> {
- if (!refresh && this.cache && Date.now() - this.cache.at < CACHE_MS) return this.cache.items
- const [list, installed] = await Promise.all([
- this.call<PluginListResponse>('plugin/list', { forceRefetch: refresh }, 120_000),
- this.call<PluginInstalledResponse>('plugin/installed', {}),
- ])
- const installedIds = new Set(curated(installed.marketplaces).filter(x => x.p.installed).map(x => x.p.id))
- const items = new Map<string, CodexPluginDto>()
- // 已安装但不在目录中的插件也要列出,便于卸载
- for (const { p, m } of [...curated(list.marketplaces), ...curated(installed.marketplaces)]) {
- if (!items.has(p.id)) items.set(p.id, { ...toDto(p, m), installed: installedIds.has(p.id) || p.installed })
- }
- const sorted = [...items.values()].sort((a, b) => Number(b.installed) - Number(a.installed))
- this.cache = { at: Date.now(), items: sorted }
- return sorted
- }
- }
- function curated(mkts: PluginMarketplaceEntry[]): { p: PluginSummary; m: string }[] {
- return mkts.filter(m => isCurated(m.name)).flatMap(m => m.plugins.map(p => ({ p, m: m.name })))
- }
- function toDto(p: PluginSummary, marketplace: string): CodexPluginDto {
- const i = p.interface
- return {
- id: p.id, name: p.name, displayName: i?.displayName ?? p.name, description: i?.shortDescription ?? '',
- category: i?.category ?? null, developer: i?.developerName ?? null, marketplace,
- installed: p.installed, enabled: p.enabled, installPolicy: p.installPolicy, availability: p.availability,
- logoUrl: i?.logoUrl ?? null,
- }
- }
|