index.vue 22 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712
  1. <template>
  2. <div class="ai-page ai-plugin">
  3. <a-tabs v-model:active-key="activeTab" class="ai-plugin__tabs" size="small" destroy-inactive-tab-pane>
  4. <template #rightExtra>
  5. <div class="ai-topbar__actions">
  6. <span class="ai-topbar__meta ai-plugin__summary">{{ summary }}</span>
  7. <a-button size="small" :loading="loading" @click="reload">
  8. <template #icon><Icon icon="mdi:refresh" :size="14" /></template>
  9. 刷新
  10. </a-button>
  11. </div>
  12. </template>
  13. <!-- ── ① 工具(只读,数据来自 agent_tool 表) ─────────────── -->
  14. <a-tab-pane key="tool">
  15. <template #tab>
  16. <span>工具</span>
  17. <span class="ai-plugin__tab-count">{{ tools.length }}</span>
  18. </template>
  19. <div class="ai-plugin__ops">
  20. <a-select
  21. v-model:value="groupFilter"
  22. class="ai-plugin__select"
  23. size="small"
  24. :options="groupOptions"
  25. placeholder="全部分类"
  26. />
  27. <a-input
  28. v-model:value="toolKeyword"
  29. class="ai-plugin__search"
  30. size="small"
  31. allow-clear
  32. placeholder="搜索工具名,如 stat_call_night"
  33. >
  34. <template #prefix><Icon icon="mdi:magnify" :size="14" /></template>
  35. </a-input>
  36. <span class="ai-plugin__mono ai-plugin__filter-count">
  37. {{
  38. filteredTools.length === tools.length
  39. ? `共 ${tools.length} 个`
  40. : `匹配 ${filteredTools.length} / ${tools.length} 个`
  41. }}
  42. </span>
  43. </div>
  44. <a-spin :spinning="loading">
  45. <div v-if="filteredTools.length" class="ai-plugin__grid">
  46. <article
  47. v-for="tool in filteredTools"
  48. :key="tool.toolName"
  49. class="ai-plugin__card is-clickable"
  50. :title="tool.toolName"
  51. @click="openTool(tool)"
  52. >
  53. <div class="ai-plugin__card-head">
  54. <span class="ai-plugin__mono ai-plugin__tool-name ai-plugin__ellipsis">{{ tool.toolName }}</span>
  55. <span v-if="tool.readOnly" class="ai-plugin__chip">只读</span>
  56. </div>
  57. <div class="ai-plugin__card-meta">
  58. <span class="ai-plugin__chip">{{ tool.groupLabel || tool.groupCode || '未分组' }}</span>
  59. <span v-if="tool.defaultActive" class="ai-plugin__chip is-success">默认装备</span>
  60. </div>
  61. <p class="ai-plugin__card-desc">{{ tool.description || '(无说明)' }}</p>
  62. </article>
  63. </div>
  64. <div v-else class="ai-plugin__empty">
  65. {{
  66. tools.length ? '没有匹配的工具' : '还没有同步到工具清单(服务端启动时会自动同步,需先建 agent_tool 表)'
  67. }}
  68. </div>
  69. </a-spin>
  70. </a-tab-pane>
  71. <!-- ── ② 技能 ──────────────────────────────────────────── -->
  72. <a-tab-pane key="skill">
  73. <template #tab>
  74. <span>技能</span>
  75. <span class="ai-plugin__tab-count">{{ skills.length }}</span>
  76. </template>
  77. <div class="ai-plugin__ops">
  78. <a-button size="small" type="primary" :disabled="installing" :loading="installing" @click="pickZip">
  79. <template #icon><Icon icon="mdi:upload" :size="14" /></template>
  80. 上传 zip 安装
  81. </a-button>
  82. <input ref="fileRef" class="ai-plugin__file" type="file" accept=".zip" @change="onZipPicked" />
  83. </div>
  84. <a-spin :spinning="loading">
  85. <div v-if="skills.length" class="ai-plugin__grid">
  86. <article v-for="skill in skills" :key="skill.path || skill.name" class="ai-plugin__card">
  87. <div class="ai-plugin__card-head">
  88. <div class="ai-plugin__name">
  89. <span class="ai-plugin__ellipsis" :title="skill.name">{{ skill.name }}</span>
  90. <span class="ai-plugin__chip" :class="sourceMeta(skill).cls">{{ sourceMeta(skill).label }}</span>
  91. </div>
  92. <a-switch
  93. v-if="skill.managed"
  94. :checked="skill.enabled !== false"
  95. size="small"
  96. @change="(checked) => onToggle(skill, checked === true)"
  97. />
  98. <span v-else class="ai-plugin__status is-off">只读</span>
  99. </div>
  100. <p class="ai-plugin__card-desc">{{ skill.description || '(SKILL.md 里没有 description)' }}</p>
  101. <div class="ai-plugin__sub ai-plugin__ellipsis" :title="skill.path">{{ skill.path }}</div>
  102. <div class="ai-plugin__card-ops">
  103. <a-button type="link" size="small" :loading="skillLoading === skill.name" @click="openSkill(skill)">
  104. 查看 SKILL.md
  105. </a-button>
  106. <a-button v-if="skill.managed" type="link" size="small" danger @click="confirmRemove(skill)">
  107. 卸载
  108. </a-button>
  109. </div>
  110. </article>
  111. </div>
  112. <div v-else class="ai-plugin__empty">
  113. 还没有技能。上传一个包含 <b>SKILL.md</b> 的 zip 即可安装(包里只有一层技能目录时最省事)。
  114. </div>
  115. </a-spin>
  116. </a-tab-pane>
  117. </a-tabs>
  118. <!-- 弹框:工具详情(说明 + 所属工具组说明) -->
  119. <a-modal
  120. v-model:open="toolOpen"
  121. :title="selectedTool?.toolName || '工具详情'"
  122. :width="760"
  123. :footer="null"
  124. destroy-on-close
  125. >
  126. <div v-if="selectedTool" class="ai-plugin__detail">
  127. <div class="ai-plugin__detail-tags">
  128. <span class="ai-plugin__chip">{{ selectedTool.groupLabel || selectedTool.groupCode || '未分组' }}</span>
  129. <span v-if="selectedTool.defaultActive" class="ai-plugin__chip is-success">默认装备</span>
  130. <span v-if="selectedTool.readOnly" class="ai-plugin__chip">只读</span>
  131. </div>
  132. <div class="ai-plugin__detail-block">
  133. <div class="ai-plugin__detail-label">说明</div>
  134. <p class="ai-plugin__detail-text">{{ selectedTool.description || '(无说明)' }}</p>
  135. </div>
  136. <div v-if="selectedTool.groupDescription" class="ai-plugin__detail-block">
  137. <div class="ai-plugin__detail-label">所属工具组说明(写给模型的激活时机)</div>
  138. <p class="ai-plugin__detail-text">{{ selectedTool.groupDescription }}</p>
  139. </div>
  140. </div>
  141. </a-modal>
  142. <!-- 抽屉:SKILL.md -->
  143. <a-drawer
  144. v-model:open="skillOpen"
  145. :title="skillDetail?.name || 'SKILL.md'"
  146. :width="720"
  147. placement="right"
  148. destroy-on-close
  149. >
  150. <div v-if="skillDetail" class="ai-plugin__preview">
  151. <div class="ai-plugin__preview-meta">
  152. <span class="ai-plugin__chip" :class="sourceMeta(skillDetail).cls">{{ sourceMeta(skillDetail).label }}</span>
  153. <span class="ai-plugin__mono ai-plugin__ellipsis" :title="skillDetail.path">{{ skillDetail.path }}</span>
  154. </div>
  155. <div v-if="skillDetail.files?.length" class="ai-plugin__preview-files">
  156. <span class="ai-plugin__mono">附属文件:</span>
  157. <span v-for="file in skillDetail.files" :key="file.path" class="ai-plugin__chip" :title="`${file.size} 字节`">
  158. {{ file.path }}
  159. </span>
  160. </div>
  161. <pre class="ai-plugin__pre">{{ skillDetail.content }}</pre>
  162. </div>
  163. </a-drawer>
  164. </div>
  165. </template>
  166. <script lang="ts" setup name="AiPluginIndex">
  167. /**
  168. * 插件与技能(服务端 agent 版)
  169. *
  170. * 两个页签全部来自服务端 `module/agent`:
  171. * ① 工具 —— 服务端启动时反射扫描 `@Tool` 后落库的 `agent_tool` 表(只读)。
  172. * 平铺展示全部工具,支持按分类筛选 + 工具名搜索,点行弹框看详情与参数。
  173. * ② 技能 —— 服务端本机用户级技能目录(`.agentscope/users/{用户}/skills`)的 SKILL.md 包,
  174. * 支持上传 zip 安装 / 卸载 / 启停
  175. *
  176. * 旧版这里走的是桌面端 Codex 的 IPC(codexApi),Codex 已废弃,故整页重写为服务端驱动;
  177. * 也因此浏览器环境同样可用(不再需要「仅桌面客户端可用」的降级提示)。
  178. * 原「MCP 接入」页签已按需求移除(服务端 MCP Server 能力本身保留,仅不再在页面展示)。
  179. */
  180. import { computed, onMounted, ref } from 'vue';
  181. import { Button, Drawer, Input, Modal, Select, Spin, Switch, Tabs } from 'ant-design-vue';
  182. import Icon from '@/core/components/Icon/src/Icon.vue';
  183. import { useMessage } from '@/core/hooks/web/useMessage';
  184. import { useUserStore } from '@/core/store/modules/case';
  185. import {
  186. deleteSkill,
  187. installSkillZip,
  188. listSkills,
  189. listTools,
  190. readSkill,
  191. toggleSkill,
  192. } from '../../api/capabilityApi';
  193. import type { AgentTool, Skill, SkillDetail } from '../../api/types';
  194. const ATabs = Tabs;
  195. const ATabPane = Tabs.TabPane;
  196. const AButton = Button;
  197. const ADrawer = Drawer;
  198. const AInput = Input;
  199. const AModal = Modal;
  200. const ASelect = Select;
  201. const ASpin = Spin;
  202. const ASwitch = Switch;
  203. const { showMessage } = useMessage();
  204. const userStore = useUserStore();
  205. type PluginTab = 'tool' | 'skill';
  206. const activeTab = ref<PluginTab>('tool');
  207. const loading = ref(false);
  208. const installing = ref(false);
  209. /** agent_tool 表的扁平行(只读) */
  210. const tools = ref<AgentTool[]>([]);
  211. const skills = ref<Skill[]>([]);
  212. const toolOpen = ref(false);
  213. const selectedTool = ref<AgentTool | null>(null);
  214. const toolKeyword = ref('');
  215. /** 分类筛选(空 = 全部分类) */
  216. const groupFilter = ref('');
  217. const skillOpen = ref(false);
  218. const skillDetail = ref<SkillDetail | null>(null);
  219. const skillLoading = ref('');
  220. const fileRef = ref<HTMLInputElement>();
  221. /** 当前案件ID:只用于附带展示「该案件 agent 配置的只读技能仓库」,技能本身与案件无关 */
  222. const caseId = computed(() => (userStore.getCaseInfo?.id ? String(userStore.getCaseInfo.id) : null));
  223. const summary = computed(() => `${tools.value.length} 个工具 · ${skills.value.length} 个技能`);
  224. /**
  225. * 分类下拉:按表里的 groupSort 排(业务组按注册顺序,基础工具最后),选项文案用组中文名。
  226. * 页面不再把工具按组折叠展示,分类只用于筛选。
  227. */
  228. const groupOptions = computed(() => {
  229. const byCode = new Map<string, { code: string; label: string; sort: number; count: number }>();
  230. for (const tool of tools.value) {
  231. const code = tool.groupCode || 'base';
  232. const hit = byCode.get(code);
  233. if (hit) {
  234. hit.count++;
  235. } else {
  236. byCode.set(code, {
  237. code,
  238. label: tool.groupLabel || code,
  239. sort: tool.groupSort ?? Number.MAX_SAFE_INTEGER,
  240. count: 1,
  241. });
  242. }
  243. }
  244. const options = [...byCode.values()].sort((a, b) => a.sort - b.sort || a.code.localeCompare(b.code));
  245. return [
  246. { value: '', label: `全部分类(${tools.value.length})` },
  247. ...options.map((g) => ({ value: g.code, label: `${g.label}(${g.count})` })),
  248. ];
  249. });
  250. /** 列表筛选:分类 + 工具名关键字(工具名搜索,与需求一致) */
  251. const filteredTools = computed(() => {
  252. const keyword = toolKeyword.value.trim().toLowerCase();
  253. return tools.value.filter((tool) => {
  254. if (groupFilter.value && (tool.groupCode || 'base') !== groupFilter.value) return false;
  255. if (!keyword) return true;
  256. return tool.toolName.toLowerCase().includes(keyword);
  257. });
  258. });
  259. onMounted(reload);
  260. async function reload() {
  261. loading.value = true;
  262. try {
  263. const [toolList, skillList] = await Promise.all([
  264. listTools().catch(() => [] as AgentTool[]),
  265. listSkills(caseId.value).catch(() => [] as Skill[]),
  266. ]);
  267. tools.value = toolList || [];
  268. skills.value = skillList || [];
  269. } finally {
  270. loading.value = false;
  271. }
  272. }
  273. /* ---------------- 工具 ---------------- */
  274. function openTool(tool: AgentTool) {
  275. selectedTool.value = tool;
  276. toolOpen.value = true;
  277. }
  278. /** 全限定类名 → 短名(抽屉里展示「类#方法」,太长会把行挤爆) */
  279. function shortClass(className?: string) {
  280. if (!className) return '';
  281. const idx = className.lastIndexOf('.');
  282. return idx >= 0 ? className.slice(idx + 1) : className;
  283. }
  284. /* ---------------- 技能 ---------------- */
  285. function sourceMeta(skill: Skill) {
  286. return skill.source === 'repo'
  287. ? { label: '配置仓库', cls: '' }
  288. : { label: skill.managed ? '用户技能' : '技能', cls: '' };
  289. }
  290. function pickZip() {
  291. fileRef.value?.click();
  292. }
  293. async function onZipPicked(event: Event) {
  294. const input = event.target as HTMLInputElement;
  295. const file = input.files?.[0];
  296. // 允许连续上传同一个文件:先清空,再交给后续流程
  297. input.value = '';
  298. if (!file) return;
  299. if (!file.name.toLowerCase().endsWith('.zip')) {
  300. showMessage('请选择 .zip 技能包', 'warning');
  301. return;
  302. }
  303. installing.value = true;
  304. try {
  305. const skill = await installSkillZip(file, false);
  306. showMessage(`技能「${skill?.name || file.name}」已安装`, 'success');
  307. await reload();
  308. } catch {
  309. // 失败原因(同名已存在 / 没有 SKILL.md / 包内路径非法…)由 defHttp 统一弹出后端 message
  310. } finally {
  311. installing.value = false;
  312. }
  313. }
  314. async function onToggle(skill: Skill, enabled: boolean) {
  315. try {
  316. await toggleSkill(skill.name, enabled);
  317. skill.enabled = enabled;
  318. showMessage(enabled ? `已启用「${skill.name}」` : `已停用「${skill.name}」,下一轮对话生效`, 'success');
  319. } catch {
  320. // 失败不改本地状态:开关会因 :checked 绑定而回弹
  321. }
  322. }
  323. function confirmRemove(skill: Skill) {
  324. Modal.confirm({
  325. title: '卸载技能',
  326. content: `确认删除技能「${skill.name}」?该技能目录会被删除,且不可恢复。`,
  327. okText: '卸载',
  328. okType: 'primary',
  329. okButtonProps: { danger: true },
  330. cancelText: '取消',
  331. onOk: async () => {
  332. await deleteSkill(skill.name);
  333. showMessage('已卸载', 'success');
  334. await reload();
  335. },
  336. });
  337. }
  338. async function openSkill(skill: Skill) {
  339. skillLoading.value = skill.name;
  340. try {
  341. skillDetail.value = await readSkill(skill.name, caseId.value);
  342. skillOpen.value = true;
  343. } catch {
  344. // 读取失败原因由 defHttp 弹出
  345. } finally {
  346. skillLoading.value = '';
  347. }
  348. }
  349. </script>
  350. <style lang="less" scoped>
  351. .ai-plugin {
  352. /* Tabs 撑起整页骨架:页签栏即顶部条,内容区自己滚动 */
  353. &__tabs {
  354. flex: 1 1 auto;
  355. min-height: 0;
  356. :deep(.ant-tabs-nav) {
  357. flex: 0 0 auto;
  358. margin: 0;
  359. padding: 0 var(--ai-sp-2);
  360. background: var(--ai-bg);
  361. border-bottom: 1px solid var(--ai-border);
  362. /* antd 自带的发丝线与顶部条边框重叠 */
  363. &::before {
  364. display: none;
  365. }
  366. }
  367. /* 靠页签内边距撑出顶部条高度(--ai-header-h),下划线才能压在分隔线上 */
  368. :deep(.ant-tabs-tab) {
  369. padding: 15px 0;
  370. font-size: 13.5px;
  371. }
  372. :deep(.ant-tabs-extra-content) {
  373. margin-left: var(--ai-sp-3);
  374. }
  375. /* 内容区内边距取分析页那一档(8px),左右与页签栏文字对齐 */
  376. :deep(.ant-tabs-content-holder) {
  377. flex: 1 1 auto;
  378. min-height: 0;
  379. padding: var(--ai-sp-2);
  380. overflow-y: auto;
  381. overscroll-behavior: contain;
  382. }
  383. }
  384. /* 摘要与页签同在一行,过长时限宽省略,避免把页签挤进溢出下拉 */
  385. &__summary {
  386. max-width: 360px;
  387. overflow: hidden;
  388. white-space: nowrap;
  389. text-overflow: ellipsis;
  390. }
  391. &__tab-count {
  392. display: inline-block;
  393. min-width: 18px;
  394. margin-left: var(--ai-sp-2);
  395. padding: 0 5px;
  396. font-size: 11px;
  397. line-height: 17px;
  398. text-align: center;
  399. color: var(--ai-text-2);
  400. background: var(--ai-bg-subtle);
  401. border: 1px solid var(--ai-border);
  402. border-radius: 999px;
  403. }
  404. &__ops {
  405. display: flex;
  406. gap: var(--ai-sp-2);
  407. margin-bottom: var(--ai-sp-2);
  408. }
  409. /* 上传入口用按钮触发,原生 input 只做文件选择 */
  410. &__file {
  411. display: none;
  412. }
  413. /* 卡片网格:间距取分析页那一档(8px);圆角比页面外壳(12px)低一档,用 --ai-radius(8px) */
  414. &__grid {
  415. display: grid;
  416. grid-template-columns: repeat(auto-fill, minmax(320px, 1fr));
  417. gap: var(--ai-sp-2);
  418. }
  419. &__card {
  420. display: flex;
  421. flex-direction: column;
  422. gap: var(--ai-sp-2);
  423. min-width: 0;
  424. padding: var(--ai-sp-3);
  425. background: var(--ai-bg);
  426. border: 1px solid var(--ai-border);
  427. border-radius: var(--ai-radius);
  428. transition:
  429. border-color 0.2s ease,
  430. box-shadow 0.2s ease,
  431. transform 0.2s ease;
  432. &:hover {
  433. border-color: var(--ai-border-strong);
  434. box-shadow: var(--ai-shadow);
  435. transform: translateY(-2px);
  436. }
  437. }
  438. &__card-head {
  439. display: flex;
  440. align-items: center;
  441. justify-content: space-between;
  442. gap: var(--ai-sp-3);
  443. min-width: 0;
  444. }
  445. &__card-desc {
  446. display: -webkit-box;
  447. -webkit-box-orient: vertical;
  448. -webkit-line-clamp: 3;
  449. min-height: calc(12.5px * 1.7 * 2);
  450. margin: 0;
  451. overflow: hidden;
  452. font-size: 12.5px;
  453. line-height: 1.7;
  454. color: var(--ai-text-2);
  455. }
  456. &__card-tools {
  457. display: flex;
  458. flex-wrap: wrap;
  459. gap: var(--ai-sp-1);
  460. align-items: center;
  461. min-width: 0;
  462. }
  463. /* 字号/字体沿用 __mono,这里只管盒子 */
  464. &__tool {
  465. max-width: 148px;
  466. padding: 0 6px;
  467. line-height: 18px;
  468. background-color: var(--ai-bg-subtle);
  469. border: 1px solid var(--ai-border);
  470. border-radius: var(--ai-radius-sm);
  471. }
  472. &__card-ops {
  473. display: flex;
  474. align-items: center;
  475. gap: var(--ai-sp-1);
  476. margin-top: auto;
  477. padding-top: var(--ai-sp-2);
  478. border-top: 1px solid var(--ai-border);
  479. }
  480. &__empty {
  481. padding: var(--ai-sp-4) var(--ai-sp-3);
  482. font-size: 12.5px;
  483. line-height: 1.7;
  484. text-align: center;
  485. color: var(--ai-text-2);
  486. background: var(--ai-bg-subtle);
  487. border: 1px dashed var(--ai-border);
  488. border-radius: var(--ai-radius);
  489. b {
  490. color: var(--ai-text);
  491. }
  492. }
  493. /* 卡片头里它是 flex item,不给 min-width:0 就会把右侧开关挤出去 */
  494. &__name {
  495. display: flex;
  496. align-items: center;
  497. gap: var(--ai-sp-2);
  498. min-width: 0;
  499. font-size: 13.5px;
  500. font-weight: 600;
  501. color: var(--ai-text);
  502. }
  503. &__sub {
  504. margin-top: 2px;
  505. overflow: hidden;
  506. font-family: 'SFMono-Regular', Consolas, monospace;
  507. font-size: 11.5px;
  508. color: var(--ai-text-2);
  509. white-space: nowrap;
  510. text-overflow: ellipsis;
  511. }
  512. &__chip {
  513. display: inline-block;
  514. flex: 0 0 auto;
  515. max-width: 220px;
  516. padding: 1px 8px;
  517. overflow: hidden;
  518. font-size: 12px;
  519. line-height: 20px;
  520. white-space: nowrap;
  521. color: var(--ai-text);
  522. text-overflow: ellipsis;
  523. background: var(--ai-bg-subtle);
  524. border: 1px solid var(--ai-border);
  525. border-radius: 999px;
  526. &.is-success {
  527. color: @success-color;
  528. background-color: fade(@success-color, 10%);
  529. border-color: fade(@success-color, 26%);
  530. }
  531. }
  532. &__mono {
  533. font-family: 'SFMono-Regular', Consolas, monospace;
  534. font-size: 12px;
  535. color: var(--ai-text-2);
  536. }
  537. &__ellipsis {
  538. display: inline-block;
  539. max-width: 100%;
  540. overflow: hidden;
  541. white-space: nowrap;
  542. text-overflow: ellipsis;
  543. }
  544. &__status {
  545. font-size: 12px;
  546. color: var(--ai-text-2);
  547. }
  548. /* 筛选栏:分类下拉 + 工具名搜索 + 计数 */
  549. &__select {
  550. min-width: 180px;
  551. }
  552. &__search {
  553. max-width: 300px;
  554. }
  555. &__filter-count {
  556. margin-left: auto;
  557. white-space: nowrap;
  558. }
  559. /* 工具卡片整卡可点开详情:手型 + 轻微上浮(与技能卡片的 hover 一致) */
  560. &__card.is-clickable {
  561. cursor: pointer;
  562. }
  563. &__tool-name {
  564. min-width: 0;
  565. font-size: 12.5px;
  566. font-weight: 600;
  567. color: var(--ai-text);
  568. }
  569. /* 参数表里的「必填」列:是=红,否=灰 */
  570. &__req {
  571. color: @error-color;
  572. }
  573. /* 工具详情弹框 */
  574. &__detail {
  575. display: flex;
  576. flex-direction: column;
  577. gap: var(--ai-sp-3);
  578. }
  579. &__detail-tags {
  580. display: flex;
  581. flex-wrap: wrap;
  582. align-items: center;
  583. gap: var(--ai-sp-2);
  584. }
  585. &__detail-label {
  586. margin-bottom: 4px;
  587. font-size: 11.5px;
  588. color: var(--ai-text-2);
  589. }
  590. &__detail-text {
  591. margin: 0;
  592. font-size: 12.5px;
  593. line-height: 1.8;
  594. color: var(--ai-text);
  595. white-space: pre-wrap;
  596. word-break: break-word;
  597. }
  598. &__preview {
  599. display: flex;
  600. flex-direction: column;
  601. gap: var(--ai-sp-2);
  602. min-height: 0;
  603. }
  604. &__preview-meta {
  605. display: flex;
  606. align-items: center;
  607. gap: var(--ai-sp-2);
  608. min-width: 0;
  609. }
  610. &__preview-files {
  611. display: flex;
  612. flex-wrap: wrap;
  613. gap: var(--ai-sp-1);
  614. align-items: center;
  615. min-width: 0;
  616. }
  617. &__pre {
  618. flex: 1 1 auto;
  619. margin: 0;
  620. padding: var(--ai-sp-3);
  621. overflow: auto;
  622. font-family: 'SFMono-Regular', Consolas, monospace;
  623. font-size: 12.5px;
  624. line-height: 1.8;
  625. color: var(--ai-text);
  626. white-space: pre-wrap;
  627. word-break: break-word;
  628. background: var(--ai-bg-subtle);
  629. border: 1px solid var(--ai-border);
  630. border-radius: var(--ai-radius);
  631. }
  632. }
  633. </style>