|
|
@@ -0,0 +1,1102 @@
|
|
|
+<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>
|
|
|
+
|
|
|
+ <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="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__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-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>
|
|
|
+
|
|
|
+ <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>
|
|
|
+ </div>
|
|
|
+ </div>
|
|
|
+
|
|
|
+ <!-- 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>
|
|
|
+ </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,
|
|
|
+ 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';
|
|
|
+ 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 ASwitch = Switch;
|
|
|
+ const ATable = Table;
|
|
|
+ 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);
|
|
|
+
|
|
|
+ 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);
|
|
|
+
|
|
|
+ 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) {
|
|
|
+ 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 || '—';
|
|
|
+ }
|
|
|
+
|
|
|
+ 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 {
|
|
|
+ &__body {
|
|
|
+ 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);
|
|
|
+ }
|
|
|
+
|
|
|
+ :deep(.ant-tabs-tab) {
|
|
|
+ font-size: 13.5px;
|
|
|
+ }
|
|
|
+ }
|
|
|
+
|
|
|
+ &__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-4);
|
|
|
+ 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-3);
|
|
|
+ }
|
|
|
+
|
|
|
+ &__cell-ops {
|
|
|
+ display: flex;
|
|
|
+ align-items: center;
|
|
|
+ gap: var(--ai-sp-1);
|
|
|
+ }
|
|
|
+
|
|
|
+ &__table {
|
|
|
+ background: var(--ai-bg);
|
|
|
+ }
|
|
|
+
|
|
|
+ &__name {
|
|
|
+ display: flex;
|
|
|
+ align-items: center;
|
|
|
+ gap: var(--ai-sp-2);
|
|
|
+ font-size: 13.5px;
|
|
|
+ font-weight: 600;
|
|
|
+ color: var(--ai-text);
|
|
|
+ }
|
|
|
+
|
|
|
+ &__badge {
|
|
|
+ padding: 0 6px;
|
|
|
+ font-size: 11px;
|
|
|
+ line-height: 17px;
|
|
|
+ color: var(--ai-text-2);
|
|
|
+ background: var(--ai-bg-subtle);
|
|
|
+ border: 1px solid var(--ai-border);
|
|
|
+ border-radius: var(--ai-radius-sm);
|
|
|
+ }
|
|
|
+
|
|
|
+ &__sub {
|
|
|
+ margin-top: 2px;
|
|
|
+ font-family: 'SFMono-Regular', Consolas, monospace;
|
|
|
+ font-size: 11.5px;
|
|
|
+ color: var(--ai-text-2);
|
|
|
+ }
|
|
|
+
|
|
|
+ &__desc {
|
|
|
+ font-size: 12.5px;
|
|
|
+ color: var(--ai-text-2);
|
|
|
+ }
|
|
|
+
|
|
|
+ &__chip {
|
|
|
+ display: inline-block;
|
|
|
+ padding: 1px 8px;
|
|
|
+ font-size: 12px;
|
|
|
+ line-height: 20px;
|
|
|
+ color: var(--ai-text);
|
|
|
+ background: var(--ai-bg-subtle);
|
|
|
+ border: 1px solid var(--ai-border);
|
|
|
+ border-radius: 999px;
|
|
|
+ }
|
|
|
+
|
|
|
+ &__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;
|
|
|
+ }
|
|
|
+ }
|
|
|
+
|
|
|
+ &__panel {
|
|
|
+ margin-bottom: var(--ai-sp-4);
|
|
|
+ padding: var(--ai-sp-4);
|
|
|
+ background: var(--ai-bg);
|
|
|
+ border: 1px solid var(--ai-border);
|
|
|
+ border-radius: var(--ai-radius-sm);
|
|
|
+ }
|
|
|
+
|
|
|
+ &__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>
|