index.vue 41 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715716717718719720721722723724725726727728729730731732733734735736737738739740741742743744745746747748749750751752753754755756757758759760761762763764765766767768769770771772773774775776777778779780781782783784785786787788789790791792793794795796797798799800801802803804805806807808809810811812813814815816817818819820821822823824825826827828829830831832833834835836837838839840841842843844845846847848849850851852853854855856857858859860861862863864865866867868869870871872873874875876877878879880881882883884885886887888889890891892893894895896897898899900901902903904905906907908909910911912913914915916917918919920921922923924925926927928929930931932933934935936937938939940941942943944945946947948949950951952953954955956957958959960961962963964965966967968969970971972973974975976977978979980981982983984985986987988989990991992993994995996997998999100010011002100310041005100610071008100910101011101210131014101510161017101810191020102110221023102410251026102710281029103010311032103310341035103610371038103910401041104210431044104510461047104810491050105110521053105410551056105710581059106010611062106310641065106610671068106910701071107210731074107510761077107810791080108110821083108410851086108710881089109010911092109310941095109610971098109911001101110211031104110511061107110811091110111111121113111411151116111711181119112011211122112311241125112611271128112911301131113211331134113511361137113811391140114111421143114411451146114711481149115011511152115311541155115611571158115911601161116211631164116511661167116811691170117111721173117411751176117711781179118011811182118311841185118611871188118911901191119211931194119511961197119811991200120112021203120412051206120712081209121012111212121312141215121612171218121912201221122212231224122512261227122812291230123112321233123412351236123712381239124012411242124312441245124612471248124912501251125212531254125512561257
  1. <template>
  2. <div class="ai-page ai-plugin">
  3. <!-- 浏览器环境没有主进程通道,页签区整体不渲染,只留提示 -->
  4. <a-alert
  5. v-if="!bridgeAvailable"
  6. class="ai-plugin__bridge-alert"
  7. type="warning"
  8. show-icon
  9. message="本功能仅在桌面客户端可用"
  10. description="Skill 与 MCP 由本机 Codex 运行时管理,需要通过桌面客户端的主进程通道读写。当前是浏览器环境,未发起任何调用。"
  11. />
  12. <!-- 页签栏即顶部条:页签居左,摘要与操作经 rightExtra 落在同一行右侧 -->
  13. <a-tabs v-else v-model:active-key="activeTab" class="ai-plugin__tabs" size="small" destroy-inactive-tab-pane>
  14. <template #rightExtra>
  15. <div class="ai-topbar__actions">
  16. <span class="ai-topbar__meta ai-plugin__summary">{{ summary }}</span>
  17. <a-button size="small" :loading="loading" @click="reload">
  18. <template #icon><Icon icon="mdi:refresh" :size="14" /></template>
  19. 刷新
  20. </a-button>
  21. </div>
  22. </template>
  23. <!-- ── MCP ─────────────────────────────────────────────── -->
  24. <a-tab-pane key="mcp">
  25. <template #tab>
  26. <span>MCP 服务</span>
  27. <span class="ai-plugin__tab-count">{{ mcpServers.length }}</span>
  28. </template>
  29. <div class="ai-plugin__hint">
  30. MCP 服务写在本机 Codex 配置(<b>CODEX_HOME/config.toml</b> 的 <b>mcp_servers</b> 段),保存后
  31. <b>热生效、无需重启</b>。出于安全考虑只接受 argv 数组形式的命令,HTTP 端点的凭据只能引用
  32. <b>环境变量名</b>,不接受明文 token。
  33. </div>
  34. <div class="ai-plugin__ops">
  35. <a-button size="small" type="primary" @click="openMcpForm(null)">
  36. <template #icon><Icon icon="mdi:plus" :size="14" /></template>
  37. 新增 MCP
  38. </a-button>
  39. </div>
  40. <a-spin :spinning="loading">
  41. <div v-if="mcpServers.length" class="ai-plugin__grid">
  42. <article v-for="record in mcpServers" :key="record.id" class="ai-plugin__card">
  43. <div class="ai-plugin__card-head">
  44. <div class="ai-plugin__name">
  45. <span class="ai-plugin__ellipsis" :title="record.id">{{ record.id }}</span>
  46. </div>
  47. <a-switch :checked="record.enabled" size="small" @change="(value: any) => toggleMcp(record, value)" />
  48. </div>
  49. <div class="ai-plugin__card-meta">
  50. <span class="ai-plugin__chip">{{ record.transport === 'http' ? 'Streamable HTTP' : 'STDIO' }}</span>
  51. <span class="ai-plugin__status" :class="record.connected ? 'is-on' : 'is-off'">
  52. {{ record.connected ? `已连接 · ${record.toolCount} 个工具` : '未连接' }}
  53. </span>
  54. </div>
  55. <div class="ai-plugin__card-target ai-plugin__mono" :title="mcpTarget(record)">
  56. {{ mcpTarget(record) }}
  57. </div>
  58. <div v-if="record.tools.length" class="ai-plugin__card-tools">
  59. <span
  60. v-for="tool in record.tools.slice(0, TOOL_PREVIEW_COUNT)"
  61. :key="tool.name"
  62. class="ai-plugin__tool ai-plugin__ellipsis"
  63. :title="tool.description || tool.name"
  64. >
  65. {{ tool.name }}
  66. </span>
  67. <a-button
  68. v-if="record.tools.length > TOOL_PREVIEW_COUNT"
  69. type="link"
  70. size="small"
  71. @click="openToolList(record)"
  72. >
  73. 全部 {{ record.tools.length }} 个
  74. </a-button>
  75. </div>
  76. <div class="ai-plugin__card-ops">
  77. <a-button type="link" size="small" @click="openMcpForm(record)">编辑</a-button>
  78. <a-button type="link" size="small" danger @click="removeMcp(record)">删除</a-button>
  79. </div>
  80. </article>
  81. </div>
  82. <div v-else-if="!loading" class="ai-plugin__empty">
  83. 还没有配置 MCP 服务。点击「新增 MCP」添加一个 stdio 命令或 HTTP 端点,Codex 就能调用它的工具。
  84. </div>
  85. </a-spin>
  86. </a-tab-pane>
  87. <!-- ── Skill ───────────────────────────────────────────── -->
  88. <a-tab-pane key="skill">
  89. <template #tab>
  90. <span>Skill</span>
  91. <span class="ai-plugin__tab-count">{{ skills.length }}</span>
  92. </template>
  93. <div class="ai-plugin__hint">
  94. Skill 是带 <b>SKILL.md</b> 的目录,装在 <b>{{ status?.codexHome || 'CODEX_HOME' }}/skills</b> 下。 Codex
  95. 原生只提供「列出 / 启停」,<b>安装与卸载由本客户端负责</b>:目录名需为小写连字符命名, SKILL.md 的 frontmatter
  96. 必须包含 <b>name</b> 与 <b>description</b>。标记为「内置」的 skill 不可删除。
  97. </div>
  98. <div class="ai-plugin__ops">
  99. <a-button size="small" type="primary" :loading="installing" @click="pickSkillFolder">
  100. <template #icon><Icon icon="mdi:folder-open-outline" :size="14" /></template>
  101. 从目录安装
  102. </a-button>
  103. <a-button size="small" :loading="installing" @click="triggerZipPick">
  104. <template #icon><Icon icon="mdi:upload" :size="14" /></template>
  105. 上传 zip 安装
  106. </a-button>
  107. <a-button size="small" @click="openSkillsDir">
  108. <template #icon><Icon icon="mdi:folder-open-outline" :size="14" /></template>
  109. 打开 skills 目录
  110. </a-button>
  111. <input ref="zipInputRef" type="file" accept=".zip" style="display: none" @change="onZipPicked" />
  112. </div>
  113. <a-spin :spinning="loading">
  114. <div v-if="skills.length" class="ai-plugin__grid">
  115. <article v-for="record in skills" :key="record.path" class="ai-plugin__card">
  116. <div class="ai-plugin__card-head">
  117. <div class="ai-plugin__name">
  118. <span class="ai-plugin__ellipsis" :title="record.name">{{ record.name }}</span>
  119. </div>
  120. <a-switch :checked="record.enabled" size="small" @change="(value: any) => toggleSkill(record, value)" />
  121. </div>
  122. <p class="ai-plugin__card-desc" :title="record.description">{{ record.description || '—' }}</p>
  123. <div class="ai-plugin__card-meta">
  124. <span class="ai-plugin__chip" :class="scopeChipClass(record.scope)">{{
  125. scopeLabel(record.scope)
  126. }}</span>
  127. </div>
  128. <div class="ai-plugin__card-ops">
  129. <a-button type="link" size="small" @click="previewSkill(record)">查看</a-button>
  130. <a-button v-if="record.managed" type="link" size="small" danger @click="removeSkill(record)">
  131. 卸载
  132. </a-button>
  133. </div>
  134. </article>
  135. </div>
  136. <div v-else-if="!loading" class="ai-plugin__empty">
  137. Codex 当前没有可见的 skill。可以从目录或 zip 安装一个。
  138. </div>
  139. </a-spin>
  140. </a-tab-pane>
  141. <!-- ── 运行时 ──────────────────────────────────────────── -->
  142. <a-tab-pane key="runtime" tab="Codex 运行时">
  143. <div class="ai-plugin__hint">
  144. 本客户端<b>不做任何 Codex / OpenAI 账号登录</b>:模型来自后端「模型管理」, 选中后由主进程写入本机 Codex
  145. 配置,<b>API Key 只注入子进程环境变量,不落盘</b>。 Codex 0.155 起只支持 Responses 协议,仅提供
  146. <b>/chat/completions</b> 的端点会在探测阶段被拦下。
  147. </div>
  148. <div class="ai-plugin__panel">
  149. <div class="ai-plugin__panel-title">运行状态</div>
  150. <div class="ai-plugin__kv">
  151. <span>状态</span>
  152. <b :class="stateClass">{{ stateLabel }}</b>
  153. <span>Codex 版本</span>
  154. <b class="ai-plugin__mono">{{ status?.version || '—' }}</b>
  155. <span>当前模型</span>
  156. <b class="ai-plugin__mono">{{ status?.defaultModel || '未配置' }}</b>
  157. <span>Provider</span>
  158. <b class="ai-plugin__mono">{{ status?.providerId || '—' }}</b>
  159. <span>CODEX_HOME</span>
  160. <b class="ai-plugin__mono ai-plugin__ellipsis" :title="status?.codexHome">
  161. {{ status?.codexHome || '—' }}
  162. </b>
  163. </div>
  164. <a-alert
  165. v-if="status?.degraded && status?.running"
  166. class="ai-plugin__alert"
  167. type="warning"
  168. show-icon
  169. message="模型目录不可用"
  170. description="model/list 没有返回可用模型。这不影响已配置的自定义模型,但页面上的模型列表可能为空。"
  171. />
  172. <a-alert
  173. v-if="status?.error"
  174. class="ai-plugin__alert"
  175. type="error"
  176. show-icon
  177. message="运行时错误"
  178. :description="status.error"
  179. />
  180. <div class="ai-plugin__ops">
  181. <a-button size="small" :loading="busy" :disabled="status?.running" @click="startRuntime">
  182. <template #icon><Icon icon="mdi:play" :size="14" /></template>
  183. 启动
  184. </a-button>
  185. <a-button size="small" :loading="busy" :disabled="!status?.running" @click="restartRuntime">
  186. <template #icon><Icon icon="mdi:refresh" :size="14" /></template>
  187. 重启
  188. </a-button>
  189. <a-button size="small" danger :loading="busy" :disabled="!status?.running" @click="stopRuntime">
  190. <template #icon><Icon icon="mdi:stop" :size="14" /></template>
  191. 停止
  192. </a-button>
  193. </div>
  194. </div>
  195. <div class="ai-plugin__panel">
  196. <div class="ai-plugin__panel-title">模型(来自后端「模型管理」)</div>
  197. <div class="ai-plugin__row">
  198. <a-select
  199. v-model:value="selectedModelId"
  200. class="ai-plugin__select"
  201. :options="modelOptions"
  202. :loading="modelsLoading"
  203. placeholder="选择一个对话模型"
  204. show-search
  205. option-filter-prop="label"
  206. />
  207. <a-button size="small" :loading="probing" :disabled="!selectedModel" @click="probeSelected">
  208. 探测端点
  209. </a-button>
  210. <a-button size="small" type="primary" :loading="applying" :disabled="!selectedModel" @click="applySelected">
  211. 应用
  212. </a-button>
  213. <a-button size="small" :disabled="!status?.applied" @click="clearApplied">清除</a-button>
  214. </div>
  215. <a-alert
  216. v-if="probeResult"
  217. class="ai-plugin__alert"
  218. :type="probeResult.compatible ? 'success' : 'error'"
  219. show-icon
  220. :message="probeResult.compatible ? '端点兼容' : '端点不兼容'"
  221. :description="probeResult.detail"
  222. />
  223. <a-alert
  224. v-if="probeResult?.hint"
  225. class="ai-plugin__alert"
  226. type="warning"
  227. show-icon
  228. message="base_url 提醒"
  229. :description="probeResult.hint"
  230. />
  231. <div v-if="status?.applied" class="ai-plugin__applied">
  232. 已应用:<b>{{ status.applied.name || status.applied.model }}</b>
  233. <span class="ai-plugin__mono">{{ status.applied.model }}</span>
  234. <span class="ai-plugin__mono">{{ status.applied.baseUrl || status.applied.builtinProvider }}</span>
  235. <span class="ai-plugin__sub">{{ status.applied.appliedAt }}</span>
  236. </div>
  237. <div class="ai-plugin__note">
  238. 重启客户端后需要重新「应用」一次:API Key 不会落盘,只能由页面从后端取到后重新注入。
  239. </div>
  240. </div>
  241. <div class="ai-plugin__panel">
  242. <div class="ai-plugin__panel-title">
  243. 诊断日志
  244. <a-button type="link" size="small" @click="loadDiagnostics">刷新</a-button>
  245. </div>
  246. <div class="ai-plugin__logs">
  247. <div v-for="(item, index) in diagnostics" :key="index" class="ai-plugin__log">
  248. <span class="ai-plugin__mono">{{ item.ts.slice(11, 19) }}</span>
  249. <span>{{ item.line }}</span>
  250. </div>
  251. <div v-if="!diagnostics.length" class="ai-empty-tip">暂无诊断输出。</div>
  252. </div>
  253. </div>
  254. </a-tab-pane>
  255. </a-tabs>
  256. <!-- MCP 表单 -->
  257. <a-modal
  258. v-model:open="mcpFormOpen"
  259. :title="mcpEditing ? '编辑 MCP 服务' : '新增 MCP 服务'"
  260. :width="620"
  261. :confirm-loading="mcpSubmitting"
  262. :mask-closable="false"
  263. ok-text="保存"
  264. cancel-text="取消"
  265. @ok="submitMcp"
  266. >
  267. <a-form class="ai-plugin__form" :model="mcpForm" layout="vertical">
  268. <a-form-item label="ID" required>
  269. <a-input
  270. v-model:value="mcpForm.id"
  271. :disabled="Boolean(mcpEditing)"
  272. placeholder="字母、数字、连字符或下划线,最长 64"
  273. :maxlength="64"
  274. />
  275. </a-form-item>
  276. <a-form-item label="传输方式" required>
  277. <a-radio-group v-model:value="mcpForm.transport" size="small">
  278. <a-radio-button value="stdio">STDIO(本机命令)</a-radio-button>
  279. <a-radio-button value="http">HTTP / SSE(远程端点)</a-radio-button>
  280. </a-radio-group>
  281. </a-form-item>
  282. <template v-if="mcpForm.transport === 'stdio'">
  283. <a-form-item label="命令" required>
  284. <a-input v-model:value="mcpForm.command" placeholder="如:npx" :maxlength="500" />
  285. </a-form-item>
  286. <a-form-item label="参数(每行一个)">
  287. <a-textarea
  288. v-model:value="mcpForm.argsText"
  289. :rows="4"
  290. placeholder="-y&#10;@modelcontextprotocol/server-everything"
  291. />
  292. </a-form-item>
  293. </template>
  294. <template v-else>
  295. <a-form-item label="URL" required>
  296. <a-input
  297. v-model:value="mcpForm.url"
  298. placeholder="https://mcp.example.com/v1(远程必须 HTTPS,HTTP 仅允许本机)"
  299. :maxlength="2048"
  300. />
  301. </a-form-item>
  302. <a-form-item label="Bearer Token 环境变量名">
  303. <a-input v-model:value="mcpForm.bearerTokenEnvVar" placeholder="如:ZSJZ_MCP_TOKEN" :maxlength="128" />
  304. <div class="ai-plugin__note">只填环境变量名,不要填 token 本身。</div>
  305. </a-form-item>
  306. </template>
  307. <a-form-item label="启用">
  308. <a-switch v-model:checked="mcpForm.enabled" size="small" />
  309. </a-form-item>
  310. </a-form>
  311. </a-modal>
  312. <!-- Skill 预览 -->
  313. <a-drawer v-model:open="skillPreviewOpen" title="SKILL.md" :width="720" placement="right">
  314. <div v-if="skillPreview" class="ai-plugin__preview">
  315. <div class="ai-plugin__mono">{{ skillPreview.dir }}</div>
  316. <pre class="ai-plugin__pre">{{ skillPreview.content }}</pre>
  317. <div class="ai-plugin__panel-title">附带文件</div>
  318. <div v-for="file in skillPreview.files" :key="file.path" class="ai-plugin__log">
  319. <span class="ai-plugin__mono">{{ file.path }}</span>
  320. <span class="ai-plugin__sub">{{ file.size }} B</span>
  321. </div>
  322. </div>
  323. </a-drawer>
  324. <!-- MCP 工具清单:Codex mcpServerStatus/list 已随状态返回,这里只做展示与过滤 -->
  325. <a-drawer
  326. v-model:open="mcpToolsOpen"
  327. :title="`工具清单 · ${mcpToolsServer?.id || ''}`"
  328. :width="720"
  329. placement="right"
  330. >
  331. <div class="ai-plugin__tools">
  332. <a-input v-model:value="toolKeyword" placeholder="按工具名或描述过滤" allow-clear />
  333. <div class="ai-plugin__sub">{{ filteredTools.length }} / {{ mcpToolsServer?.tools.length || 0 }} 个工具</div>
  334. <div v-for="tool in filteredTools" :key="tool.name" class="ai-plugin__tool-row">
  335. <div class="ai-plugin__mono">{{ tool.name }}</div>
  336. <div v-if="tool.description" class="ai-plugin__tool-desc">{{ tool.description }}</div>
  337. </div>
  338. <div v-if="!filteredTools.length" class="ai-empty-tip">没有匹配的工具。</div>
  339. </div>
  340. </a-drawer>
  341. </div>
  342. </template>
  343. <script lang="ts" setup name="AiPluginIndex">
  344. /**
  345. * AI 插件页:管理本机 Codex 运行时的 MCP 服务、Skill 与模型 provider。
  346. *
  347. * 全部能力走桌面端 IPC(src/ai/api/codexApi.ts → electron/controller/codexCtl.ts),
  348. * 不走 /js/a HTTP;只有「模型」下拉复用后端模型管理接口。
  349. * 浏览器环境下不发任何调用,整页降级为提示。
  350. */
  351. import { computed, onBeforeUnmount, onMounted, reactive, ref } from 'vue';
  352. import {
  353. Alert,
  354. Button,
  355. Drawer,
  356. Form,
  357. FormItem,
  358. Input,
  359. Modal,
  360. RadioButton,
  361. RadioGroup,
  362. Select,
  363. Spin,
  364. Switch,
  365. Tabs,
  366. Textarea,
  367. } from 'ant-design-vue';
  368. import Icon from '@/core/components/Icon/src/Icon.vue';
  369. import { useMessage } from '@/core/hooks/web/useMessage';
  370. import * as codexApi from '../../api/codexApi';
  371. import * as modelApi from '../../api/modelApi';
  372. import type { AiModel, ModelProvider } from '../../api/types';
  373. const AAlert = Alert;
  374. const AButton = Button;
  375. const ADrawer = Drawer;
  376. const AForm = Form;
  377. const AFormItem = FormItem;
  378. const AInput = Input;
  379. const AModal = Modal;
  380. const ARadioGroup = RadioGroup;
  381. const ARadioButton = RadioButton;
  382. const ASelect = Select;
  383. const ASpin = Spin;
  384. const ASwitch = Switch;
  385. const ATabs = Tabs;
  386. const ATabPane = Tabs.TabPane;
  387. const ATextarea = Textarea;
  388. const { showMessage } = useMessage();
  389. const bridgeAvailable = ref(codexApi.isCodexBridgeAvailable());
  390. const activeTab = ref<'mcp' | 'skill' | 'runtime'>('mcp');
  391. const loading = ref(false);
  392. const busy = ref(false);
  393. const installing = ref(false);
  394. const status = ref<codexApi.CodexStatus | null>(null);
  395. const mcpServers = ref<codexApi.CodexMcpServer[]>([]);
  396. const skills = ref<codexApi.CodexSkill[]>([]);
  397. const diagnostics = ref<codexApi.CodexDiagnostic[]>([]);
  398. const models = ref<AiModel[]>([]);
  399. const providers = ref<ModelProvider[]>([]);
  400. const modelsLoading = ref(false);
  401. const selectedModelId = ref<string | undefined>(undefined);
  402. const probing = ref(false);
  403. const applying = ref(false);
  404. const probeResult = ref<codexApi.ProviderProbeResult | null>(null);
  405. const zipInputRef = ref<HTMLInputElement | null>(null);
  406. const skillPreviewOpen = ref(false);
  407. const skillPreview = ref<codexApi.CodexSkillReadResult | null>(null);
  408. /** 卡片上平铺的工具名个数,超出部分收进抽屉,避免 77 个工具把卡片撑爆 */
  409. const TOOL_PREVIEW_COUNT = 6;
  410. const mcpToolsOpen = ref(false);
  411. const mcpToolsServer = ref<codexApi.CodexMcpServer | null>(null);
  412. const toolKeyword = ref('');
  413. const filteredTools = computed(() => {
  414. const keyword = toolKeyword.value.trim().toLowerCase();
  415. const tools = mcpToolsServer.value?.tools || [];
  416. if (!keyword) return tools;
  417. return tools.filter(
  418. (tool) => tool.name.toLowerCase().includes(keyword) || tool.description.toLowerCase().includes(keyword),
  419. );
  420. });
  421. function openToolList(record: codexApi.CodexMcpServer) {
  422. mcpToolsServer.value = record;
  423. toolKeyword.value = '';
  424. mcpToolsOpen.value = true;
  425. }
  426. const mcpFormOpen = ref(false);
  427. const mcpEditing = ref<codexApi.CodexMcpServer | null>(null);
  428. const mcpSubmitting = ref(false);
  429. const mcpForm = reactive({
  430. id: '',
  431. transport: 'stdio' as 'stdio' | 'http',
  432. command: '',
  433. argsText: '',
  434. url: '',
  435. bearerTokenEnvVar: '',
  436. enabled: true,
  437. });
  438. const unsubscribers: Array<() => void> = [];
  439. const summary = computed(() => {
  440. if (!bridgeAvailable.value) return '仅桌面客户端可用';
  441. const parts = [`${mcpServers.value.length} 个 MCP`, `${skills.value.length} 个 Skill`];
  442. parts.push(status.value?.running ? `运行中 · ${status.value.defaultModel || '未配置模型'}` : '未启动');
  443. return parts.join(' · ');
  444. });
  445. const stateLabel = computed(() => {
  446. const state = status.value?.state;
  447. if (state === 'ready') return '运行中';
  448. if (state === 'starting') return '启动中';
  449. if (state === 'error') return '异常';
  450. return '已停止';
  451. });
  452. const stateClass = computed(() => {
  453. const state = status.value?.state;
  454. if (state === 'ready') return 'is-on';
  455. if (state === 'error') return 'is-err';
  456. return 'is-off';
  457. });
  458. /** 只列对话模型:Codex 是 agent,向量模型用不上 */
  459. const modelOptions = computed(() =>
  460. models.value
  461. .filter((item) => !item.type || item.type === 'llm')
  462. .map((item) => ({
  463. label: `${item.name || item.modelId || item.id}(${item.modelId || '—'})`,
  464. value: item.id,
  465. })),
  466. );
  467. const selectedModel = computed(() => models.value.find((item) => item.id === selectedModelId.value) || null);
  468. function mcpTarget(record: codexApi.CodexMcpServer) {
  469. if (record.transport === 'http') return record.url || '—';
  470. return [record.command, ...(record.args || [])].filter(Boolean).join(' ') || '—';
  471. }
  472. function scopeLabel(scope: string) {
  473. if (scope === 'system') return '内置';
  474. if (scope === 'user') return '用户';
  475. if (scope === 'repo') return '仓库';
  476. if (scope === 'admin') return '管理员';
  477. return scope || '—';
  478. }
  479. /** 内置标红、用户自装标绿,其余来源保持中性色 */
  480. function scopeChipClass(scope: string) {
  481. if (scope === 'system') return 'is-error';
  482. if (scope === 'user') return 'is-success';
  483. return '';
  484. }
  485. onMounted(async () => {
  486. if (!bridgeAvailable.value) return;
  487. unsubscribers.push(codexApi.onCodexStatus((payload) => (status.value = payload)));
  488. unsubscribers.push(
  489. codexApi.onCodexDiagnostic((item) => {
  490. diagnostics.value = [...diagnostics.value, item].slice(-200);
  491. }),
  492. );
  493. await reload();
  494. await loadModels();
  495. });
  496. onBeforeUnmount(() => {
  497. for (const unsubscribe of unsubscribers) unsubscribe();
  498. unsubscribers.length = 0;
  499. });
  500. async function reload() {
  501. if (!bridgeAvailable.value) return;
  502. loading.value = true;
  503. try {
  504. const [nextStatus, nextMcp, nextSkills, logs] = await Promise.all([
  505. codexApi.codexStatus(),
  506. codexApi.codexMcpList().catch(() => [] as codexApi.CodexMcpServer[]),
  507. codexApi.codexSkillList().catch(() => [] as codexApi.CodexSkill[]),
  508. codexApi.codexLogsTail(200).catch(() => [] as codexApi.CodexDiagnostic[]),
  509. ]);
  510. status.value = nextStatus;
  511. mcpServers.value = nextMcp;
  512. skills.value = nextSkills;
  513. diagnostics.value = logs;
  514. } catch (e: any) {
  515. showMessage(e?.message || '加载插件信息失败', 'error');
  516. } finally {
  517. loading.value = false;
  518. }
  519. }
  520. async function loadModels() {
  521. modelsLoading.value = true;
  522. try {
  523. const [list, providerList] = await Promise.all([
  524. modelApi.listModels(),
  525. modelApi.listProviders().catch(() => [] as ModelProvider[]),
  526. ]);
  527. models.value = list || [];
  528. providers.value = providerList || [];
  529. const applied = status.value?.applied;
  530. selectedModelId.value = applied?.modelRecordId || models.value.find((m) => m.defaultModel)?.id || undefined;
  531. } catch (e: any) {
  532. showMessage(e?.message || '加载模型列表失败(需要登录后端)', 'error');
  533. } finally {
  534. modelsLoading.value = false;
  535. }
  536. }
  537. /** 把后端模型记录翻译成主进程要的入参;厂商协议类型来自 agent_model_provider.type */
  538. function buildApplyInput(model: AiModel): codexApi.ApplyProviderInput {
  539. const providerType = providers.value.find((item) => item.name === model.provider)?.type ?? null;
  540. return {
  541. modelId: model.modelId || '',
  542. name: model.name || null,
  543. baseUrl: model.baseUrl || null,
  544. apiKey: model.apiKey || null,
  545. headersJson: model.headersJson || null,
  546. config: model.config || null,
  547. providerType,
  548. modelRecordId: model.id,
  549. };
  550. }
  551. async function probeSelected() {
  552. const model = selectedModel.value;
  553. if (!model) return;
  554. probing.value = true;
  555. probeResult.value = null;
  556. try {
  557. const input = buildApplyInput(model);
  558. probeResult.value = await codexApi.codexProbeProvider({
  559. baseUrl: input.baseUrl || '',
  560. apiKey: input.apiKey,
  561. });
  562. } catch (e: any) {
  563. showMessage(e?.message || '探测失败', 'error');
  564. } finally {
  565. probing.value = false;
  566. }
  567. }
  568. async function applySelected() {
  569. const model = selectedModel.value;
  570. if (!model) return;
  571. applying.value = true;
  572. try {
  573. const result = await codexApi.codexApplyProvider(buildApplyInput(model));
  574. status.value = result.status;
  575. probeResult.value = null;
  576. showMessage(`已应用模型「${result.applied.model}」`, 'success');
  577. if (result.droppedConfigKeys.length) {
  578. showMessage(`config 中不支持的键已忽略:${result.droppedConfigKeys.join('、')}`, 'warning');
  579. }
  580. if (result.hint) showMessage(result.hint, 'warning');
  581. } catch (e: any) {
  582. showMessage(e?.message || '应用模型失败', 'error');
  583. } finally {
  584. applying.value = false;
  585. }
  586. }
  587. async function clearApplied() {
  588. try {
  589. status.value = await codexApi.codexClearProvider();
  590. showMessage('已清除模型配置', 'success');
  591. } catch (e: any) {
  592. showMessage(e?.message || '清除失败', 'error');
  593. }
  594. }
  595. async function startRuntime() {
  596. busy.value = true;
  597. try {
  598. status.value = await codexApi.codexStart();
  599. showMessage('Codex 运行时已启动', 'success');
  600. } catch (e: any) {
  601. showMessage(e?.message || '启动失败', 'error');
  602. } finally {
  603. busy.value = false;
  604. }
  605. }
  606. async function restartRuntime() {
  607. busy.value = true;
  608. try {
  609. status.value = await codexApi.codexRestart();
  610. showMessage('Codex 运行时已重启', 'success');
  611. } catch (e: any) {
  612. showMessage(e?.message || '重启失败', 'error');
  613. } finally {
  614. busy.value = false;
  615. }
  616. }
  617. async function stopRuntime() {
  618. busy.value = true;
  619. try {
  620. status.value = await codexApi.codexStop();
  621. showMessage('Codex 运行时已停止', 'success');
  622. } catch (e: any) {
  623. showMessage(e?.message || '停止失败', 'error');
  624. } finally {
  625. busy.value = false;
  626. }
  627. }
  628. async function loadDiagnostics() {
  629. try {
  630. diagnostics.value = await codexApi.codexLogsTail(200);
  631. } catch (e: any) {
  632. showMessage(e?.message || '读取诊断日志失败', 'error');
  633. }
  634. }
  635. // ── MCP ──────────────────────────────────────────────────────────────
  636. function openMcpForm(record: any) {
  637. mcpEditing.value = record ? (record as codexApi.CodexMcpServer) : null;
  638. mcpForm.id = record?.id ?? '';
  639. mcpForm.transport = record?.transport ?? 'stdio';
  640. mcpForm.command = record?.command ?? '';
  641. mcpForm.argsText = (record?.args ?? []).join('\n');
  642. mcpForm.url = record?.url ?? '';
  643. mcpForm.bearerTokenEnvVar = record?.bearerTokenEnvVar ?? '';
  644. mcpForm.enabled = record ? record.enabled : true;
  645. mcpFormOpen.value = true;
  646. }
  647. async function submitMcp() {
  648. if (!mcpForm.id.trim()) {
  649. showMessage('请填写 ID', 'error');
  650. return;
  651. }
  652. mcpSubmitting.value = true;
  653. try {
  654. const args = mcpForm.argsText
  655. .split(/\r?\n/)
  656. .map((line) => line.trim())
  657. .filter(Boolean);
  658. mcpServers.value = await codexApi.codexMcpSave({
  659. id: mcpForm.id.trim(),
  660. transport: mcpForm.transport,
  661. command: mcpForm.transport === 'stdio' ? mcpForm.command.trim() : null,
  662. args: mcpForm.transport === 'stdio' ? args : [],
  663. url: mcpForm.transport === 'http' ? mcpForm.url.trim() : null,
  664. bearerTokenEnvVar: mcpForm.transport === 'http' ? mcpForm.bearerTokenEnvVar.trim() || null : null,
  665. enabled: mcpForm.enabled,
  666. });
  667. mcpFormOpen.value = false;
  668. showMessage('已保存并热生效', 'success');
  669. } catch (e: any) {
  670. showMessage(e?.message || '保存失败', 'error');
  671. } finally {
  672. mcpSubmitting.value = false;
  673. }
  674. }
  675. async function toggleMcp(record: any, enabled: boolean) {
  676. try {
  677. mcpServers.value = await codexApi.codexMcpSave({
  678. id: record.id,
  679. transport: record.transport,
  680. command: record.command,
  681. args: record.args,
  682. url: record.url,
  683. bearerTokenEnvVar: record.bearerTokenEnvVar,
  684. enabled,
  685. });
  686. } catch (e: any) {
  687. showMessage(e?.message || '切换失败', 'error');
  688. }
  689. }
  690. function removeMcp(record: any) {
  691. Modal.confirm({
  692. title: '删除 MCP 服务',
  693. content: `确认删除「${record.id}」?该配置只影响本机 Codex。`,
  694. okText: '删除',
  695. okType: 'danger',
  696. cancelText: '取消',
  697. onOk: async () => {
  698. try {
  699. mcpServers.value = await codexApi.codexMcpRemove(record.id);
  700. showMessage('已删除', 'success');
  701. } catch (e: any) {
  702. showMessage(e?.message || '删除失败', 'error');
  703. }
  704. },
  705. });
  706. }
  707. // ── Skill ────────────────────────────────────────────────────────────
  708. async function pickSkillFolder() {
  709. try {
  710. const srcPath = await codexApi.selectDirectory();
  711. if (!srcPath) return;
  712. await installSkill(() => codexApi.codexSkillInstallFolder({ srcPath }));
  713. } catch (e: any) {
  714. showMessage(e?.message || '选择目录失败', 'error');
  715. }
  716. }
  717. function triggerZipPick() {
  718. zipInputRef.value?.click();
  719. }
  720. async function onZipPicked(event: Event) {
  721. const input = event.target as HTMLInputElement;
  722. const file = input.files?.[0];
  723. input.value = '';
  724. if (!file) return;
  725. try {
  726. const data = await codexApi.readFileBytes(file);
  727. await installSkill(() => codexApi.codexSkillInstallZip({ data }));
  728. } catch (e: any) {
  729. showMessage(e?.message || '读取 zip 失败', 'error');
  730. }
  731. }
  732. async function installSkill(run: () => Promise<codexApi.CodexSkill[]>) {
  733. installing.value = true;
  734. try {
  735. skills.value = await run();
  736. showMessage('安装成功', 'success');
  737. } catch (e: any) {
  738. showMessage(e?.message || '安装失败', 'error');
  739. } finally {
  740. installing.value = false;
  741. }
  742. }
  743. async function toggleSkill(record: any, enabled: boolean) {
  744. try {
  745. const result = await codexApi.codexSkillSetEnabled({ path: record.path }, enabled);
  746. skills.value = skills.value.map((item) =>
  747. item.path === record.path ? { ...item, enabled: result.effectiveEnabled } : item,
  748. );
  749. } catch (e: any) {
  750. showMessage(e?.message || '切换失败', 'error');
  751. }
  752. }
  753. async function previewSkill(record: any) {
  754. try {
  755. skillPreview.value = await codexApi.codexSkillRead({ path: record.path });
  756. skillPreviewOpen.value = true;
  757. } catch (e: any) {
  758. showMessage(e?.message || '读取失败', 'error');
  759. }
  760. }
  761. function removeSkill(record: any) {
  762. Modal.confirm({
  763. title: '卸载 Skill',
  764. content: `确认删除「${record.name}」?将移除 ${record.path} 整个目录。`,
  765. okText: '卸载',
  766. okType: 'danger',
  767. cancelText: '取消',
  768. onOk: async () => {
  769. try {
  770. skills.value = await codexApi.codexSkillRemove({ path: record.path });
  771. showMessage('已卸载', 'success');
  772. } catch (e: any) {
  773. showMessage(e?.message || '卸载失败', 'error');
  774. }
  775. },
  776. });
  777. }
  778. async function openSkillsDir() {
  779. try {
  780. await codexApi.codexSkillOpenFolder();
  781. } catch (e: any) {
  782. showMessage(e?.message || '打开目录失败', 'error');
  783. }
  784. }
  785. </script>
  786. <style lang="less" scoped>
  787. .ai-plugin {
  788. /* Tabs 撑起整页骨架:页签栏即顶部条,内容区自己滚动 */
  789. &__tabs {
  790. flex: 1 1 auto;
  791. min-height: 0;
  792. :deep(.ant-tabs-nav) {
  793. flex: 0 0 auto;
  794. margin: 0;
  795. padding: 0 var(--ai-sp-2);
  796. background: var(--ai-bg);
  797. border-bottom: 1px solid var(--ai-border);
  798. /* antd 自带的发丝线与顶部条边框重叠 */
  799. &::before {
  800. display: none;
  801. }
  802. }
  803. /* 靠页签内边距撑出顶部条高度(--ai-header-h),下划线才能压在分隔线上 */
  804. :deep(.ant-tabs-tab) {
  805. padding: 15px 0;
  806. font-size: 13.5px;
  807. }
  808. :deep(.ant-tabs-extra-content) {
  809. margin-left: var(--ai-sp-3);
  810. }
  811. /* 内容区内边距取分析页那一档(8px),左右与页签栏文字对齐 */
  812. :deep(.ant-tabs-content-holder) {
  813. flex: 1 1 auto;
  814. min-height: 0;
  815. padding: var(--ai-sp-2);
  816. overflow-y: auto;
  817. overscroll-behavior: contain;
  818. }
  819. }
  820. /* 摘要与页签同在一行,过长时限宽省略,避免把页签挤进溢出下拉 */
  821. &__summary {
  822. max-width: 360px;
  823. overflow: hidden;
  824. white-space: nowrap;
  825. text-overflow: ellipsis;
  826. }
  827. /* 浏览器环境下没有页签栏,提示条自己留出边距 */
  828. &__bridge-alert {
  829. flex: 0 0 auto;
  830. margin: var(--ai-sp-2);
  831. }
  832. &__tab-count {
  833. display: inline-block;
  834. min-width: 18px;
  835. margin-left: var(--ai-sp-2);
  836. padding: 0 5px;
  837. font-size: 11px;
  838. line-height: 17px;
  839. text-align: center;
  840. color: var(--ai-text-2);
  841. background: var(--ai-bg-subtle);
  842. border: 1px solid var(--ai-border);
  843. border-radius: 999px;
  844. }
  845. &__hint {
  846. margin-bottom: var(--ai-sp-2);
  847. padding: var(--ai-sp-3);
  848. font-size: 12.5px;
  849. line-height: 1.75;
  850. color: var(--ai-text-2);
  851. background: var(--ai-primary-softer);
  852. border-left: 3px solid fade(@primary-color, 45%);
  853. border-radius: 0 var(--ai-radius-sm) var(--ai-radius-sm) 0;
  854. b {
  855. color: var(--ai-text);
  856. }
  857. }
  858. &__ops {
  859. display: flex;
  860. gap: var(--ai-sp-2);
  861. margin-bottom: var(--ai-sp-2);
  862. }
  863. /* 卡片网格:间距取分析页那一档(8px);圆角比页面外壳(12px)低一档,用 --ai-radius(8px) */
  864. &__grid {
  865. display: grid;
  866. grid-template-columns: repeat(auto-fill, minmax(320px, 1fr));
  867. gap: var(--ai-sp-2);
  868. }
  869. &__card {
  870. display: flex;
  871. flex-direction: column;
  872. gap: var(--ai-sp-2);
  873. min-width: 0;
  874. padding: var(--ai-sp-3);
  875. background: var(--ai-bg);
  876. border: 1px solid var(--ai-border);
  877. border-radius: var(--ai-radius);
  878. transition:
  879. border-color 0.2s ease,
  880. box-shadow 0.2s ease,
  881. transform 0.2s ease;
  882. &:hover {
  883. border-color: var(--ai-border-strong);
  884. box-shadow: var(--ai-shadow);
  885. transform: translateY(-2px);
  886. }
  887. }
  888. &__card-head {
  889. display: flex;
  890. align-items: center;
  891. justify-content: space-between;
  892. gap: var(--ai-sp-3);
  893. min-width: 0;
  894. }
  895. &__card-desc {
  896. display: -webkit-box;
  897. -webkit-box-orient: vertical;
  898. -webkit-line-clamp: 3;
  899. min-height: calc(12.5px * 1.7 * 2);
  900. margin: 0;
  901. overflow: hidden;
  902. font-size: 12.5px;
  903. line-height: 1.7;
  904. color: var(--ai-text-2);
  905. }
  906. &__card-target {
  907. display: -webkit-box;
  908. -webkit-box-orient: vertical;
  909. -webkit-line-clamp: 2;
  910. overflow: hidden;
  911. word-break: break-all;
  912. }
  913. &__card-tools {
  914. display: flex;
  915. flex-wrap: wrap;
  916. gap: var(--ai-sp-1);
  917. align-items: center;
  918. min-width: 0;
  919. }
  920. /* 字号/字体沿用 __mono,这里只管盒子 */
  921. &__tool {
  922. max-width: 148px;
  923. padding: 0 6px;
  924. line-height: 18px;
  925. background-color: var(--ai-bg-subtle);
  926. border: 1px solid var(--ai-border);
  927. border-radius: var(--ai-radius-sm);
  928. }
  929. &__tools {
  930. display: flex;
  931. flex-direction: column;
  932. gap: var(--ai-sp-3);
  933. }
  934. &__tool-row {
  935. padding: var(--ai-sp-2) 0;
  936. border-bottom: 1px dashed var(--ai-border);
  937. }
  938. &__tool-desc {
  939. margin-top: 2px;
  940. font-size: 12px;
  941. line-height: 1.7;
  942. color: var(--ai-text-2);
  943. white-space: pre-wrap;
  944. }
  945. &__card-meta {
  946. display: flex;
  947. align-items: center;
  948. gap: var(--ai-sp-2);
  949. min-width: 0;
  950. color: var(--ai-text-2);
  951. }
  952. &__card-ops {
  953. display: flex;
  954. align-items: center;
  955. gap: var(--ai-sp-1);
  956. margin-top: auto;
  957. padding-top: var(--ai-sp-2);
  958. border-top: 1px solid var(--ai-border);
  959. }
  960. &__empty {
  961. padding: var(--ai-sp-4) var(--ai-sp-3);
  962. font-size: 12.5px;
  963. line-height: 1.7;
  964. text-align: center;
  965. color: var(--ai-text-2);
  966. background: var(--ai-bg-subtle);
  967. border: 1px dashed var(--ai-border);
  968. border-radius: var(--ai-radius);
  969. }
  970. /* 卡片头里它是 flex item,不给 min-width:0 就会把右侧开关挤出去 */
  971. &__name {
  972. display: flex;
  973. align-items: center;
  974. gap: var(--ai-sp-2);
  975. min-width: 0;
  976. font-size: 13.5px;
  977. font-weight: 600;
  978. color: var(--ai-text);
  979. }
  980. &__sub {
  981. margin-top: 2px;
  982. font-family: 'SFMono-Regular', Consolas, monospace;
  983. font-size: 11.5px;
  984. color: var(--ai-text-2);
  985. }
  986. &__chip {
  987. display: inline-block;
  988. flex: 0 0 auto;
  989. padding: 1px 8px;
  990. font-size: 12px;
  991. line-height: 20px;
  992. white-space: nowrap;
  993. color: var(--ai-text);
  994. background: var(--ai-bg-subtle);
  995. border: 1px solid var(--ai-border);
  996. border-radius: 999px;
  997. &.is-error {
  998. color: @error-color;
  999. background-color: fade(@error-color, 10%);
  1000. border-color: fade(@error-color, 26%);
  1001. }
  1002. &.is-success {
  1003. color: @success-color;
  1004. background-color: fade(@success-color, 10%);
  1005. border-color: fade(@success-color, 26%);
  1006. }
  1007. }
  1008. &__mono {
  1009. font-family: 'SFMono-Regular', Consolas, monospace;
  1010. font-size: 12px;
  1011. color: var(--ai-text-2);
  1012. }
  1013. &__ellipsis {
  1014. display: inline-block;
  1015. max-width: 100%;
  1016. overflow: hidden;
  1017. white-space: nowrap;
  1018. text-overflow: ellipsis;
  1019. }
  1020. &__status {
  1021. font-size: 12px;
  1022. &.is-on {
  1023. color: @success-color;
  1024. }
  1025. &.is-off {
  1026. color: var(--ai-text-2);
  1027. }
  1028. &.is-err {
  1029. color: @error-color;
  1030. }
  1031. }
  1032. /* 运行时页签里的分块面板:与卡片同档(8px 圆角 / 12px 内边距 / 8px 块间距) */
  1033. &__panel {
  1034. margin-bottom: var(--ai-sp-2);
  1035. padding: var(--ai-sp-3);
  1036. background: var(--ai-bg);
  1037. border: 1px solid var(--ai-border);
  1038. border-radius: var(--ai-radius);
  1039. }
  1040. &__panel-title {
  1041. display: flex;
  1042. align-items: center;
  1043. gap: var(--ai-sp-2);
  1044. margin-bottom: var(--ai-sp-3);
  1045. font-size: 13px;
  1046. font-weight: 600;
  1047. color: var(--ai-text);
  1048. }
  1049. &__kv {
  1050. display: grid;
  1051. grid-template-columns: 120px 1fr;
  1052. gap: var(--ai-sp-2) var(--ai-sp-3);
  1053. font-size: 12.5px;
  1054. color: var(--ai-text-2);
  1055. b {
  1056. font-weight: 500;
  1057. color: var(--ai-text);
  1058. &.is-on {
  1059. color: @success-color;
  1060. }
  1061. &.is-err {
  1062. color: @error-color;
  1063. }
  1064. &.is-off {
  1065. color: var(--ai-text-2);
  1066. }
  1067. }
  1068. }
  1069. &__row {
  1070. display: flex;
  1071. flex-wrap: wrap;
  1072. gap: var(--ai-sp-2);
  1073. align-items: center;
  1074. }
  1075. &__select {
  1076. min-width: 320px;
  1077. }
  1078. &__alert {
  1079. margin-top: var(--ai-sp-3);
  1080. }
  1081. &__applied {
  1082. display: flex;
  1083. flex-wrap: wrap;
  1084. gap: var(--ai-sp-2);
  1085. align-items: center;
  1086. margin-top: var(--ai-sp-3);
  1087. font-size: 12.5px;
  1088. color: var(--ai-text-2);
  1089. }
  1090. &__note {
  1091. margin-top: var(--ai-sp-2);
  1092. font-size: 12px;
  1093. line-height: 1.7;
  1094. color: var(--ai-text-2);
  1095. }
  1096. &__logs {
  1097. max-height: 260px;
  1098. overflow-y: auto;
  1099. font-size: 12px;
  1100. }
  1101. &__log {
  1102. display: flex;
  1103. gap: var(--ai-sp-2);
  1104. padding: 2px 0;
  1105. color: var(--ai-text-2);
  1106. border-bottom: 1px dashed var(--ai-border);
  1107. }
  1108. &__form {
  1109. :deep(.ant-form-item) {
  1110. margin-bottom: var(--ai-sp-3);
  1111. }
  1112. }
  1113. &__preview {
  1114. font-size: 12.5px;
  1115. }
  1116. &__pre {
  1117. max-height: 50vh;
  1118. padding: var(--ai-sp-3);
  1119. margin: var(--ai-sp-2) 0 var(--ai-sp-4);
  1120. overflow: auto;
  1121. font-family: 'SFMono-Regular', Consolas, monospace;
  1122. font-size: 12px;
  1123. line-height: 1.7;
  1124. white-space: pre-wrap;
  1125. background: var(--ai-bg-subtle);
  1126. border: 1px solid var(--ai-border);
  1127. border-radius: var(--ai-radius-sm);
  1128. }
  1129. }
  1130. </style>