|
|
@@ -1,296 +1,279 @@
|
|
|
<template>
|
|
|
<div class="ai-page ai-plugin">
|
|
|
- <header class="ai-topbar">
|
|
|
- <div class="ai-topbar__title">
|
|
|
- 插件
|
|
|
- <span class="ai-topbar__meta">{{ summary }}</span>
|
|
|
- </div>
|
|
|
- <span class="ai-topbar__spacer"></span>
|
|
|
- <div class="ai-topbar__actions">
|
|
|
- <a-button size="small" :loading="loading" :disabled="!bridgeAvailable" @click="reload">
|
|
|
- <template #icon><Icon icon="mdi:refresh" :size="14" /></template>
|
|
|
- 刷新
|
|
|
- </a-button>
|
|
|
- </div>
|
|
|
- </header>
|
|
|
-
|
|
|
- <div class="ai-scroll ai-plugin__body">
|
|
|
- <div class="ai-plugin__inner">
|
|
|
- <a-alert
|
|
|
- v-if="!bridgeAvailable"
|
|
|
- type="warning"
|
|
|
- show-icon
|
|
|
- message="本功能仅在桌面客户端可用"
|
|
|
- description="Skill 与 MCP 由本机 Codex 运行时管理,需要通过桌面客户端的主进程通道读写。当前是浏览器环境,未发起任何调用。"
|
|
|
- />
|
|
|
-
|
|
|
- <a-tabs v-else v-model:active-key="activeTab" class="ai-plugin__tabs" size="small" destroy-inactive-tab-pane>
|
|
|
- <!-- ── MCP ─────────────────────────────────────────────── -->
|
|
|
- <a-tab-pane key="mcp">
|
|
|
- <template #tab>
|
|
|
- <span>MCP 服务</span>
|
|
|
- <span class="ai-plugin__tab-count">{{ mcpServers.length }}</span>
|
|
|
- </template>
|
|
|
-
|
|
|
- <div class="ai-plugin__hint">
|
|
|
- MCP 服务写在本机 Codex 配置(<b>CODEX_HOME/config.toml</b> 的 <b>mcp_servers</b> 段),保存后
|
|
|
- <b>热生效、无需重启</b>。出于安全考虑只接受 argv 数组形式的命令,HTTP 端点的凭据只能引用
|
|
|
- <b>环境变量名</b>,不接受明文 token。
|
|
|
- </div>
|
|
|
+ <!-- 浏览器环境没有主进程通道,页签区整体不渲染,只留提示 -->
|
|
|
+ <a-alert
|
|
|
+ v-if="!bridgeAvailable"
|
|
|
+ class="ai-plugin__bridge-alert"
|
|
|
+ type="warning"
|
|
|
+ show-icon
|
|
|
+ message="本功能仅在桌面客户端可用"
|
|
|
+ description="Skill 与 MCP 由本机 Codex 运行时管理,需要通过桌面客户端的主进程通道读写。当前是浏览器环境,未发起任何调用。"
|
|
|
+ />
|
|
|
+
|
|
|
+ <!-- 页签栏即顶部条:页签居左,摘要与操作经 rightExtra 落在同一行右侧 -->
|
|
|
+ <a-tabs v-else v-model:active-key="activeTab" class="ai-plugin__tabs" size="small" destroy-inactive-tab-pane>
|
|
|
+ <template #rightExtra>
|
|
|
+ <div class="ai-topbar__actions">
|
|
|
+ <span class="ai-topbar__meta ai-plugin__summary">{{ summary }}</span>
|
|
|
+ <a-button size="small" :loading="loading" @click="reload">
|
|
|
+ <template #icon><Icon icon="mdi:refresh" :size="14" /></template>
|
|
|
+ 刷新
|
|
|
+ </a-button>
|
|
|
+ </div>
|
|
|
+ </template>
|
|
|
|
|
|
- <div class="ai-plugin__ops">
|
|
|
- <a-button size="small" type="primary" @click="openMcpForm(null)">
|
|
|
- <template #icon><Icon icon="mdi:plus" :size="14" /></template>
|
|
|
- 新增 MCP
|
|
|
- </a-button>
|
|
|
- </div>
|
|
|
+ <!-- ── MCP ─────────────────────────────────────────────── -->
|
|
|
+ <a-tab-pane key="mcp">
|
|
|
+ <template #tab>
|
|
|
+ <span>MCP 服务</span>
|
|
|
+ <span class="ai-plugin__tab-count">{{ mcpServers.length }}</span>
|
|
|
+ </template>
|
|
|
|
|
|
- <a-table
|
|
|
- class="ai-plugin__table"
|
|
|
- size="middle"
|
|
|
- :columns="mcpColumns"
|
|
|
- :data-source="mcpServers"
|
|
|
- :loading="loading"
|
|
|
- :pagination="false"
|
|
|
- row-key="id"
|
|
|
- >
|
|
|
- <template #bodyCell="{ column, record }">
|
|
|
- <template v-if="column.key === 'id'">
|
|
|
- <div class="ai-plugin__name">{{ record.id }}</div>
|
|
|
- <div class="ai-plugin__sub">{{ record.transport === 'http' ? 'HTTP / SSE' : 'STDIO' }}</div>
|
|
|
- </template>
|
|
|
- <template v-else-if="column.key === 'target'">
|
|
|
- <span class="ai-plugin__mono ai-plugin__ellipsis" :title="mcpTarget(record)">
|
|
|
- {{ mcpTarget(record) }}
|
|
|
- </span>
|
|
|
- </template>
|
|
|
- <template v-else-if="column.key === 'state'">
|
|
|
- <span class="ai-plugin__status" :class="record.connected ? 'is-on' : 'is-off'">
|
|
|
- {{ record.connected ? `已连接 · ${record.toolCount} 个工具` : '未连接' }}
|
|
|
- </span>
|
|
|
- <div v-if="record.authStatus && record.authStatus !== 'unknown'" class="ai-plugin__sub">
|
|
|
- 鉴权:{{ record.authStatus }}
|
|
|
- </div>
|
|
|
- </template>
|
|
|
- <template v-else-if="column.key === 'enabled'">
|
|
|
- <a-switch :checked="record.enabled" size="small" @change="(value: any) => toggleMcp(record, value)" />
|
|
|
- </template>
|
|
|
- <template v-else-if="column.key === 'action'">
|
|
|
- <div class="ai-plugin__cell-ops">
|
|
|
- <a-button type="link" size="small" @click="openMcpForm(record)">编辑</a-button>
|
|
|
- <a-button type="link" size="small" danger @click="removeMcp(record)">删除</a-button>
|
|
|
- </div>
|
|
|
- </template>
|
|
|
- </template>
|
|
|
- <template #emptyText>
|
|
|
- <div class="ai-empty-tip">
|
|
|
- 还没有配置 MCP 服务。点击「新增 MCP」添加一个 stdio 命令或 HTTP 端点,Codex 就能调用它的工具。
|
|
|
- </div>
|
|
|
- </template>
|
|
|
- </a-table>
|
|
|
- </a-tab-pane>
|
|
|
-
|
|
|
- <!-- ── Skill ───────────────────────────────────────────── -->
|
|
|
- <a-tab-pane key="skill">
|
|
|
- <template #tab>
|
|
|
- <span>Skill</span>
|
|
|
- <span class="ai-plugin__tab-count">{{ skills.length }}</span>
|
|
|
- </template>
|
|
|
-
|
|
|
- <div class="ai-plugin__hint">
|
|
|
- Skill 是带 <b>SKILL.md</b> 的目录,装在 <b>{{ status?.codexHome || 'CODEX_HOME' }}/skills</b> 下。 Codex
|
|
|
- 原生只提供「列出 / 启停」,<b>安装与卸载由本客户端负责</b>:目录名需为小写连字符命名, SKILL.md 的
|
|
|
- frontmatter 必须包含 <b>name</b> 与 <b>description</b>。标记为「内置」的 skill 不可删除。
|
|
|
- </div>
|
|
|
+ <div class="ai-plugin__hint">
|
|
|
+ MCP 服务写在本机 Codex 配置(<b>CODEX_HOME/config.toml</b> 的 <b>mcp_servers</b> 段),保存后
|
|
|
+ <b>热生效、无需重启</b>。出于安全考虑只接受 argv 数组形式的命令,HTTP 端点的凭据只能引用
|
|
|
+ <b>环境变量名</b>,不接受明文 token。
|
|
|
+ </div>
|
|
|
|
|
|
- <div class="ai-plugin__ops">
|
|
|
- <a-button size="small" type="primary" :loading="installing" @click="pickSkillFolder">
|
|
|
- <template #icon><Icon icon="mdi:folder-open-outline" :size="14" /></template>
|
|
|
- 从目录安装
|
|
|
- </a-button>
|
|
|
- <a-button size="small" :loading="installing" @click="triggerZipPick">
|
|
|
- <template #icon><Icon icon="mdi:upload" :size="14" /></template>
|
|
|
- 上传 zip 安装
|
|
|
- </a-button>
|
|
|
- <a-button size="small" @click="openSkillsDir">
|
|
|
- <template #icon><Icon icon="mdi:folder-open-outline" :size="14" /></template>
|
|
|
- 打开 skills 目录
|
|
|
- </a-button>
|
|
|
- <input ref="zipInputRef" type="file" accept=".zip" style="display: none" @change="onZipPicked" />
|
|
|
- </div>
|
|
|
+ <div class="ai-plugin__ops">
|
|
|
+ <a-button size="small" type="primary" @click="openMcpForm(null)">
|
|
|
+ <template #icon><Icon icon="mdi:plus" :size="14" /></template>
|
|
|
+ 新增 MCP
|
|
|
+ </a-button>
|
|
|
+ </div>
|
|
|
|
|
|
- <a-table
|
|
|
- class="ai-plugin__table"
|
|
|
- size="middle"
|
|
|
- :columns="skillColumns"
|
|
|
- :data-source="skills"
|
|
|
- :loading="loading"
|
|
|
- :pagination="false"
|
|
|
- row-key="path"
|
|
|
- >
|
|
|
- <template #bodyCell="{ column, record }">
|
|
|
- <template v-if="column.key === 'name'">
|
|
|
- <div class="ai-plugin__name">
|
|
|
- {{ record.name }}
|
|
|
- <span v-if="!record.managed" class="ai-plugin__badge">内置</span>
|
|
|
- </div>
|
|
|
- <div class="ai-plugin__sub ai-plugin__ellipsis" :title="record.path">{{ record.path }}</div>
|
|
|
- </template>
|
|
|
- <template v-else-if="column.key === 'description'">
|
|
|
- <span class="ai-plugin__desc" :title="record.description">{{ record.description || '—' }}</span>
|
|
|
- </template>
|
|
|
- <template v-else-if="column.key === 'scope'">
|
|
|
- <span class="ai-plugin__chip">{{ scopeLabel(record.scope) }}</span>
|
|
|
- </template>
|
|
|
- <template v-else-if="column.key === 'enabled'">
|
|
|
- <a-switch
|
|
|
- :checked="record.enabled"
|
|
|
- size="small"
|
|
|
- @change="(value: any) => toggleSkill(record, value)"
|
|
|
- />
|
|
|
- </template>
|
|
|
- <template v-else-if="column.key === 'action'">
|
|
|
- <div class="ai-plugin__cell-ops">
|
|
|
- <a-button type="link" size="small" @click="previewSkill(record)">查看</a-button>
|
|
|
- <a-button type="link" size="small" danger :disabled="!record.managed" @click="removeSkill(record)">
|
|
|
- 卸载
|
|
|
- </a-button>
|
|
|
- </div>
|
|
|
- </template>
|
|
|
- </template>
|
|
|
- <template #emptyText>
|
|
|
- <div class="ai-empty-tip">Codex 当前没有可见的 skill。可以从目录或 zip 安装一个。</div>
|
|
|
- </template>
|
|
|
- </a-table>
|
|
|
- </a-tab-pane>
|
|
|
-
|
|
|
- <!-- ── 运行时 ──────────────────────────────────────────── -->
|
|
|
- <a-tab-pane key="runtime" tab="Codex 运行时">
|
|
|
- <div class="ai-plugin__hint">
|
|
|
- 本客户端<b>不做任何 Codex / OpenAI 账号登录</b>:模型来自后端「模型管理」, 选中后由主进程写入本机 Codex
|
|
|
- 配置,<b>API Key 只注入子进程环境变量,不落盘</b>。 Codex 0.155 起只支持 Responses 协议,仅提供
|
|
|
- <b>/chat/completions</b> 的端点会在探测阶段被拦下。
|
|
|
- </div>
|
|
|
+ <a-spin :spinning="loading">
|
|
|
+ <div v-if="mcpServers.length" class="ai-plugin__grid">
|
|
|
+ <article v-for="record in mcpServers" :key="record.id" class="ai-plugin__card">
|
|
|
+ <div class="ai-plugin__card-head">
|
|
|
+ <div class="ai-plugin__name">
|
|
|
+ <span class="ai-plugin__ellipsis" :title="record.id">{{ record.id }}</span>
|
|
|
+ </div>
|
|
|
+ <a-switch :checked="record.enabled" size="small" @change="(value: any) => toggleMcp(record, value)" />
|
|
|
+ </div>
|
|
|
|
|
|
- <div class="ai-plugin__panel">
|
|
|
- <div class="ai-plugin__panel-title">运行状态</div>
|
|
|
- <div class="ai-plugin__kv">
|
|
|
- <span>状态</span>
|
|
|
- <b :class="stateClass">{{ stateLabel }}</b>
|
|
|
- <span>Codex 版本</span>
|
|
|
- <b class="ai-plugin__mono">{{ status?.version || '—' }}</b>
|
|
|
- <span>当前模型</span>
|
|
|
- <b class="ai-plugin__mono">{{ status?.defaultModel || '未配置' }}</b>
|
|
|
- <span>Provider</span>
|
|
|
- <b class="ai-plugin__mono">{{ status?.providerId || '—' }}</b>
|
|
|
- <span>CODEX_HOME</span>
|
|
|
- <b class="ai-plugin__mono ai-plugin__ellipsis" :title="status?.codexHome">
|
|
|
- {{ status?.codexHome || '—' }}
|
|
|
- </b>
|
|
|
+ <div class="ai-plugin__card-meta">
|
|
|
+ <span class="ai-plugin__chip">{{ record.transport === 'http' ? 'Streamable HTTP' : 'STDIO' }}</span>
|
|
|
+ <span class="ai-plugin__status" :class="record.connected ? 'is-on' : 'is-off'">
|
|
|
+ {{ record.connected ? `已连接 · ${record.toolCount} 个工具` : '未连接' }}
|
|
|
+ </span>
|
|
|
</div>
|
|
|
- <a-alert
|
|
|
- v-if="status?.degraded && status?.running"
|
|
|
- class="ai-plugin__alert"
|
|
|
- type="warning"
|
|
|
- show-icon
|
|
|
- message="模型目录不可用"
|
|
|
- description="model/list 没有返回可用模型。这不影响已配置的自定义模型,但页面上的模型列表可能为空。"
|
|
|
- />
|
|
|
- <a-alert
|
|
|
- v-if="status?.error"
|
|
|
- class="ai-plugin__alert"
|
|
|
- type="error"
|
|
|
- show-icon
|
|
|
- message="运行时错误"
|
|
|
- :description="status.error"
|
|
|
- />
|
|
|
- <div class="ai-plugin__ops">
|
|
|
- <a-button size="small" :loading="busy" :disabled="status?.running" @click="startRuntime">
|
|
|
- <template #icon><Icon icon="mdi:play" :size="14" /></template>
|
|
|
- 启动
|
|
|
- </a-button>
|
|
|
- <a-button size="small" :loading="busy" :disabled="!status?.running" @click="restartRuntime">
|
|
|
- <template #icon><Icon icon="mdi:refresh" :size="14" /></template>
|
|
|
- 重启
|
|
|
- </a-button>
|
|
|
- <a-button size="small" danger :loading="busy" :disabled="!status?.running" @click="stopRuntime">
|
|
|
- <template #icon><Icon icon="mdi:stop" :size="14" /></template>
|
|
|
- 停止
|
|
|
- </a-button>
|
|
|
+
|
|
|
+ <div class="ai-plugin__card-target ai-plugin__mono" :title="mcpTarget(record)">
|
|
|
+ {{ mcpTarget(record) }}
|
|
|
</div>
|
|
|
- </div>
|
|
|
|
|
|
- <div class="ai-plugin__panel">
|
|
|
- <div class="ai-plugin__panel-title">模型(来自后端「模型管理」)</div>
|
|
|
- <div class="ai-plugin__row">
|
|
|
- <a-select
|
|
|
- v-model:value="selectedModelId"
|
|
|
- class="ai-plugin__select"
|
|
|
- :options="modelOptions"
|
|
|
- :loading="modelsLoading"
|
|
|
- placeholder="选择一个对话模型"
|
|
|
- show-search
|
|
|
- option-filter-prop="label"
|
|
|
- />
|
|
|
- <a-button size="small" :loading="probing" :disabled="!selectedModel" @click="probeSelected">
|
|
|
- 探测端点
|
|
|
- </a-button>
|
|
|
+ <div v-if="record.tools.length" class="ai-plugin__card-tools">
|
|
|
+ <span
|
|
|
+ v-for="tool in record.tools.slice(0, TOOL_PREVIEW_COUNT)"
|
|
|
+ :key="tool.name"
|
|
|
+ class="ai-plugin__tool ai-plugin__ellipsis"
|
|
|
+ :title="tool.description || tool.name"
|
|
|
+ >
|
|
|
+ {{ tool.name }}
|
|
|
+ </span>
|
|
|
<a-button
|
|
|
+ v-if="record.tools.length > TOOL_PREVIEW_COUNT"
|
|
|
+ type="link"
|
|
|
size="small"
|
|
|
- type="primary"
|
|
|
- :loading="applying"
|
|
|
- :disabled="!selectedModel"
|
|
|
- @click="applySelected"
|
|
|
+ @click="openToolList(record)"
|
|
|
>
|
|
|
- 应用
|
|
|
+ 全部 {{ record.tools.length }} 个
|
|
|
</a-button>
|
|
|
- <a-button size="small" :disabled="!status?.applied" @click="clearApplied">清除</a-button>
|
|
|
</div>
|
|
|
|
|
|
- <a-alert
|
|
|
- v-if="probeResult"
|
|
|
- class="ai-plugin__alert"
|
|
|
- :type="probeResult.compatible ? 'success' : 'error'"
|
|
|
- show-icon
|
|
|
- :message="probeResult.compatible ? '端点兼容' : '端点不兼容'"
|
|
|
- :description="probeResult.detail"
|
|
|
- />
|
|
|
- <a-alert
|
|
|
- v-if="probeResult?.hint"
|
|
|
- class="ai-plugin__alert"
|
|
|
- type="warning"
|
|
|
- show-icon
|
|
|
- message="base_url 提醒"
|
|
|
- :description="probeResult.hint"
|
|
|
- />
|
|
|
- <div v-if="status?.applied" class="ai-plugin__applied">
|
|
|
- 已应用:<b>{{ status.applied.name || status.applied.model }}</b>
|
|
|
- <span class="ai-plugin__mono">{{ status.applied.model }}</span>
|
|
|
- <span class="ai-plugin__mono">{{ status.applied.baseUrl || status.applied.builtinProvider }}</span>
|
|
|
- <span class="ai-plugin__sub">{{ status.applied.appliedAt }}</span>
|
|
|
+ <div class="ai-plugin__card-ops">
|
|
|
+ <a-button type="link" size="small" @click="openMcpForm(record)">编辑</a-button>
|
|
|
+ <a-button type="link" size="small" danger @click="removeMcp(record)">删除</a-button>
|
|
|
</div>
|
|
|
- <div class="ai-plugin__note">
|
|
|
- 重启客户端后需要重新「应用」一次:API Key 不会落盘,只能由页面从后端取到后重新注入。
|
|
|
+ </article>
|
|
|
+ </div>
|
|
|
+ <div v-else-if="!loading" class="ai-plugin__empty">
|
|
|
+ 还没有配置 MCP 服务。点击「新增 MCP」添加一个 stdio 命令或 HTTP 端点,Codex 就能调用它的工具。
|
|
|
+ </div>
|
|
|
+ </a-spin>
|
|
|
+ </a-tab-pane>
|
|
|
+
|
|
|
+ <!-- ── Skill ───────────────────────────────────────────── -->
|
|
|
+ <a-tab-pane key="skill">
|
|
|
+ <template #tab>
|
|
|
+ <span>Skill</span>
|
|
|
+ <span class="ai-plugin__tab-count">{{ skills.length }}</span>
|
|
|
+ </template>
|
|
|
+
|
|
|
+ <div class="ai-plugin__hint">
|
|
|
+ Skill 是带 <b>SKILL.md</b> 的目录,装在 <b>{{ status?.codexHome || 'CODEX_HOME' }}/skills</b> 下。 Codex
|
|
|
+ 原生只提供「列出 / 启停」,<b>安装与卸载由本客户端负责</b>:目录名需为小写连字符命名, SKILL.md 的 frontmatter
|
|
|
+ 必须包含 <b>name</b> 与 <b>description</b>。标记为「内置」的 skill 不可删除。
|
|
|
+ </div>
|
|
|
+
|
|
|
+ <div class="ai-plugin__ops">
|
|
|
+ <a-button size="small" type="primary" :loading="installing" @click="pickSkillFolder">
|
|
|
+ <template #icon><Icon icon="mdi:folder-open-outline" :size="14" /></template>
|
|
|
+ 从目录安装
|
|
|
+ </a-button>
|
|
|
+ <a-button size="small" :loading="installing" @click="triggerZipPick">
|
|
|
+ <template #icon><Icon icon="mdi:upload" :size="14" /></template>
|
|
|
+ 上传 zip 安装
|
|
|
+ </a-button>
|
|
|
+ <a-button size="small" @click="openSkillsDir">
|
|
|
+ <template #icon><Icon icon="mdi:folder-open-outline" :size="14" /></template>
|
|
|
+ 打开 skills 目录
|
|
|
+ </a-button>
|
|
|
+ <input ref="zipInputRef" type="file" accept=".zip" style="display: none" @change="onZipPicked" />
|
|
|
+ </div>
|
|
|
+
|
|
|
+ <a-spin :spinning="loading">
|
|
|
+ <div v-if="skills.length" class="ai-plugin__grid">
|
|
|
+ <article v-for="record in skills" :key="record.path" class="ai-plugin__card">
|
|
|
+ <div class="ai-plugin__card-head">
|
|
|
+ <div class="ai-plugin__name">
|
|
|
+ <span class="ai-plugin__ellipsis" :title="record.name">{{ record.name }}</span>
|
|
|
+ </div>
|
|
|
+ <a-switch :checked="record.enabled" size="small" @change="(value: any) => toggleSkill(record, value)" />
|
|
|
</div>
|
|
|
- </div>
|
|
|
|
|
|
- <div class="ai-plugin__panel">
|
|
|
- <div class="ai-plugin__panel-title">
|
|
|
- 诊断日志
|
|
|
- <a-button type="link" size="small" @click="loadDiagnostics">刷新</a-button>
|
|
|
+ <p class="ai-plugin__card-desc" :title="record.description">{{ record.description || '—' }}</p>
|
|
|
+
|
|
|
+ <div class="ai-plugin__card-meta">
|
|
|
+ <span class="ai-plugin__chip" :class="scopeChipClass(record.scope)">{{
|
|
|
+ scopeLabel(record.scope)
|
|
|
+ }}</span>
|
|
|
</div>
|
|
|
- <div class="ai-plugin__logs">
|
|
|
- <div v-for="(item, index) in diagnostics" :key="index" class="ai-plugin__log">
|
|
|
- <span class="ai-plugin__mono">{{ item.ts.slice(11, 19) }}</span>
|
|
|
- <span>{{ item.line }}</span>
|
|
|
- </div>
|
|
|
- <div v-if="!diagnostics.length" class="ai-empty-tip">暂无诊断输出。</div>
|
|
|
+
|
|
|
+ <div class="ai-plugin__card-ops">
|
|
|
+ <a-button type="link" size="small" @click="previewSkill(record)">查看</a-button>
|
|
|
+ <a-button v-if="record.managed" type="link" size="small" danger @click="removeSkill(record)">
|
|
|
+ 卸载
|
|
|
+ </a-button>
|
|
|
</div>
|
|
|
+ </article>
|
|
|
+ </div>
|
|
|
+ <div v-else-if="!loading" class="ai-plugin__empty">
|
|
|
+ Codex 当前没有可见的 skill。可以从目录或 zip 安装一个。
|
|
|
+ </div>
|
|
|
+ </a-spin>
|
|
|
+ </a-tab-pane>
|
|
|
+
|
|
|
+ <!-- ── 运行时 ──────────────────────────────────────────── -->
|
|
|
+ <a-tab-pane key="runtime" tab="Codex 运行时">
|
|
|
+ <div class="ai-plugin__hint">
|
|
|
+ 本客户端<b>不做任何 Codex / OpenAI 账号登录</b>:模型来自后端「模型管理」, 选中后由主进程写入本机 Codex
|
|
|
+ 配置,<b>API Key 只注入子进程环境变量,不落盘</b>。 Codex 0.155 起只支持 Responses 协议,仅提供
|
|
|
+ <b>/chat/completions</b> 的端点会在探测阶段被拦下。
|
|
|
+ </div>
|
|
|
+
|
|
|
+ <div class="ai-plugin__panel">
|
|
|
+ <div class="ai-plugin__panel-title">运行状态</div>
|
|
|
+ <div class="ai-plugin__kv">
|
|
|
+ <span>状态</span>
|
|
|
+ <b :class="stateClass">{{ stateLabel }}</b>
|
|
|
+ <span>Codex 版本</span>
|
|
|
+ <b class="ai-plugin__mono">{{ status?.version || '—' }}</b>
|
|
|
+ <span>当前模型</span>
|
|
|
+ <b class="ai-plugin__mono">{{ status?.defaultModel || '未配置' }}</b>
|
|
|
+ <span>Provider</span>
|
|
|
+ <b class="ai-plugin__mono">{{ status?.providerId || '—' }}</b>
|
|
|
+ <span>CODEX_HOME</span>
|
|
|
+ <b class="ai-plugin__mono ai-plugin__ellipsis" :title="status?.codexHome">
|
|
|
+ {{ status?.codexHome || '—' }}
|
|
|
+ </b>
|
|
|
+ </div>
|
|
|
+ <a-alert
|
|
|
+ v-if="status?.degraded && status?.running"
|
|
|
+ class="ai-plugin__alert"
|
|
|
+ type="warning"
|
|
|
+ show-icon
|
|
|
+ message="模型目录不可用"
|
|
|
+ description="model/list 没有返回可用模型。这不影响已配置的自定义模型,但页面上的模型列表可能为空。"
|
|
|
+ />
|
|
|
+ <a-alert
|
|
|
+ v-if="status?.error"
|
|
|
+ class="ai-plugin__alert"
|
|
|
+ type="error"
|
|
|
+ show-icon
|
|
|
+ message="运行时错误"
|
|
|
+ :description="status.error"
|
|
|
+ />
|
|
|
+ <div class="ai-plugin__ops">
|
|
|
+ <a-button size="small" :loading="busy" :disabled="status?.running" @click="startRuntime">
|
|
|
+ <template #icon><Icon icon="mdi:play" :size="14" /></template>
|
|
|
+ 启动
|
|
|
+ </a-button>
|
|
|
+ <a-button size="small" :loading="busy" :disabled="!status?.running" @click="restartRuntime">
|
|
|
+ <template #icon><Icon icon="mdi:refresh" :size="14" /></template>
|
|
|
+ 重启
|
|
|
+ </a-button>
|
|
|
+ <a-button size="small" danger :loading="busy" :disabled="!status?.running" @click="stopRuntime">
|
|
|
+ <template #icon><Icon icon="mdi:stop" :size="14" /></template>
|
|
|
+ 停止
|
|
|
+ </a-button>
|
|
|
+ </div>
|
|
|
+ </div>
|
|
|
+
|
|
|
+ <div class="ai-plugin__panel">
|
|
|
+ <div class="ai-plugin__panel-title">模型(来自后端「模型管理」)</div>
|
|
|
+ <div class="ai-plugin__row">
|
|
|
+ <a-select
|
|
|
+ v-model:value="selectedModelId"
|
|
|
+ class="ai-plugin__select"
|
|
|
+ :options="modelOptions"
|
|
|
+ :loading="modelsLoading"
|
|
|
+ placeholder="选择一个对话模型"
|
|
|
+ show-search
|
|
|
+ option-filter-prop="label"
|
|
|
+ />
|
|
|
+ <a-button size="small" :loading="probing" :disabled="!selectedModel" @click="probeSelected">
|
|
|
+ 探测端点
|
|
|
+ </a-button>
|
|
|
+ <a-button size="small" type="primary" :loading="applying" :disabled="!selectedModel" @click="applySelected">
|
|
|
+ 应用
|
|
|
+ </a-button>
|
|
|
+ <a-button size="small" :disabled="!status?.applied" @click="clearApplied">清除</a-button>
|
|
|
+ </div>
|
|
|
+
|
|
|
+ <a-alert
|
|
|
+ v-if="probeResult"
|
|
|
+ class="ai-plugin__alert"
|
|
|
+ :type="probeResult.compatible ? 'success' : 'error'"
|
|
|
+ show-icon
|
|
|
+ :message="probeResult.compatible ? '端点兼容' : '端点不兼容'"
|
|
|
+ :description="probeResult.detail"
|
|
|
+ />
|
|
|
+ <a-alert
|
|
|
+ v-if="probeResult?.hint"
|
|
|
+ class="ai-plugin__alert"
|
|
|
+ type="warning"
|
|
|
+ show-icon
|
|
|
+ message="base_url 提醒"
|
|
|
+ :description="probeResult.hint"
|
|
|
+ />
|
|
|
+ <div v-if="status?.applied" class="ai-plugin__applied">
|
|
|
+ 已应用:<b>{{ status.applied.name || status.applied.model }}</b>
|
|
|
+ <span class="ai-plugin__mono">{{ status.applied.model }}</span>
|
|
|
+ <span class="ai-plugin__mono">{{ status.applied.baseUrl || status.applied.builtinProvider }}</span>
|
|
|
+ <span class="ai-plugin__sub">{{ status.applied.appliedAt }}</span>
|
|
|
+ </div>
|
|
|
+ <div class="ai-plugin__note">
|
|
|
+ 重启客户端后需要重新「应用」一次:API Key 不会落盘,只能由页面从后端取到后重新注入。
|
|
|
+ </div>
|
|
|
+ </div>
|
|
|
+
|
|
|
+ <div class="ai-plugin__panel">
|
|
|
+ <div class="ai-plugin__panel-title">
|
|
|
+ 诊断日志
|
|
|
+ <a-button type="link" size="small" @click="loadDiagnostics">刷新</a-button>
|
|
|
+ </div>
|
|
|
+ <div class="ai-plugin__logs">
|
|
|
+ <div v-for="(item, index) in diagnostics" :key="index" class="ai-plugin__log">
|
|
|
+ <span class="ai-plugin__mono">{{ item.ts.slice(11, 19) }}</span>
|
|
|
+ <span>{{ item.line }}</span>
|
|
|
</div>
|
|
|
- </a-tab-pane>
|
|
|
- </a-tabs>
|
|
|
- </div>
|
|
|
- </div>
|
|
|
+ <div v-if="!diagnostics.length" class="ai-empty-tip">暂无诊断输出。</div>
|
|
|
+ </div>
|
|
|
+ </div>
|
|
|
+ </a-tab-pane>
|
|
|
+ </a-tabs>
|
|
|
|
|
|
<!-- MCP 表单 -->
|
|
|
<a-modal
|
|
|
@@ -364,6 +347,24 @@
|
|
|
</div>
|
|
|
</div>
|
|
|
</a-drawer>
|
|
|
+
|
|
|
+ <!-- MCP 工具清单:Codex mcpServerStatus/list 已随状态返回,这里只做展示与过滤 -->
|
|
|
+ <a-drawer
|
|
|
+ v-model:open="mcpToolsOpen"
|
|
|
+ :title="`工具清单 · ${mcpToolsServer?.id || ''}`"
|
|
|
+ :width="720"
|
|
|
+ placement="right"
|
|
|
+ >
|
|
|
+ <div class="ai-plugin__tools">
|
|
|
+ <a-input v-model:value="toolKeyword" placeholder="按工具名或描述过滤" allow-clear />
|
|
|
+ <div class="ai-plugin__sub">{{ filteredTools.length }} / {{ mcpToolsServer?.tools.length || 0 }} 个工具</div>
|
|
|
+ <div v-for="tool in filteredTools" :key="tool.name" class="ai-plugin__tool-row">
|
|
|
+ <div class="ai-plugin__mono">{{ tool.name }}</div>
|
|
|
+ <div v-if="tool.description" class="ai-plugin__tool-desc">{{ tool.description }}</div>
|
|
|
+ </div>
|
|
|
+ <div v-if="!filteredTools.length" class="ai-empty-tip">没有匹配的工具。</div>
|
|
|
+ </div>
|
|
|
+ </a-drawer>
|
|
|
</div>
|
|
|
</template>
|
|
|
|
|
|
@@ -387,12 +388,11 @@
|
|
|
RadioButton,
|
|
|
RadioGroup,
|
|
|
Select,
|
|
|
+ Spin,
|
|
|
Switch,
|
|
|
- Table,
|
|
|
Tabs,
|
|
|
Textarea,
|
|
|
} from 'ant-design-vue';
|
|
|
- import type { TableColumnsType } from 'ant-design-vue';
|
|
|
import Icon from '@/core/components/Icon/src/Icon.vue';
|
|
|
import { useMessage } from '@/core/hooks/web/useMessage';
|
|
|
import * as codexApi from '../../api/codexApi';
|
|
|
@@ -409,8 +409,8 @@
|
|
|
const ARadioGroup = RadioGroup;
|
|
|
const ARadioButton = RadioButton;
|
|
|
const ASelect = Select;
|
|
|
+ const ASpin = Spin;
|
|
|
const ASwitch = Switch;
|
|
|
- const ATable = Table;
|
|
|
const ATabs = Tabs;
|
|
|
const ATabPane = Tabs.TabPane;
|
|
|
const ATextarea = Textarea;
|
|
|
@@ -440,6 +440,27 @@
|
|
|
const skillPreviewOpen = ref(false);
|
|
|
const skillPreview = ref<codexApi.CodexSkillReadResult | null>(null);
|
|
|
|
|
|
+ /** 卡片上平铺的工具名个数,超出部分收进抽屉,避免 77 个工具把卡片撑爆 */
|
|
|
+ const TOOL_PREVIEW_COUNT = 6;
|
|
|
+ const mcpToolsOpen = ref(false);
|
|
|
+ const mcpToolsServer = ref<codexApi.CodexMcpServer | null>(null);
|
|
|
+ const toolKeyword = ref('');
|
|
|
+
|
|
|
+ const filteredTools = computed(() => {
|
|
|
+ const keyword = toolKeyword.value.trim().toLowerCase();
|
|
|
+ const tools = mcpToolsServer.value?.tools || [];
|
|
|
+ if (!keyword) return tools;
|
|
|
+ return tools.filter(
|
|
|
+ (tool) => tool.name.toLowerCase().includes(keyword) || tool.description.toLowerCase().includes(keyword),
|
|
|
+ );
|
|
|
+ });
|
|
|
+
|
|
|
+ function openToolList(record: codexApi.CodexMcpServer) {
|
|
|
+ mcpToolsServer.value = record;
|
|
|
+ toolKeyword.value = '';
|
|
|
+ mcpToolsOpen.value = true;
|
|
|
+ }
|
|
|
+
|
|
|
const mcpFormOpen = ref(false);
|
|
|
const mcpEditing = ref<codexApi.CodexMcpServer | null>(null);
|
|
|
const mcpSubmitting = ref(false);
|
|
|
@@ -489,23 +510,7 @@
|
|
|
|
|
|
const selectedModel = computed(() => models.value.find((item) => item.id === selectedModelId.value) || null);
|
|
|
|
|
|
- const mcpColumns: TableColumnsType = [
|
|
|
- { title: 'ID', key: 'id', width: 200 },
|
|
|
- { title: '命令 / URL', key: 'target', ellipsis: true },
|
|
|
- { title: '连接状态', key: 'state', width: 170 },
|
|
|
- { title: '启用', key: 'enabled', width: 80 },
|
|
|
- { title: '操作', key: 'action', width: 140, fixed: 'right' },
|
|
|
- ];
|
|
|
-
|
|
|
- const skillColumns: TableColumnsType = [
|
|
|
- { title: 'Skill', key: 'name', width: 260 },
|
|
|
- { title: '描述', key: 'description', ellipsis: true },
|
|
|
- { title: '来源', key: 'scope', width: 90 },
|
|
|
- { title: '启用', key: 'enabled', width: 80 },
|
|
|
- { title: '操作', key: 'action', width: 140, fixed: 'right' },
|
|
|
- ];
|
|
|
-
|
|
|
- function mcpTarget(record: any) {
|
|
|
+ function mcpTarget(record: codexApi.CodexMcpServer) {
|
|
|
if (record.transport === 'http') return record.url || '—';
|
|
|
return [record.command, ...(record.args || [])].filter(Boolean).join(' ') || '—';
|
|
|
}
|
|
|
@@ -518,6 +523,13 @@
|
|
|
return scope || '—';
|
|
|
}
|
|
|
|
|
|
+ /** 内置标红、用户自装标绿,其余来源保持中性色 */
|
|
|
+ function scopeChipClass(scope: string) {
|
|
|
+ if (scope === 'system') return 'is-error';
|
|
|
+ if (scope === 'user') return 'is-success';
|
|
|
+ return '';
|
|
|
+ }
|
|
|
+
|
|
|
onMounted(async () => {
|
|
|
if (!bridgeAvailable.value) return;
|
|
|
unsubscribers.push(codexApi.onCodexStatus((payload) => (status.value = payload)));
|
|
|
@@ -847,26 +859,55 @@
|
|
|
|
|
|
<style lang="less" scoped>
|
|
|
.ai-plugin {
|
|
|
- &__body {
|
|
|
+ /* Tabs 撑起整页骨架:页签栏即顶部条,内容区自己滚动 */
|
|
|
+ &__tabs {
|
|
|
flex: 1 1 auto;
|
|
|
min-height: 0;
|
|
|
- overflow-y: auto;
|
|
|
- }
|
|
|
|
|
|
- &__inner {
|
|
|
- max-width: 1280px;
|
|
|
- margin: 0 auto;
|
|
|
- padding: var(--ai-sp-2) var(--ai-sp-5) var(--ai-sp-5);
|
|
|
- }
|
|
|
-
|
|
|
- &__tabs {
|
|
|
:deep(.ant-tabs-nav) {
|
|
|
- margin-bottom: var(--ai-sp-4);
|
|
|
+ flex: 0 0 auto;
|
|
|
+ margin: 0;
|
|
|
+ padding: 0 var(--ai-sp-4);
|
|
|
+ background: var(--ai-bg);
|
|
|
+ border-bottom: 1px solid var(--ai-border);
|
|
|
+
|
|
|
+ /* antd 自带的发丝线与顶部条边框重叠 */
|
|
|
+ &::before {
|
|
|
+ display: none;
|
|
|
+ }
|
|
|
}
|
|
|
|
|
|
+ /* 靠页签内边距撑出顶部条高度(--ai-header-h),下划线才能压在分隔线上 */
|
|
|
:deep(.ant-tabs-tab) {
|
|
|
+ padding: 15px 0;
|
|
|
font-size: 13.5px;
|
|
|
}
|
|
|
+
|
|
|
+ :deep(.ant-tabs-extra-content) {
|
|
|
+ margin-left: var(--ai-sp-4);
|
|
|
+ }
|
|
|
+
|
|
|
+ :deep(.ant-tabs-content-holder) {
|
|
|
+ flex: 1 1 auto;
|
|
|
+ min-height: 0;
|
|
|
+ padding: var(--ai-sp-4) var(--ai-sp-5) var(--ai-sp-5);
|
|
|
+ overflow-y: auto;
|
|
|
+ overscroll-behavior: contain;
|
|
|
+ }
|
|
|
+ }
|
|
|
+
|
|
|
+ /* 摘要与页签同在一行,过长时限宽省略,避免把页签挤进溢出下拉 */
|
|
|
+ &__summary {
|
|
|
+ max-width: 360px;
|
|
|
+ overflow: hidden;
|
|
|
+ white-space: nowrap;
|
|
|
+ text-overflow: ellipsis;
|
|
|
+ }
|
|
|
+
|
|
|
+ /* 浏览器环境下没有页签栏,提示条自己留出边距 */
|
|
|
+ &__bridge-alert {
|
|
|
+ flex: 0 0 auto;
|
|
|
+ margin: var(--ai-sp-5);
|
|
|
}
|
|
|
|
|
|
&__tab-count {
|
|
|
@@ -904,56 +945,169 @@
|
|
|
margin-bottom: var(--ai-sp-3);
|
|
|
}
|
|
|
|
|
|
- &__cell-ops {
|
|
|
+ /* 卡片网格:与案件管理页同一套「一行若干等宽卡片 + 悬浮抬起」的语言,
|
|
|
+ 但配色/圆角/间距全部取 ai.less 的 token,暗色随变量自动生效 */
|
|
|
+ &__grid {
|
|
|
+ display: grid;
|
|
|
+ grid-template-columns: repeat(auto-fill, minmax(320px, 1fr));
|
|
|
+ gap: var(--ai-sp-4);
|
|
|
+ }
|
|
|
+
|
|
|
+ &__card {
|
|
|
+ display: flex;
|
|
|
+ flex-direction: column;
|
|
|
+ gap: var(--ai-sp-2);
|
|
|
+ min-width: 0;
|
|
|
+ padding: var(--ai-sp-4);
|
|
|
+ background: var(--ai-bg);
|
|
|
+ border: 1px solid var(--ai-border);
|
|
|
+ border-radius: var(--ai-radius-lg);
|
|
|
+ transition:
|
|
|
+ border-color 0.2s ease,
|
|
|
+ box-shadow 0.2s ease,
|
|
|
+ transform 0.2s ease;
|
|
|
+
|
|
|
+ &:hover {
|
|
|
+ border-color: var(--ai-border-strong);
|
|
|
+ box-shadow: var(--ai-shadow);
|
|
|
+ transform: translateY(-2px);
|
|
|
+ }
|
|
|
+ }
|
|
|
+
|
|
|
+ &__card-head {
|
|
|
display: flex;
|
|
|
align-items: center;
|
|
|
- gap: var(--ai-sp-1);
|
|
|
+ justify-content: space-between;
|
|
|
+ gap: var(--ai-sp-3);
|
|
|
+ min-width: 0;
|
|
|
}
|
|
|
|
|
|
- &__table {
|
|
|
- background: var(--ai-bg);
|
|
|
+ &__card-desc {
|
|
|
+ display: -webkit-box;
|
|
|
+ -webkit-box-orient: vertical;
|
|
|
+ -webkit-line-clamp: 3;
|
|
|
+ min-height: calc(12.5px * 1.7 * 2);
|
|
|
+ margin: 0;
|
|
|
+ overflow: hidden;
|
|
|
+ font-size: 12.5px;
|
|
|
+ line-height: 1.7;
|
|
|
+ color: var(--ai-text-2);
|
|
|
}
|
|
|
|
|
|
- &__name {
|
|
|
+ &__card-target {
|
|
|
+ display: -webkit-box;
|
|
|
+ -webkit-box-orient: vertical;
|
|
|
+ -webkit-line-clamp: 2;
|
|
|
+ overflow: hidden;
|
|
|
+ word-break: break-all;
|
|
|
+ }
|
|
|
+
|
|
|
+ &__card-tools {
|
|
|
display: flex;
|
|
|
+ flex-wrap: wrap;
|
|
|
+ gap: var(--ai-sp-1);
|
|
|
align-items: center;
|
|
|
- gap: var(--ai-sp-2);
|
|
|
- font-size: 13.5px;
|
|
|
- font-weight: 600;
|
|
|
- color: var(--ai-text);
|
|
|
+ min-width: 0;
|
|
|
}
|
|
|
|
|
|
- &__badge {
|
|
|
+ /* 字号/字体沿用 __mono,这里只管盒子 */
|
|
|
+ &__tool {
|
|
|
+ max-width: 148px;
|
|
|
padding: 0 6px;
|
|
|
- font-size: 11px;
|
|
|
- line-height: 17px;
|
|
|
- color: var(--ai-text-2);
|
|
|
- background: var(--ai-bg-subtle);
|
|
|
+ line-height: 18px;
|
|
|
+ background-color: var(--ai-bg-subtle);
|
|
|
border: 1px solid var(--ai-border);
|
|
|
border-radius: var(--ai-radius-sm);
|
|
|
}
|
|
|
|
|
|
- &__sub {
|
|
|
+ &__tools {
|
|
|
+ display: flex;
|
|
|
+ flex-direction: column;
|
|
|
+ gap: var(--ai-sp-3);
|
|
|
+ }
|
|
|
+
|
|
|
+ &__tool-row {
|
|
|
+ padding: var(--ai-sp-2) 0;
|
|
|
+ border-bottom: 1px dashed var(--ai-border);
|
|
|
+ }
|
|
|
+
|
|
|
+ &__tool-desc {
|
|
|
margin-top: 2px;
|
|
|
- font-family: 'SFMono-Regular', Consolas, monospace;
|
|
|
- font-size: 11.5px;
|
|
|
+ font-size: 12px;
|
|
|
+ line-height: 1.7;
|
|
|
+ color: var(--ai-text-2);
|
|
|
+ white-space: pre-wrap;
|
|
|
+ }
|
|
|
+
|
|
|
+ &__card-meta {
|
|
|
+ display: flex;
|
|
|
+ align-items: center;
|
|
|
+ gap: var(--ai-sp-2);
|
|
|
+ min-width: 0;
|
|
|
color: var(--ai-text-2);
|
|
|
}
|
|
|
|
|
|
- &__desc {
|
|
|
+ &__card-ops {
|
|
|
+ display: flex;
|
|
|
+ align-items: center;
|
|
|
+ gap: var(--ai-sp-1);
|
|
|
+ margin-top: auto;
|
|
|
+ padding-top: var(--ai-sp-2);
|
|
|
+ border-top: 1px solid var(--ai-border);
|
|
|
+ }
|
|
|
+
|
|
|
+ &__empty {
|
|
|
+ padding: var(--ai-sp-6) var(--ai-sp-4);
|
|
|
font-size: 12.5px;
|
|
|
+ line-height: 1.7;
|
|
|
+ text-align: center;
|
|
|
+ color: var(--ai-text-2);
|
|
|
+ background: var(--ai-bg-subtle);
|
|
|
+ border: 1px dashed var(--ai-border);
|
|
|
+ border-radius: var(--ai-radius-lg);
|
|
|
+ }
|
|
|
+
|
|
|
+ /* 卡片头里它是 flex item,不给 min-width:0 就会把右侧开关挤出去 */
|
|
|
+ &__name {
|
|
|
+ display: flex;
|
|
|
+ align-items: center;
|
|
|
+ gap: var(--ai-sp-2);
|
|
|
+ min-width: 0;
|
|
|
+ font-size: 13.5px;
|
|
|
+ font-weight: 600;
|
|
|
+ color: var(--ai-text);
|
|
|
+ }
|
|
|
+
|
|
|
+ &__sub {
|
|
|
+ margin-top: 2px;
|
|
|
+ font-family: 'SFMono-Regular', Consolas, monospace;
|
|
|
+ font-size: 11.5px;
|
|
|
color: var(--ai-text-2);
|
|
|
}
|
|
|
|
|
|
&__chip {
|
|
|
display: inline-block;
|
|
|
+ flex: 0 0 auto;
|
|
|
padding: 1px 8px;
|
|
|
font-size: 12px;
|
|
|
line-height: 20px;
|
|
|
+ white-space: nowrap;
|
|
|
color: var(--ai-text);
|
|
|
background: var(--ai-bg-subtle);
|
|
|
border: 1px solid var(--ai-border);
|
|
|
border-radius: 999px;
|
|
|
+
|
|
|
+ &.is-error {
|
|
|
+ color: @error-color;
|
|
|
+ background-color: fade(@error-color, 10%);
|
|
|
+ border-color: fade(@error-color, 26%);
|
|
|
+ }
|
|
|
+
|
|
|
+ &.is-success {
|
|
|
+ color: @success-color;
|
|
|
+ background-color: fade(@success-color, 10%);
|
|
|
+ border-color: fade(@success-color, 26%);
|
|
|
+ }
|
|
|
}
|
|
|
|
|
|
&__mono {
|