| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715716717718719720721722723724725726727728729730731732733734735736737738739740741742743744745746747748749750751752753754755756757758759760761762763764765766767768769770771772773774775776777778779780781782783784785786787788789790791792793794795796797798799800801802803804805806807808809810811812813814815816817818819820821822823824825826827828829830831832833834835836837838839840841842843844845846847848849850851852853854855856857858859860861862863864865866867868869870871872873874875876877878879880881882883884885886887888889890891892893894895896897898899900901902903904905906907908909910911912913914915916917918919920921922923924925926927928929930931932933934935936937938939940941942943944945946947948949950951952953954955956957958959960961962963964965966967968969970971972973974975976977978979980981982983984985986987988989990991992993994995996997998999100010011002100310041005100610071008100910101011101210131014101510161017101810191020102110221023102410251026102710281029103010311032103310341035103610371038103910401041104210431044104510461047104810491050105110521053105410551056105710581059106010611062106310641065106610671068106910701071107210731074107510761077107810791080108110821083108410851086108710881089109010911092109310941095109610971098109911001101110211031104110511061107110811091110111111121113111411151116111711181119112011211122112311241125112611271128112911301131113211331134113511361137113811391140114111421143114411451146114711481149115011511152115311541155115611571158115911601161116211631164116511661167116811691170117111721173117411751176117711781179118011811182118311841185118611871188118911901191119211931194119511961197119811991200120112021203120412051206120712081209121012111212121312141215121612171218121912201221122212231224122512261227122812291230123112321233123412351236123712381239124012411242124312441245124612471248124912501251125212531254125512561257 |
- <template>
- <div class="ai-page ai-plugin">
- <!-- 浏览器环境没有主进程通道,页签区整体不渲染,只留提示 -->
- <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>
- <!-- ── 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>
- <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-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__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>
- <div class="ai-plugin__card-target ai-plugin__mono" :title="mcpTarget(record)">
- {{ mcpTarget(record) }}
- </div>
- <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"
- @click="openToolList(record)"
- >
- 全部 {{ record.tools.length }} 个
- </a-button>
- </div>
- <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>
- </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>
- <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__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>
- <div v-if="!diagnostics.length" class="ai-empty-tip">暂无诊断输出。</div>
- </div>
- </div>
- </a-tab-pane>
- </a-tabs>
- <!-- MCP 表单 -->
- <a-modal
- v-model:open="mcpFormOpen"
- :title="mcpEditing ? '编辑 MCP 服务' : '新增 MCP 服务'"
- :width="620"
- :confirm-loading="mcpSubmitting"
- :mask-closable="false"
- ok-text="保存"
- cancel-text="取消"
- @ok="submitMcp"
- >
- <a-form class="ai-plugin__form" :model="mcpForm" layout="vertical">
- <a-form-item label="ID" required>
- <a-input
- v-model:value="mcpForm.id"
- :disabled="Boolean(mcpEditing)"
- placeholder="字母、数字、连字符或下划线,最长 64"
- :maxlength="64"
- />
- </a-form-item>
- <a-form-item label="传输方式" required>
- <a-radio-group v-model:value="mcpForm.transport" size="small">
- <a-radio-button value="stdio">STDIO(本机命令)</a-radio-button>
- <a-radio-button value="http">HTTP / SSE(远程端点)</a-radio-button>
- </a-radio-group>
- </a-form-item>
- <template v-if="mcpForm.transport === 'stdio'">
- <a-form-item label="命令" required>
- <a-input v-model:value="mcpForm.command" placeholder="如:npx" :maxlength="500" />
- </a-form-item>
- <a-form-item label="参数(每行一个)">
- <a-textarea
- v-model:value="mcpForm.argsText"
- :rows="4"
- placeholder="-y @modelcontextprotocol/server-everything"
- />
- </a-form-item>
- </template>
- <template v-else>
- <a-form-item label="URL" required>
- <a-input
- v-model:value="mcpForm.url"
- placeholder="https://mcp.example.com/v1(远程必须 HTTPS,HTTP 仅允许本机)"
- :maxlength="2048"
- />
- </a-form-item>
- <a-form-item label="Bearer Token 环境变量名">
- <a-input v-model:value="mcpForm.bearerTokenEnvVar" placeholder="如:ZSJZ_MCP_TOKEN" :maxlength="128" />
- <div class="ai-plugin__note">只填环境变量名,不要填 token 本身。</div>
- </a-form-item>
- </template>
- <a-form-item label="启用">
- <a-switch v-model:checked="mcpForm.enabled" size="small" />
- </a-form-item>
- </a-form>
- </a-modal>
- <!-- Skill 预览 -->
- <a-drawer v-model:open="skillPreviewOpen" title="SKILL.md" :width="720" placement="right">
- <div v-if="skillPreview" class="ai-plugin__preview">
- <div class="ai-plugin__mono">{{ skillPreview.dir }}</div>
- <pre class="ai-plugin__pre">{{ skillPreview.content }}</pre>
- <div class="ai-plugin__panel-title">附带文件</div>
- <div v-for="file in skillPreview.files" :key="file.path" class="ai-plugin__log">
- <span class="ai-plugin__mono">{{ file.path }}</span>
- <span class="ai-plugin__sub">{{ file.size }} B</span>
- </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>
- <script lang="ts" setup name="AiPluginIndex">
- /**
- * AI 插件页:管理本机 Codex 运行时的 MCP 服务、Skill 与模型 provider。
- *
- * 全部能力走桌面端 IPC(src/ai/api/codexApi.ts → electron/controller/codexCtl.ts),
- * 不走 /js/a HTTP;只有「模型」下拉复用后端模型管理接口。
- * 浏览器环境下不发任何调用,整页降级为提示。
- */
- import { computed, onBeforeUnmount, onMounted, reactive, ref } from 'vue';
- import {
- Alert,
- Button,
- Drawer,
- Form,
- FormItem,
- Input,
- Modal,
- RadioButton,
- RadioGroup,
- Select,
- Spin,
- Switch,
- Tabs,
- Textarea,
- } 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';
- import * as modelApi from '../../api/modelApi';
- import type { AiModel, ModelProvider } from '../../api/types';
- const AAlert = Alert;
- const AButton = Button;
- const ADrawer = Drawer;
- const AForm = Form;
- const AFormItem = FormItem;
- const AInput = Input;
- const AModal = Modal;
- const ARadioGroup = RadioGroup;
- const ARadioButton = RadioButton;
- const ASelect = Select;
- const ASpin = Spin;
- const ASwitch = Switch;
- const ATabs = Tabs;
- const ATabPane = Tabs.TabPane;
- const ATextarea = Textarea;
- const { showMessage } = useMessage();
- const bridgeAvailable = ref(codexApi.isCodexBridgeAvailable());
- const activeTab = ref<'mcp' | 'skill' | 'runtime'>('mcp');
- const loading = ref(false);
- const busy = ref(false);
- const installing = ref(false);
- const status = ref<codexApi.CodexStatus | null>(null);
- const mcpServers = ref<codexApi.CodexMcpServer[]>([]);
- const skills = ref<codexApi.CodexSkill[]>([]);
- const diagnostics = ref<codexApi.CodexDiagnostic[]>([]);
- const models = ref<AiModel[]>([]);
- const providers = ref<ModelProvider[]>([]);
- const modelsLoading = ref(false);
- const selectedModelId = ref<string | undefined>(undefined);
- const probing = ref(false);
- const applying = ref(false);
- const probeResult = ref<codexApi.ProviderProbeResult | null>(null);
- const zipInputRef = ref<HTMLInputElement | null>(null);
- 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);
- const mcpForm = reactive({
- id: '',
- transport: 'stdio' as 'stdio' | 'http',
- command: '',
- argsText: '',
- url: '',
- bearerTokenEnvVar: '',
- enabled: true,
- });
- const unsubscribers: Array<() => void> = [];
- const summary = computed(() => {
- if (!bridgeAvailable.value) return '仅桌面客户端可用';
- const parts = [`${mcpServers.value.length} 个 MCP`, `${skills.value.length} 个 Skill`];
- parts.push(status.value?.running ? `运行中 · ${status.value.defaultModel || '未配置模型'}` : '未启动');
- return parts.join(' · ');
- });
- const stateLabel = computed(() => {
- const state = status.value?.state;
- if (state === 'ready') return '运行中';
- if (state === 'starting') return '启动中';
- if (state === 'error') return '异常';
- return '已停止';
- });
- const stateClass = computed(() => {
- const state = status.value?.state;
- if (state === 'ready') return 'is-on';
- if (state === 'error') return 'is-err';
- return 'is-off';
- });
- /** 只列对话模型:Codex 是 agent,向量模型用不上 */
- const modelOptions = computed(() =>
- models.value
- .filter((item) => !item.type || item.type === 'llm')
- .map((item) => ({
- label: `${item.name || item.modelId || item.id}(${item.modelId || '—'})`,
- value: item.id,
- })),
- );
- const selectedModel = computed(() => models.value.find((item) => item.id === selectedModelId.value) || null);
- function mcpTarget(record: codexApi.CodexMcpServer) {
- if (record.transport === 'http') return record.url || '—';
- return [record.command, ...(record.args || [])].filter(Boolean).join(' ') || '—';
- }
- function scopeLabel(scope: string) {
- if (scope === 'system') return '内置';
- if (scope === 'user') return '用户';
- if (scope === 'repo') return '仓库';
- if (scope === 'admin') return '管理员';
- 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)));
- unsubscribers.push(
- codexApi.onCodexDiagnostic((item) => {
- diagnostics.value = [...diagnostics.value, item].slice(-200);
- }),
- );
- await reload();
- await loadModels();
- });
- onBeforeUnmount(() => {
- for (const unsubscribe of unsubscribers) unsubscribe();
- unsubscribers.length = 0;
- });
- async function reload() {
- if (!bridgeAvailable.value) return;
- loading.value = true;
- try {
- const [nextStatus, nextMcp, nextSkills, logs] = await Promise.all([
- codexApi.codexStatus(),
- codexApi.codexMcpList().catch(() => [] as codexApi.CodexMcpServer[]),
- codexApi.codexSkillList().catch(() => [] as codexApi.CodexSkill[]),
- codexApi.codexLogsTail(200).catch(() => [] as codexApi.CodexDiagnostic[]),
- ]);
- status.value = nextStatus;
- mcpServers.value = nextMcp;
- skills.value = nextSkills;
- diagnostics.value = logs;
- } catch (e: any) {
- showMessage(e?.message || '加载插件信息失败', 'error');
- } finally {
- loading.value = false;
- }
- }
- async function loadModels() {
- modelsLoading.value = true;
- try {
- const [list, providerList] = await Promise.all([
- modelApi.listModels(),
- modelApi.listProviders().catch(() => [] as ModelProvider[]),
- ]);
- models.value = list || [];
- providers.value = providerList || [];
- const applied = status.value?.applied;
- selectedModelId.value = applied?.modelRecordId || models.value.find((m) => m.defaultModel)?.id || undefined;
- } catch (e: any) {
- showMessage(e?.message || '加载模型列表失败(需要登录后端)', 'error');
- } finally {
- modelsLoading.value = false;
- }
- }
- /** 把后端模型记录翻译成主进程要的入参;厂商协议类型来自 agent_model_provider.type */
- function buildApplyInput(model: AiModel): codexApi.ApplyProviderInput {
- const providerType = providers.value.find((item) => item.name === model.provider)?.type ?? null;
- return {
- modelId: model.modelId || '',
- name: model.name || null,
- baseUrl: model.baseUrl || null,
- apiKey: model.apiKey || null,
- headersJson: model.headersJson || null,
- config: model.config || null,
- providerType,
- modelRecordId: model.id,
- };
- }
- async function probeSelected() {
- const model = selectedModel.value;
- if (!model) return;
- probing.value = true;
- probeResult.value = null;
- try {
- const input = buildApplyInput(model);
- probeResult.value = await codexApi.codexProbeProvider({
- baseUrl: input.baseUrl || '',
- apiKey: input.apiKey,
- });
- } catch (e: any) {
- showMessage(e?.message || '探测失败', 'error');
- } finally {
- probing.value = false;
- }
- }
- async function applySelected() {
- const model = selectedModel.value;
- if (!model) return;
- applying.value = true;
- try {
- const result = await codexApi.codexApplyProvider(buildApplyInput(model));
- status.value = result.status;
- probeResult.value = null;
- showMessage(`已应用模型「${result.applied.model}」`, 'success');
- if (result.droppedConfigKeys.length) {
- showMessage(`config 中不支持的键已忽略:${result.droppedConfigKeys.join('、')}`, 'warning');
- }
- if (result.hint) showMessage(result.hint, 'warning');
- } catch (e: any) {
- showMessage(e?.message || '应用模型失败', 'error');
- } finally {
- applying.value = false;
- }
- }
- async function clearApplied() {
- try {
- status.value = await codexApi.codexClearProvider();
- showMessage('已清除模型配置', 'success');
- } catch (e: any) {
- showMessage(e?.message || '清除失败', 'error');
- }
- }
- async function startRuntime() {
- busy.value = true;
- try {
- status.value = await codexApi.codexStart();
- showMessage('Codex 运行时已启动', 'success');
- } catch (e: any) {
- showMessage(e?.message || '启动失败', 'error');
- } finally {
- busy.value = false;
- }
- }
- async function restartRuntime() {
- busy.value = true;
- try {
- status.value = await codexApi.codexRestart();
- showMessage('Codex 运行时已重启', 'success');
- } catch (e: any) {
- showMessage(e?.message || '重启失败', 'error');
- } finally {
- busy.value = false;
- }
- }
- async function stopRuntime() {
- busy.value = true;
- try {
- status.value = await codexApi.codexStop();
- showMessage('Codex 运行时已停止', 'success');
- } catch (e: any) {
- showMessage(e?.message || '停止失败', 'error');
- } finally {
- busy.value = false;
- }
- }
- async function loadDiagnostics() {
- try {
- diagnostics.value = await codexApi.codexLogsTail(200);
- } catch (e: any) {
- showMessage(e?.message || '读取诊断日志失败', 'error');
- }
- }
- // ── MCP ──────────────────────────────────────────────────────────────
- function openMcpForm(record: any) {
- mcpEditing.value = record ? (record as codexApi.CodexMcpServer) : null;
- mcpForm.id = record?.id ?? '';
- mcpForm.transport = record?.transport ?? 'stdio';
- mcpForm.command = record?.command ?? '';
- mcpForm.argsText = (record?.args ?? []).join('\n');
- mcpForm.url = record?.url ?? '';
- mcpForm.bearerTokenEnvVar = record?.bearerTokenEnvVar ?? '';
- mcpForm.enabled = record ? record.enabled : true;
- mcpFormOpen.value = true;
- }
- async function submitMcp() {
- if (!mcpForm.id.trim()) {
- showMessage('请填写 ID', 'error');
- return;
- }
- mcpSubmitting.value = true;
- try {
- const args = mcpForm.argsText
- .split(/\r?\n/)
- .map((line) => line.trim())
- .filter(Boolean);
- mcpServers.value = await codexApi.codexMcpSave({
- id: mcpForm.id.trim(),
- transport: mcpForm.transport,
- command: mcpForm.transport === 'stdio' ? mcpForm.command.trim() : null,
- args: mcpForm.transport === 'stdio' ? args : [],
- url: mcpForm.transport === 'http' ? mcpForm.url.trim() : null,
- bearerTokenEnvVar: mcpForm.transport === 'http' ? mcpForm.bearerTokenEnvVar.trim() || null : null,
- enabled: mcpForm.enabled,
- });
- mcpFormOpen.value = false;
- showMessage('已保存并热生效', 'success');
- } catch (e: any) {
- showMessage(e?.message || '保存失败', 'error');
- } finally {
- mcpSubmitting.value = false;
- }
- }
- async function toggleMcp(record: any, enabled: boolean) {
- try {
- mcpServers.value = await codexApi.codexMcpSave({
- id: record.id,
- transport: record.transport,
- command: record.command,
- args: record.args,
- url: record.url,
- bearerTokenEnvVar: record.bearerTokenEnvVar,
- enabled,
- });
- } catch (e: any) {
- showMessage(e?.message || '切换失败', 'error');
- }
- }
- function removeMcp(record: any) {
- Modal.confirm({
- title: '删除 MCP 服务',
- content: `确认删除「${record.id}」?该配置只影响本机 Codex。`,
- okText: '删除',
- okType: 'danger',
- cancelText: '取消',
- onOk: async () => {
- try {
- mcpServers.value = await codexApi.codexMcpRemove(record.id);
- showMessage('已删除', 'success');
- } catch (e: any) {
- showMessage(e?.message || '删除失败', 'error');
- }
- },
- });
- }
- // ── Skill ────────────────────────────────────────────────────────────
- async function pickSkillFolder() {
- try {
- const srcPath = await codexApi.selectDirectory();
- if (!srcPath) return;
- await installSkill(() => codexApi.codexSkillInstallFolder({ srcPath }));
- } catch (e: any) {
- showMessage(e?.message || '选择目录失败', 'error');
- }
- }
- function triggerZipPick() {
- zipInputRef.value?.click();
- }
- async function onZipPicked(event: Event) {
- const input = event.target as HTMLInputElement;
- const file = input.files?.[0];
- input.value = '';
- if (!file) return;
- try {
- const data = await codexApi.readFileBytes(file);
- await installSkill(() => codexApi.codexSkillInstallZip({ data }));
- } catch (e: any) {
- showMessage(e?.message || '读取 zip 失败', 'error');
- }
- }
- async function installSkill(run: () => Promise<codexApi.CodexSkill[]>) {
- installing.value = true;
- try {
- skills.value = await run();
- showMessage('安装成功', 'success');
- } catch (e: any) {
- showMessage(e?.message || '安装失败', 'error');
- } finally {
- installing.value = false;
- }
- }
- async function toggleSkill(record: any, enabled: boolean) {
- try {
- const result = await codexApi.codexSkillSetEnabled({ path: record.path }, enabled);
- skills.value = skills.value.map((item) =>
- item.path === record.path ? { ...item, enabled: result.effectiveEnabled } : item,
- );
- } catch (e: any) {
- showMessage(e?.message || '切换失败', 'error');
- }
- }
- async function previewSkill(record: any) {
- try {
- skillPreview.value = await codexApi.codexSkillRead({ path: record.path });
- skillPreviewOpen.value = true;
- } catch (e: any) {
- showMessage(e?.message || '读取失败', 'error');
- }
- }
- function removeSkill(record: any) {
- Modal.confirm({
- title: '卸载 Skill',
- content: `确认删除「${record.name}」?将移除 ${record.path} 整个目录。`,
- okText: '卸载',
- okType: 'danger',
- cancelText: '取消',
- onOk: async () => {
- try {
- skills.value = await codexApi.codexSkillRemove({ path: record.path });
- showMessage('已卸载', 'success');
- } catch (e: any) {
- showMessage(e?.message || '卸载失败', 'error');
- }
- },
- });
- }
- async function openSkillsDir() {
- try {
- await codexApi.codexSkillOpenFolder();
- } catch (e: any) {
- showMessage(e?.message || '打开目录失败', 'error');
- }
- }
- </script>
- <style lang="less" scoped>
- .ai-plugin {
- /* Tabs 撑起整页骨架:页签栏即顶部条,内容区自己滚动 */
- &__tabs {
- flex: 1 1 auto;
- min-height: 0;
- :deep(.ant-tabs-nav) {
- flex: 0 0 auto;
- margin: 0;
- padding: 0 var(--ai-sp-2);
- 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-3);
- }
- /* 内容区内边距取分析页那一档(8px),左右与页签栏文字对齐 */
- :deep(.ant-tabs-content-holder) {
- flex: 1 1 auto;
- min-height: 0;
- padding: var(--ai-sp-2);
- 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-2);
- }
- &__tab-count {
- display: inline-block;
- min-width: 18px;
- margin-left: var(--ai-sp-2);
- padding: 0 5px;
- font-size: 11px;
- line-height: 17px;
- text-align: center;
- color: var(--ai-text-2);
- background: var(--ai-bg-subtle);
- border: 1px solid var(--ai-border);
- border-radius: 999px;
- }
- &__hint {
- margin-bottom: var(--ai-sp-2);
- padding: var(--ai-sp-3);
- font-size: 12.5px;
- line-height: 1.75;
- color: var(--ai-text-2);
- background: var(--ai-primary-softer);
- border-left: 3px solid fade(@primary-color, 45%);
- border-radius: 0 var(--ai-radius-sm) var(--ai-radius-sm) 0;
- b {
- color: var(--ai-text);
- }
- }
- &__ops {
- display: flex;
- gap: var(--ai-sp-2);
- margin-bottom: var(--ai-sp-2);
- }
- /* 卡片网格:间距取分析页那一档(8px);圆角比页面外壳(12px)低一档,用 --ai-radius(8px) */
- &__grid {
- display: grid;
- grid-template-columns: repeat(auto-fill, minmax(320px, 1fr));
- gap: var(--ai-sp-2);
- }
- &__card {
- display: flex;
- flex-direction: column;
- gap: var(--ai-sp-2);
- min-width: 0;
- padding: var(--ai-sp-3);
- background: var(--ai-bg);
- border: 1px solid var(--ai-border);
- border-radius: var(--ai-radius);
- 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;
- justify-content: space-between;
- gap: var(--ai-sp-3);
- min-width: 0;
- }
- &__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);
- }
- &__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;
- min-width: 0;
- }
- /* 字号/字体沿用 __mono,这里只管盒子 */
- &__tool {
- max-width: 148px;
- padding: 0 6px;
- line-height: 18px;
- background-color: var(--ai-bg-subtle);
- border: 1px solid var(--ai-border);
- border-radius: var(--ai-radius-sm);
- }
- &__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-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);
- }
- &__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-4) var(--ai-sp-3);
- 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);
- }
- /* 卡片头里它是 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 {
- font-family: 'SFMono-Regular', Consolas, monospace;
- font-size: 12px;
- color: var(--ai-text-2);
- }
- &__ellipsis {
- display: inline-block;
- max-width: 100%;
- overflow: hidden;
- white-space: nowrap;
- text-overflow: ellipsis;
- }
- &__status {
- font-size: 12px;
- &.is-on {
- color: @success-color;
- }
- &.is-off {
- color: var(--ai-text-2);
- }
- &.is-err {
- color: @error-color;
- }
- }
- /* 运行时页签里的分块面板:与卡片同档(8px 圆角 / 12px 内边距 / 8px 块间距) */
- &__panel {
- margin-bottom: var(--ai-sp-2);
- padding: var(--ai-sp-3);
- background: var(--ai-bg);
- border: 1px solid var(--ai-border);
- border-radius: var(--ai-radius);
- }
- &__panel-title {
- display: flex;
- align-items: center;
- gap: var(--ai-sp-2);
- margin-bottom: var(--ai-sp-3);
- font-size: 13px;
- font-weight: 600;
- color: var(--ai-text);
- }
- &__kv {
- display: grid;
- grid-template-columns: 120px 1fr;
- gap: var(--ai-sp-2) var(--ai-sp-3);
- font-size: 12.5px;
- color: var(--ai-text-2);
- b {
- font-weight: 500;
- color: var(--ai-text);
- &.is-on {
- color: @success-color;
- }
- &.is-err {
- color: @error-color;
- }
- &.is-off {
- color: var(--ai-text-2);
- }
- }
- }
- &__row {
- display: flex;
- flex-wrap: wrap;
- gap: var(--ai-sp-2);
- align-items: center;
- }
- &__select {
- min-width: 320px;
- }
- &__alert {
- margin-top: var(--ai-sp-3);
- }
- &__applied {
- display: flex;
- flex-wrap: wrap;
- gap: var(--ai-sp-2);
- align-items: center;
- margin-top: var(--ai-sp-3);
- font-size: 12.5px;
- color: var(--ai-text-2);
- }
- &__note {
- margin-top: var(--ai-sp-2);
- font-size: 12px;
- line-height: 1.7;
- color: var(--ai-text-2);
- }
- &__logs {
- max-height: 260px;
- overflow-y: auto;
- font-size: 12px;
- }
- &__log {
- display: flex;
- gap: var(--ai-sp-2);
- padding: 2px 0;
- color: var(--ai-text-2);
- border-bottom: 1px dashed var(--ai-border);
- }
- &__form {
- :deep(.ant-form-item) {
- margin-bottom: var(--ai-sp-3);
- }
- }
- &__preview {
- font-size: 12.5px;
- }
- &__pre {
- max-height: 50vh;
- padding: var(--ai-sp-3);
- margin: var(--ai-sp-2) 0 var(--ai-sp-4);
- overflow: auto;
- font-family: 'SFMono-Regular', Consolas, monospace;
- font-size: 12px;
- line-height: 1.7;
- white-space: pre-wrap;
- background: var(--ai-bg-subtle);
- border: 1px solid var(--ai-border);
- border-radius: var(--ai-radius-sm);
- }
- }
- </style>
|