|
@@ -1,7 +1,7 @@
|
|
|
<script setup lang="ts">
|
|
<script setup lang="ts">
|
|
|
-import { computed, onMounted, ref } from 'vue'
|
|
|
|
|
|
|
+import { computed, onMounted, ref, watch } from 'vue'
|
|
|
import type {
|
|
import type {
|
|
|
- AvailablePluginDto, CodexSkillCatalogDto, CommunitySearchResultDto, CommunitySkillDto, CommunitySkillPreviewDto, PluginAction, PluginStateDto,
|
|
|
|
|
|
|
+ AvailablePluginDto, CodexPluginCatalogDto, CodexPluginDetailDto, CodexPluginDto, CodexSkillCatalogDto, CommunitySearchResultDto, CommunitySkillDto, CommunitySkillPreviewDto, PluginAction, PluginStateDto,
|
|
|
} from '@agent-server/shared'
|
|
} from '@agent-server/shared'
|
|
|
import PageHeader from '../components/PageHeader.vue'
|
|
import PageHeader from '../components/PageHeader.vue'
|
|
|
import { admin, errorMessage } from '../lib/api'
|
|
import { admin, errorMessage } from '../lib/api'
|
|
@@ -10,7 +10,7 @@ import { formatTime, toast } from '../lib/ui'
|
|
|
const OFFICIAL_SOURCE = 'anthropics/skills'
|
|
const OFFICIAL_SOURCE = 'anthropics/skills'
|
|
|
|
|
|
|
|
// 每个引擎接入各自厂商的官方技能仓库;第三方技能两个引擎共用
|
|
// 每个引擎接入各自厂商的官方技能仓库;第三方技能两个引擎共用
|
|
|
-const tab = ref<'claude' | 'codex' | 'community'>('claude')
|
|
|
|
|
|
|
+const tab = ref<'claude' | 'codex' | 'codex-plugins' | 'community'>('claude')
|
|
|
const codex = ref<CodexSkillCatalogDto | null>(null)
|
|
const codex = ref<CodexSkillCatalogDto | null>(null)
|
|
|
const codexBusy = ref<string | null>(null)
|
|
const codexBusy = ref<string | null>(null)
|
|
|
const codexSearch = ref('')
|
|
const codexSearch = ref('')
|
|
@@ -44,6 +44,77 @@ async function codexAct(key: string, fn: () => Promise<CodexSkillCatalogDto>, do
|
|
|
}
|
|
}
|
|
|
}
|
|
}
|
|
|
|
|
|
|
|
|
|
+// Codex 插件(openai-curated):只在 Playground 中使用
|
|
|
|
|
+const plugins = ref<CodexPluginCatalogDto | null>(null)
|
|
|
|
|
+const pluginQuery = ref('')
|
|
|
|
|
+const pluginCategory = ref('')
|
|
|
|
|
+const pluginInstalledOnly = ref(false)
|
|
|
|
|
+const pluginBusy = ref<string | null>(null)
|
|
|
|
|
+const pluginDetail = ref<CodexPluginDetailDto | null>(null)
|
|
|
|
|
+const pluginAuth = ref<{ name: string; apps: { id: string; name: string; installUrl: string | null }[] } | null>(null)
|
|
|
|
|
+
|
|
|
|
|
+async function loadPlugins(opts: { more?: boolean; refresh?: boolean } = {}) {
|
|
|
|
|
+ pluginBusy.value = opts.refresh ? 'refresh' : 'load'
|
|
|
|
|
+ try {
|
|
|
|
|
+ const r = await admin.codexPlugins.catalog({
|
|
|
|
|
+ q: pluginQuery.value.trim(), category: pluginCategory.value, installed: pluginInstalledOnly.value,
|
|
|
|
|
+ offset: opts.more ? plugins.value?.items.length ?? 0 : 0, refresh: opts.refresh,
|
|
|
|
|
+ })
|
|
|
|
|
+ plugins.value = opts.more && plugins.value ? { ...r, items: [...plugins.value.items, ...r.items] } : r
|
|
|
|
|
+ } catch (err) {
|
|
|
|
|
+ toast(errorMessage(err), 'error')
|
|
|
|
|
+ } finally {
|
|
|
|
|
+ pluginBusy.value = null
|
|
|
|
|
+ }
|
|
|
|
|
+}
|
|
|
|
|
+let pluginTimer: ReturnType<typeof setTimeout> | undefined
|
|
|
|
|
+watch([pluginQuery, pluginCategory, pluginInstalledOnly], () => {
|
|
|
|
|
+ clearTimeout(pluginTimer)
|
|
|
|
|
+ pluginTimer = setTimeout(() => void loadPlugins(), 300)
|
|
|
|
|
+})
|
|
|
|
|
+watch(tab, t => { if (t === 'codex-plugins' && !plugins.value) void loadPlugins() })
|
|
|
|
|
+
|
|
|
|
|
+async function openPlugin(p: CodexPluginDto) {
|
|
|
|
|
+ pluginBusy.value = p.name
|
|
|
|
|
+ try {
|
|
|
|
|
+ pluginDetail.value = await admin.codexPlugins.detail(p.name)
|
|
|
|
|
+ } catch (err) {
|
|
|
|
|
+ toast(errorMessage(err), 'error')
|
|
|
|
|
+ } finally {
|
|
|
|
|
+ pluginBusy.value = null
|
|
|
|
|
+ }
|
|
|
|
|
+}
|
|
|
|
|
+
|
|
|
|
|
+async function installPlugin(p: CodexPluginDto) {
|
|
|
|
|
+ pluginBusy.value = p.name
|
|
|
|
|
+ try {
|
|
|
|
|
+ const r = await admin.codexPlugins.install(p.name)
|
|
|
|
|
+ toast(`${p.displayName} 已安装,可在 Playground 的 Codex 会话中使用`, 'success')
|
|
|
|
|
+ if (r.appsNeedingAuth.length) pluginAuth.value = { name: p.displayName, apps: r.appsNeedingAuth }
|
|
|
|
|
+ pluginDetail.value = null
|
|
|
|
|
+ await loadPlugins()
|
|
|
|
|
+ } catch (err) {
|
|
|
|
|
+ toast(errorMessage(err), 'error')
|
|
|
|
|
+ } finally {
|
|
|
|
|
+ pluginBusy.value = null
|
|
|
|
|
+ }
|
|
|
|
|
+}
|
|
|
|
|
+
|
|
|
|
|
+async function uninstallPlugin(p: CodexPluginDto) {
|
|
|
|
|
+ if (!window.confirm(`卸载 Codex 插件 ${p.displayName}?`)) return
|
|
|
|
|
+ pluginBusy.value = p.name
|
|
|
|
|
+ try {
|
|
|
|
|
+ await admin.codexPlugins.uninstall(p.name)
|
|
|
|
|
+ toast(`${p.displayName} 已卸载`, 'success')
|
|
|
|
|
+ pluginDetail.value = null
|
|
|
|
|
+ await loadPlugins()
|
|
|
|
|
+ } catch (err) {
|
|
|
|
|
+ toast(errorMessage(err), 'error')
|
|
|
|
|
+ } finally {
|
|
|
|
|
+ pluginBusy.value = null
|
|
|
|
|
+ }
|
|
|
|
|
+}
|
|
|
|
|
+
|
|
|
// 第三方技能:skills.sh 搜索 → 预览 SKILL.md → 按提交安装
|
|
// 第三方技能:skills.sh 搜索 → 预览 SKILL.md → 按提交安装
|
|
|
const community = ref<CommunitySkillDto[]>([])
|
|
const community = ref<CommunitySkillDto[]>([])
|
|
|
const communityQuery = ref('')
|
|
const communityQuery = ref('')
|
|
@@ -179,14 +250,71 @@ onMounted(() => {
|
|
|
</PageHeader>
|
|
</PageHeader>
|
|
|
<div class="flex-none flex gap-1 px-6 border-b border-b-solid border-border">
|
|
<div class="flex-none flex gap-1 px-6 border-b border-b-solid border-border">
|
|
|
<button
|
|
<button
|
|
|
- v-for="t in ([['claude', 'Claude · Anthropic 官方技能'], ['codex', 'Codex · OpenAI 官方技能'], ['community', '第三方技能 · 两个引擎']] as const)" :key="t[0]" type="button"
|
|
|
|
|
|
|
+ v-for="t in ([['claude', 'Claude · Anthropic 官方技能'], ['codex', 'Codex · OpenAI 官方技能'], ['codex-plugins', 'Codex · OpenAI 插件'], ['community', '第三方技能 · 两个引擎']] as const)" :key="t[0]" type="button"
|
|
|
class="h-9 px-3 border-none bg-transparent cursor-pointer text-fg-muted hover:text-fg border-b-solid border-b-2"
|
|
class="h-9 px-3 border-none bg-transparent cursor-pointer text-fg-muted hover:text-fg border-b-solid border-b-2"
|
|
|
:class="tab === t[0] ? '!text-fg border-b-accent' : 'border-b-transparent'" @click="tab = t[0]"
|
|
:class="tab === t[0] ? '!text-fg border-b-accent' : 'border-b-transparent'" @click="tab = t[0]"
|
|
|
>{{ t[1] }}</button>
|
|
>{{ t[1] }}</button>
|
|
|
</div>
|
|
</div>
|
|
|
<div class="flex-1 overflow-y-auto">
|
|
<div class="flex-1 overflow-y-auto">
|
|
|
|
|
+ <!-- Codex 插件:openai-curated 目录,只在 Playground 中使用 -->
|
|
|
|
|
+ <div v-if="tab === 'codex-plugins'" class="max-w-240 p-6 flex flex-col gap-5">
|
|
|
|
|
+ <section class="card p-4 flex gap-3 border-[color-mix(in_srgb,var(--app-warning)_45%,transparent)]">
|
|
|
|
|
+ <span class="codicon codicon-extensions text-warning text-[18px] mt-0.5" />
|
|
|
|
|
+ <div class="flex flex-col gap-1.5 text-[12.5px]">
|
|
|
|
|
+ <div class="font-600 text-[13px]">来源:OpenAI 官方插件目录(openai-curated),<b>只在 Playground 中使用</b></div>
|
|
|
|
|
+ <div>插件由 Codex 自己安装和管理,可能包含技能和应用连接器(如 Gmail、Google Drive)。连接器<b>使用 Codex 登录账号的授权</b>访问对应数据,首次使用需要在 ChatGPT 中完成连接。</div>
|
|
|
|
|
+ <div>只有在后台 Playground 中创建的 Codex 会话会开放已安装的插件,调用连接器前会弹出审批;<b>经 API Key 创建的会话始终关闭插件与连接器</b>。对话中不能安装新插件。</div>
|
|
|
|
|
+ </div>
|
|
|
|
|
+ </section>
|
|
|
|
|
+ <div class="flex items-center gap-3 flex-wrap">
|
|
|
|
|
+ <div class="relative flex-1 min-w-60 max-w-100">
|
|
|
|
|
+ <span class="codicon codicon-search absolute left-2 top-1/2 -translate-y-1/2 text-fg-muted text-[13px]" />
|
|
|
|
|
+ <input v-model="pluginQuery" class="field pl-7" placeholder="搜索插件名称、描述或开发者" aria-label="搜索插件">
|
|
|
|
|
+ </div>
|
|
|
|
|
+ <select v-model="pluginCategory" class="field w-44" aria-label="分类">
|
|
|
|
|
+ <option value="">全部分类</option>
|
|
|
|
|
+ <option v-for="c in plugins?.categories" :key="c" :value="c">{{ c }}</option>
|
|
|
|
|
+ </select>
|
|
|
|
|
+ <label class="flex items-center gap-1.5 cursor-pointer text-[12px]"><input v-model="pluginInstalledOnly" type="checkbox">只看已安装</label>
|
|
|
|
|
+ <span class="flex-1" />
|
|
|
|
|
+ <button type="button" class="btn-secondary" :disabled="!!pluginBusy" @click="loadPlugins({ refresh: true })">
|
|
|
|
|
+ <span class="codicon" :class="pluginBusy === 'refresh' ? 'codicon-loading codicon-modifier-spin' : 'codicon-sync'" />更新目录
|
|
|
|
|
+ </button>
|
|
|
|
|
+ </div>
|
|
|
|
|
+ <div v-if="plugins?.error" class="text-error text-[12px]">读取插件目录失败:{{ plugins.error }}(需要 Codex 已登录)</div>
|
|
|
|
|
+ <div v-if="plugins && !plugins.error" class="hint">共 {{ plugins.total }} 个<template v-if="plugins.fetchedAt"> · 目录获取于 {{ formatTime(plugins.fetchedAt) }}</template></div>
|
|
|
|
|
+ <div class="card divide-y divide-x-0 divide-solid divide-border">
|
|
|
|
|
+ <div v-for="p in plugins?.items" :key="p.id" class="flex items-start gap-3 px-4 py-2.5">
|
|
|
|
|
+ <img v-if="p.logoUrl" :src="p.logoUrl" alt="" class="w-7 h-7 rounded-md flex-none mt-0.5 bg-code" loading="lazy" referrerpolicy="no-referrer">
|
|
|
|
|
+ <span v-else class="w-7 h-7 rounded-md flex-none mt-0.5 bg-code flex items-center justify-center"><span class="codicon codicon-extensions text-fg-muted" /></span>
|
|
|
|
|
+ <div class="flex-1 min-w-0">
|
|
|
|
|
+ <div class="flex items-center gap-2 flex-wrap">
|
|
|
|
|
+ <span class="font-500">{{ p.displayName }}</span>
|
|
|
|
|
+ <span class="hint">{{ [p.developer, p.category].filter(Boolean).join(' · ') }}</span>
|
|
|
|
|
+ <span v-if="p.installed" class="badge bg-code text-accent">已安装</span>
|
|
|
|
|
+ <span v-if="p.installPolicy === 'INSTALLED_BY_DEFAULT'" class="badge bg-code">默认安装</span>
|
|
|
|
|
+ <span v-if="p.availability !== 'AVAILABLE' || p.installPolicy === 'NOT_AVAILABLE'" class="badge bg-code text-warning">不可用</span>
|
|
|
|
|
+ </div>
|
|
|
|
|
+ <div class="hint line-clamp-2">{{ p.description }}</div>
|
|
|
|
|
+ </div>
|
|
|
|
|
+ <button type="button" class="btn-ghost flex-none" :disabled="!!pluginBusy" @click="openPlugin(p)">
|
|
|
|
|
+ <span class="codicon" :class="pluginBusy === p.name ? 'codicon-loading codicon-modifier-spin' : 'codicon-info'" />详情
|
|
|
|
|
+ </button>
|
|
|
|
|
+ <button v-if="p.installed" type="button" class="icon-btn flex-none" title="卸载" :disabled="!!pluginBusy" @click="uninstallPlugin(p)"><span class="codicon codicon-trash" /></button>
|
|
|
|
|
+ <button v-else type="button" class="btn-secondary flex-none" :disabled="!!pluginBusy || p.availability !== 'AVAILABLE' || p.installPolicy === 'NOT_AVAILABLE'" @click="installPlugin(p)">
|
|
|
|
|
+ <span class="codicon codicon-cloud-download" />安装
|
|
|
|
|
+ </button>
|
|
|
|
|
+ </div>
|
|
|
|
|
+ <div v-if="plugins && !plugins.error && !plugins.items.length" class="px-4 py-3 hint">没有匹配的插件</div>
|
|
|
|
|
+ <div v-if="!plugins" class="px-4 py-3 hint">正在读取插件目录…</div>
|
|
|
|
|
+ </div>
|
|
|
|
|
+ <button v-if="plugins && plugins.items.length < plugins.total" type="button" class="btn-secondary self-center" :disabled="!!pluginBusy" @click="loadPlugins({ more: true })">
|
|
|
|
|
+ 加载更多({{ plugins.items.length }} / {{ plugins.total }})
|
|
|
|
|
+ </button>
|
|
|
|
|
+ </div>
|
|
|
|
|
+
|
|
|
<!-- 第三方技能:skills.sh 目录 → GitHub -->
|
|
<!-- 第三方技能:skills.sh 目录 → GitHub -->
|
|
|
- <div v-if="tab === 'community'" class="max-w-240 p-6 flex flex-col gap-6">
|
|
|
|
|
|
|
+ <div v-else-if="tab === 'community'" class="max-w-240 p-6 flex flex-col gap-6">
|
|
|
<section class="card p-4 flex gap-3 border-[color-mix(in_srgb,var(--app-warning)_45%,transparent)]">
|
|
<section class="card p-4 flex gap-3 border-[color-mix(in_srgb,var(--app-warning)_45%,transparent)]">
|
|
|
<span class="codicon codicon-shield text-warning text-[18px] mt-0.5" />
|
|
<span class="codicon codicon-shield text-warning text-[18px] mt-0.5" />
|
|
|
<div class="flex flex-col gap-1.5 text-[12.5px]">
|
|
<div class="flex flex-col gap-1.5 text-[12.5px]">
|
|
@@ -431,6 +559,55 @@ onMounted(() => {
|
|
|
</div>
|
|
</div>
|
|
|
</div>
|
|
</div>
|
|
|
|
|
|
|
|
|
|
+ <!-- Codex 插件详情 -->
|
|
|
|
|
+ <div v-if="pluginDetail" class="fixed inset-0 z-40 flex items-center justify-center bg-[var(--app-overlay)]" @click.self="pluginDetail = null">
|
|
|
|
|
+ <div class="w-[min(640px,92vw)] max-h-[85vh] overflow-y-auto card bg-input-alt p-5 flex flex-col gap-3 shadow-xl text-[12.5px]">
|
|
|
|
|
+ <div class="flex items-center gap-2">
|
|
|
|
|
+ <img v-if="pluginDetail.logoUrl" :src="pluginDetail.logoUrl" alt="" class="w-8 h-8 rounded-md" referrerpolicy="no-referrer">
|
|
|
|
|
+ <span class="font-600 text-[14px]">{{ pluginDetail.displayName }}</span>
|
|
|
|
|
+ <span class="hint">{{ [pluginDetail.developer, pluginDetail.category].filter(Boolean).join(' · ') }}</span>
|
|
|
|
|
+ <span class="flex-1" />
|
|
|
|
|
+ <button type="button" class="icon-btn" title="关闭" @click="pluginDetail = null"><span class="codicon codicon-close" /></button>
|
|
|
|
|
+ </div>
|
|
|
|
|
+ <div class="whitespace-pre-wrap">{{ pluginDetail.longDescription || pluginDetail.description }}</div>
|
|
|
|
|
+ <div v-if="pluginDetail.apps.length" class="flex flex-col gap-1">
|
|
|
|
|
+ <div class="font-600">应用连接器(使用 Codex 登录账号的授权)</div>
|
|
|
|
|
+ <div v-for="a in pluginDetail.apps" :key="a.id" class="flex items-center gap-2">
|
|
|
|
|
+ <span>{{ a.name }}</span>
|
|
|
|
|
+ <a v-if="a.installUrl" :href="a.installUrl" target="_blank" rel="noopener noreferrer" class="text-link">在 ChatGPT 中连接</a>
|
|
|
|
|
+ </div>
|
|
|
|
|
+ </div>
|
|
|
|
|
+ <div v-if="pluginDetail.skills.length" class="flex flex-col gap-1">
|
|
|
|
|
+ <div class="font-600">技能({{ pluginDetail.skills.length }})</div>
|
|
|
|
|
+ <div v-for="s in pluginDetail.skills" :key="s.name"><span class="font-mono text-[12px]">{{ s.name }}</span><span class="hint"> — {{ s.description }}</span></div>
|
|
|
|
|
+ </div>
|
|
|
|
|
+ <div v-if="pluginDetail.mcpServers.length"><span class="font-600">MCP 服务:</span>{{ pluginDetail.mcpServers.join('、') }}</div>
|
|
|
|
|
+ <div class="flex gap-3 hint">
|
|
|
|
|
+ <a v-if="pluginDetail.websiteUrl" :href="pluginDetail.websiteUrl" target="_blank" rel="noopener noreferrer" class="text-link">网站</a>
|
|
|
|
|
+ <a v-if="pluginDetail.privacyPolicyUrl" :href="pluginDetail.privacyPolicyUrl" target="_blank" rel="noopener noreferrer" class="text-link">隐私政策</a>
|
|
|
|
|
+ <a v-if="pluginDetail.termsOfServiceUrl" :href="pluginDetail.termsOfServiceUrl" target="_blank" rel="noopener noreferrer" class="text-link">服务条款</a>
|
|
|
|
|
+ </div>
|
|
|
|
|
+ <div class="flex justify-end gap-2 mt-1">
|
|
|
|
|
+ <button type="button" class="btn-secondary" @click="pluginDetail = null">关闭</button>
|
|
|
|
|
+ <button v-if="pluginDetail.installed" type="button" class="btn-secondary" :disabled="!!pluginBusy" @click="uninstallPlugin(pluginDetail)">卸载</button>
|
|
|
|
|
+ <button v-else type="button" class="btn-primary" :disabled="!!pluginBusy || pluginDetail.availability !== 'AVAILABLE' || pluginDetail.installPolicy === 'NOT_AVAILABLE'" @click="installPlugin(pluginDetail)">安装</button>
|
|
|
|
|
+ </div>
|
|
|
|
|
+ </div>
|
|
|
|
|
+ </div>
|
|
|
|
|
+
|
|
|
|
|
+ <!-- 安装后需要授权的应用 -->
|
|
|
|
|
+ <div v-if="pluginAuth" class="fixed inset-0 z-40 flex items-center justify-center bg-[var(--app-overlay)]" @click.self="pluginAuth = null">
|
|
|
|
|
+ <div class="w-[480px] card bg-input-alt p-5 flex flex-col gap-3 shadow-xl text-[12.5px]">
|
|
|
|
|
+ <div class="font-600 text-[14px]">{{ pluginAuth.name }} 需要连接应用</div>
|
|
|
|
|
+ <div>以下应用需要在 ChatGPT 中用 Codex 登录的账号完成连接,之后才能在 Playground 中使用:</div>
|
|
|
|
|
+ <div v-for="a in pluginAuth.apps" :key="a.id" class="flex items-center gap-2">
|
|
|
|
|
+ <span>{{ a.name }}</span>
|
|
|
|
|
+ <a v-if="a.installUrl" :href="a.installUrl" target="_blank" rel="noopener noreferrer" class="text-link">去连接</a>
|
|
|
|
|
+ </div>
|
|
|
|
|
+ <div class="flex justify-end"><button type="button" class="btn-primary" @click="pluginAuth = null">知道了</button></div>
|
|
|
|
|
+ </div>
|
|
|
|
|
+ </div>
|
|
|
|
|
+
|
|
|
<!-- 第三方技能预览:安装前必须查看 -->
|
|
<!-- 第三方技能预览:安装前必须查看 -->
|
|
|
<div v-if="preview" class="fixed inset-0 z-40 flex items-center justify-center bg-[var(--app-overlay)]" @click.self="preview = null">
|
|
<div v-if="preview" class="fixed inset-0 z-40 flex items-center justify-center bg-[var(--app-overlay)]" @click.self="preview = null">
|
|
|
<div class="w-[min(860px,92vw)] max-h-[88vh] card bg-input-alt p-5 flex flex-col gap-3 shadow-xl text-[12.5px]">
|
|
<div class="w-[min(860px,92vw)] max-h-[88vh] card bg-input-alt p-5 flex flex-col gap-3 shadow-xl text-[12.5px]">
|