cc 2 weeks ago
parent
commit
a6a1a07d3c

+ 22 - 1
ai-electron/electron/service/codex/codexRuntime.test.ts

@@ -1,5 +1,5 @@
 import { describe, expect, it } from 'vitest';
-import { buildArgs, DEFAULT_API_KEY_ENV, type CodexProviderSpec } from './codexRuntime';
+import { buildArgs, DEFAULT_API_KEY_ENV, toToolSummaries, type CodexProviderSpec } from './codexRuntime';
 
 /** 这组断言锁的是「喂给 codex.exe 的 -c 参数」——provider 能不能生效全在这里 */
 
@@ -97,3 +97,24 @@ describe('buildArgs', () => {
     expect(joined).toContain('model_providers.zsjz.http_headers={"X Token"="a"}');
   });
 });
+
+/** mcpServerStatus/list 的 tools 详情结构不由我们决定,这里锁住退化行为 */
+describe('toToolSummaries', () => {
+  it('详情里带 name / description 时优先取详情', () => {
+    expect(toToolSummaries({ query_case: { name: 'query_case_v2', description: '按条件查案件' } })).toEqual([
+      { name: 'query_case_v2', description: '按条件查案件' },
+    ]);
+  });
+
+  it('详情形态不认识时退化成用 key 当工具名,描述留空', () => {
+    expect(toToolSummaries({ list_tables: { inputSchema: {} }, ping: null })).toEqual([
+      { name: 'list_tables', description: '' },
+      { name: 'ping', description: '' },
+    ]);
+  });
+
+  it('tools 缺失时返回空列表', () => {
+    expect(toToolSummaries(undefined)).toEqual([]);
+    expect(toToolSummaries(null)).toEqual([]);
+  });
+});

+ 18 - 0
ai-electron/electron/service/codex/codexRuntime.ts

@@ -48,6 +48,7 @@ export interface CodexMcpServerStatus {
   authStatus: string;
   connected: boolean;
   toolCount: number;
+  tools: Array<{ name: string; description: string }>;
 }
 
 export interface CodexSkillSummary {
@@ -352,6 +353,7 @@ export class CodexRuntime extends EventEmitter {
           authStatus: server.authStatus,
           connected: server.serverInfo !== null,
           toolCount: Object.keys(server.tools ?? {}).length,
+          tools: toToolSummaries(server.tools),
         })),
       );
       cursor = result.nextCursor;
@@ -784,6 +786,22 @@ function readString(value: unknown): string | null {
   return typeof value === 'string' ? value : null;
 }
 
+/**
+ * mcpServerStatus/list 的 tools 是「工具名 → 详情」映射,详情结构由 Codex 版本决定。
+ * 这里只认 name / description,认不出的形态退化成只留 key 名,避免协议一变列表就空掉。
+ */
+export function toToolSummaries(
+  tools: Record<string, unknown> | null | undefined,
+): Array<{ name: string; description: string }> {
+  return Object.entries(tools ?? {}).map(([key, value]) => {
+    const detail = asRecord(value);
+    return {
+      name: readString(detail?.name) || key,
+      description: readString(detail?.description) || '',
+    };
+  });
+}
+
 function asError(value: unknown): Error {
   return value instanceof Error ? value : new Error(String(value));
 }

+ 27 - 4
ai-electron/electron/service/codex/mcpService.test.ts

@@ -3,7 +3,15 @@ import { McpService, type McpServerInput } from './mcpService';
 import type { JsonValue } from './codexRuntime';
 
 /** 内存版 runtime:只实现 McpService 用到的四个方法 */
-function makeRuntime(statuses: Array<{ name: string; connected: boolean; toolCount: number; authStatus: string }> = []) {
+function makeRuntime(
+  statuses: Array<{
+    name: string;
+    connected: boolean;
+    toolCount: number;
+    authStatus: string;
+    tools?: Array<{ name: string; description: string }>;
+  }> = [],
+) {
   const servers: Record<string, JsonValue> = {};
   return {
     servers,
@@ -14,7 +22,8 @@ function makeRuntime(statuses: Array<{ name: string; connected: boolean; toolCou
       else servers[id] = value;
     }),
     reloadMcpServers: vi.fn(async () => undefined),
-    listMcpServerStatuses: vi.fn(async () => statuses),
+    // 调用方只写关心的字段,tools 缺省补空数组以对齐 CodexMcpServerStatus
+    listMcpServerStatuses: vi.fn(async () => statuses.map((item) => ({ ...item, tools: item.tools ?? [] }))),
   };
 }
 
@@ -139,18 +148,32 @@ describe('list', () => {
       enabled: true,
       connected: false,
       toolCount: 0,
+      tools: [],
       authStatus: 'unknown',
     });
   });
 
   it('合并 app-server 返回的连接状态', async () => {
     const statusRuntime = makeRuntime([
-      { name: 'demo-tools', connected: true, toolCount: 7, authStatus: 'authorized' },
+      {
+        name: 'demo-tools',
+        connected: true,
+        toolCount: 2,
+        authStatus: 'authorized',
+        tools: [
+          { name: 'query_case', description: '按条件查询案件数据' },
+          { name: 'list_tables', description: '' },
+        ],
+      },
     ]);
     statusRuntime.servers['demo-tools'] = { enabled: true, command: 'npx', args: [] };
 
     const items = await new McpService(statusRuntime).list();
-    expect(items[0]).toMatchObject({ connected: true, toolCount: 7, authStatus: 'authorized' });
+    expect(items[0]).toMatchObject({ connected: true, toolCount: 2, authStatus: 'authorized' });
+    expect(items[0].tools).toEqual([
+      { name: 'query_case', description: '按条件查询案件数据' },
+      { name: 'list_tables', description: '' },
+    ]);
   });
 
   it('listMcpServerStatuses 失败时仍能列出配置', async () => {

+ 3 - 0
ai-electron/electron/service/codex/mcpService.ts

@@ -35,6 +35,8 @@ export interface McpServerSetting {
   bearerTokenEnvVar: string | null;
   connected: boolean;
   toolCount: number;
+  /** 连上时由 Codex mcpServerStatus/list 带出的工具清单;未连接为空数组 */
+  tools: Array<{ name: string; description: string }>;
   authStatus: string;
 }
 
@@ -197,6 +199,7 @@ function parseStoredServer(
     bearerTokenEnvVar: readString(record.bearer_token_env_var),
     connected: status?.connected ?? false,
     toolCount: status?.toolCount ?? 0,
+    tools: status?.tools ?? [],
     authStatus: status?.authStatus ?? 'unknown',
   };
 }

+ 2 - 0
ai-electron/frontend/src/ai/api/codexApi.ts

@@ -197,6 +197,8 @@ export interface CodexMcpServer {
   bearerTokenEnvVar: string | null;
   connected: boolean;
   toolCount: number;
+  /** 已连接时 Codex 返回的工具清单;未连接为空数组 */
+  tools: Array<{ name: string; description: string }>;
   authStatus: string;
 }
 

+ 3 - 2
ai-electron/frontend/src/ai/styles/ai.less

@@ -237,9 +237,10 @@
   background: var(--ai-bg);
 }
 
-/* 细滚动条 */
+/* 细滚动条:页签栏即顶部条的页面由 Tabs 内容区自己滚动,故一并收细 */
 .ai-scroll,
-.ai-scroll-area {
+.ai-scroll-area,
+.ai-page .ant-tabs-content-holder {
   &::-webkit-scrollbar {
     width: 8px;
     height: 8px;

+ 9 - 13
ai-electron/frontend/src/ai/views/aiAnalysis/index.vue

@@ -121,7 +121,7 @@
    * 与后端 AgentChatController 的 workspaceId 入参一致。
    */
   import { computed, nextTick, onMounted, ref, watch } from 'vue';
-  import { Button, Tooltip, Modal } from 'ant-design-vue';
+  import { Button, Tooltip } from 'ant-design-vue';
   import Icon from '@/core/components/Icon/src/Icon.vue';
   import { useUserStore } from '@/core/store/modules/case';
   import { useMessage } from '@/core/hooks/web/useMessage';
@@ -240,18 +240,14 @@
     }
   }
 
-  function onRemove(id: string) {
-    Modal.confirm({
-      title: '删除会话',
-      content: '删除后该会话的消息记录将一并移除,且不可恢复。确认删除?',
-      okText: '删除',
-      okType: 'danger',
-      cancelText: '取消',
-      onOk: async () => {
-        await store.removeSession(id);
-        showMessage('已删除', 'success');
-      },
-    });
+  /** 确认框在 SessionList 的删除图标上(那里能带上会话标题),这里只负责执行 */
+  async function onRemove(id: string) {
+    try {
+      await store.removeSession(id);
+      showMessage('已删除', 'success');
+    } catch (e: any) {
+      showMessage(e?.message || '删除失败', 'error');
+    }
   }
 
   async function onRemoveMessage(id: string) {

+ 177 - 130
ai-electron/frontend/src/ai/views/aiModel/index.vue

@@ -1,108 +1,79 @@
 <template>
   <div class="ai-page ai-model">
-    <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" @click="load">
-          <template #icon><Icon icon="mdi:refresh" :size="14" /></template>
-          刷新
-        </a-button>
-        <a-button size="small" type="primary" @click="openCreate">
-          <template #icon><Icon icon="mdi:plus" :size="14" /></template>
-          新增{{ activeMeta.label }}
-        </a-button>
-      </div>
-    </header>
-
-    <div class="ai-scroll ai-model__body">
-      <div class="ai-model__inner">
-        <a-tabs v-model:active-key="activeTab" class="ai-model__tabs" size="small" destroy-inactive-tab-pane>
-          <a-tab-pane v-for="tab in TABS" :key="tab.key">
-            <template #tab>
-              <span>{{ tab.label }}</span>
-              <span class="ai-model__tab-count">{{ listOf(tab.key).length }}</span>
-            </template>
-
-            <div class="ai-model__hint">{{ tab.hint }}</div>
-
-            <a-table
-              class="ai-model__table"
-              size="middle"
-              :columns="columns"
-              :data-source="listOf(tab.key)"
-              :loading="loading"
-              :pagination="false"
-              row-key="id"
-            >
-              <template #bodyCell="{ column, record }">
-                <template v-if="column.key === 'name'">
-                  <div class="ai-model__name">
-                    <span class="ai-model__name-text">{{ record.name || record.modelId }}</span>
-                    <span v-if="record.defaultModel" class="ai-model__badge">
-                      默认{{ tab.key === 'embedding' ? '向量' : '对话' }}
-                    </span>
-                  </div>
-                  <div class="ai-model__sub">{{ record.modelId || '—' }}</div>
-                </template>
-
-                <template v-else-if="column.key === 'provider'">
-                  <span class="ai-model__chip">{{ record.provider || '—' }}</span>
-                </template>
-
-                <template v-else-if="column.key === 'apiKey'">
-                  <span class="ai-model__mono">{{ maskKey(record.apiKey) }}</span>
-                </template>
-
-                <template v-else-if="column.key === 'baseUrl'">
-                  <span class="ai-model__mono ai-model__ellipsis" :title="record.baseUrl">
-                    {{ record.baseUrl || '—' }}
+    <!-- 页签栏即顶部条:页签居左,摘要与操作经 rightExtra 落在同一行右侧 -->
+    <a-tabs v-model:active-key="activeTab" class="ai-model__tabs" size="small" destroy-inactive-tab-pane>
+      <template #rightExtra>
+        <div class="ai-topbar__actions">
+          <span class="ai-topbar__meta ai-model__summary">{{ summary }}</span>
+          <a-button size="small" :loading="loading" @click="load">
+            <template #icon><Icon icon="mdi:refresh" :size="14" /></template>
+            刷新
+          </a-button>
+          <a-button size="small" type="primary" @click="openCreate">
+            <template #icon><Icon icon="mdi:plus" :size="14" /></template>
+            新增{{ activeMeta.label }}
+          </a-button>
+        </div>
+      </template>
+
+      <a-tab-pane v-for="tab in TABS" :key="tab.key">
+        <template #tab>
+          <span>{{ tab.label }}</span>
+          <span class="ai-model__tab-count">{{ listOf(tab.key).length }}</span>
+        </template>
+
+        <div class="ai-model__hint">{{ tab.hint }}</div>
+
+        <a-spin :spinning="loading">
+          <div v-if="listOf(tab.key).length" class="ai-model__grid">
+            <article v-for="record in listOf(tab.key)" :key="record.id" class="ai-model__card">
+              <div class="ai-model__card-head">
+                <div class="ai-model__name">
+                  <span class="ai-model__name-text ai-model__ellipsis" :title="record.name || record.modelId">
+                    {{ record.name || record.modelId }}
                   </span>
-                </template>
-
-                <template v-else-if="column.key === 'status'">
-                  <span class="ai-model__status" :class="statusMeta(record.status).cls">
-                    {{ statusMeta(record.status).label }}
+                  <span v-if="record.defaultModel" class="ai-model__badge">
+                    默认{{ tab.key === 'embedding' ? '向量' : '对话' }}
                   </span>
-                </template>
-
-                <template v-else-if="column.key === 'updatedAt'">
-                  <span class="ai-model__mono">{{ record.updatedAt || record.createdAt || '—' }}</span>
-                </template>
-
-                <template v-else-if="column.key === 'action'">
-                  <div class="ai-model__ops">
-                    <a-button type="link" size="small" @click="openEdit(record)">编辑</a-button>
-                    <a-button
-                      type="link"
-                      size="small"
-                      :disabled="record.defaultModel"
-                      @click="onSetDefault(record, tab)"
-                    >
-                      {{ record.defaultModel ? '已是默认' : '设为默认' }}
-                    </a-button>
-                    <a-button type="link" size="small" danger @click="onDelete(record)">删除</a-button>
-                  </div>
-                </template>
-              </template>
-
-              <template #emptyText>
-                <div class="ai-empty-tip">
-                  {{
-                    tab.key === 'embedding'
-                      ? '还没有向量模型。向量模型用于表结构语义检索,点击右上角「新增向量模型」添加。'
-                      : '还没有对话模型。点击右上角「新增对话模型」添加一个,AI 对话才能开始工作。'
-                  }}
                 </div>
-              </template>
-            </a-table>
-          </a-tab-pane>
-        </a-tabs>
-      </div>
-    </div>
+                <span class="ai-model__status" :class="statusMeta(record.status).cls">
+                  {{ statusMeta(record.status).label }}
+                </span>
+              </div>
+
+              <div class="ai-model__card-meta">
+                <span class="ai-model__chip">{{ record.provider || '—' }}</span>
+                <span class="ai-model__mono">{{ record.modelId || '—' }}</span>
+              </div>
+
+              <div class="ai-model__card-meta">
+                <span class="ai-model__mono">{{ maskKey(record.apiKey) }}</span>
+                <span class="ai-model__mono ai-model__ellipsis" :title="record.baseUrl">
+                  {{ record.baseUrl || '—' }}
+                </span>
+              </div>
+
+              <div class="ai-model__sub">更新:{{ record.updatedAt || record.createdAt || '—' }}</div>
+
+              <div class="ai-model__card-ops">
+                <a-button type="link" size="small" @click="openEdit(record)">编辑</a-button>
+                <a-button type="link" size="small" :disabled="record.defaultModel" @click="onSetDefault(record, tab)">
+                  {{ record.defaultModel ? '已是默认' : '设为默认' }}
+                </a-button>
+                <a-button type="link" size="small" danger @click="onDelete(record)">删除</a-button>
+              </div>
+            </article>
+          </div>
+          <div v-else-if="!loading" class="ai-model__empty">
+            {{
+              tab.key === 'embedding'
+                ? '还没有向量模型。向量模型用于表结构语义检索,点击右上角「新增向量模型」添加。'
+                : '还没有对话模型。点击右上角「新增对话模型」添加一个,AI 对话才能开始工作。'
+            }}
+          </div>
+        </a-spin>
+      </a-tab-pane>
+    </a-tabs>
 
     <ModelFormModal
       v-model:open="formOpen"
@@ -124,8 +95,7 @@
    * 模型数量为个位数级,故不做服务端分页,列表一次拉全量。
    */
   import { computed, onMounted, ref } from 'vue';
-  import { Button, Table, Tabs, Modal } from 'ant-design-vue';
-  import type { TableColumnsType } from 'ant-design-vue';
+  import { Button, Modal, Spin, Tabs } from 'ant-design-vue';
   import Icon from '@/core/components/Icon/src/Icon.vue';
   import { useMessage } from '@/core/hooks/web/useMessage';
   import * as modelApi from '../../api/modelApi';
@@ -133,7 +103,7 @@
   import ModelFormModal from '../../components/ModelFormModal.vue';
 
   const AButton = Button;
-  const ATable = Table;
+  const ASpin = Spin;
   const ATabs = Tabs;
   const ATabPane = Tabs.TabPane;
 
@@ -192,16 +162,6 @@
     return parts.join(' · ');
   });
 
-  const columns: TableColumnsType = [
-    { title: '模型', key: 'name', width: 240 },
-    { title: '厂商', key: 'provider', width: 110 },
-    { title: 'API Key', key: 'apiKey', width: 150 },
-    { title: 'Base URL', key: 'baseUrl', ellipsis: true },
-    { title: '状态', key: 'status', width: 80 },
-    { title: '更新时间', key: 'updatedAt', width: 170 },
-    { title: '操作', key: 'action', width: 210, fixed: 'right' },
-  ];
-
   /**
    * 状态展示:后端 StatusEnum 为 ACTIVE / SUSPENDED / ARCHIVED / DISABLED / DELETED
    * (DELETED 已被列表接口过滤,不会出现)
@@ -243,9 +203,8 @@
     formOpen.value = true;
   }
 
-  /** a-table 插槽的 record 为宽松类型,这里统一在入参处放宽、内部断言 */
-  function openEdit(record: any) {
-    editing.value = record as AiModel;
+  function openEdit(record: AiModel) {
+    editing.value = record;
     formOpen.value = true;
   }
 
@@ -295,27 +254,49 @@
 
 <style lang="less" scoped>
   .ai-model {
-    &__body {
+    /* Tabs 撑起整页骨架:页签栏即顶部条,内容区自己滚动 */
+    &__tabs {
       flex: 1 1 auto;
       min-height: 0;
-      overflow-y: auto;
-    }
 
-    &__inner {
-      max-width: 1280px;
-      margin: 0 auto;
-      padding: var(--ai-sp-2) var(--ai-sp-5) var(--ai-sp-5);
-    }
-
-    /* Tab 头与顶部条衔接:去掉 antd 默认下边距,下划线贯穿内容宽度 */
-    &__tabs {
       :deep(.ant-tabs-nav) {
-        margin-bottom: var(--ai-sp-4);
+        flex: 0 0 auto;
+        margin: 0;
+        padding: 0 var(--ai-sp-4);
+        background: var(--ai-bg);
+        border-bottom: 1px solid var(--ai-border);
+
+        /* antd 自带的发丝线与顶部条边框重叠 */
+        &::before {
+          display: none;
+        }
       }
 
+      /* 靠页签内边距撑出顶部条高度(--ai-header-h),下划线才能压在分隔线上 */
       :deep(.ant-tabs-tab) {
+        padding: 15px 0;
         font-size: 13.5px;
       }
+
+      :deep(.ant-tabs-extra-content) {
+        margin-left: var(--ai-sp-4);
+      }
+
+      :deep(.ant-tabs-content-holder) {
+        flex: 1 1 auto;
+        min-height: 0;
+        padding: var(--ai-sp-4) var(--ai-sp-5) var(--ai-sp-5);
+        overflow-y: auto;
+        overscroll-behavior: contain;
+      }
+    }
+
+    /* 摘要与页签同在一行,过长时限宽省略,避免把页签挤进溢出下拉 */
+    &__summary {
+      max-width: 360px;
+      overflow: hidden;
+      white-space: nowrap;
+      text-overflow: ellipsis;
     }
 
     &__tab-count {
@@ -347,14 +328,71 @@
       }
     }
 
-    &__table {
+    /* 卡片网格:与插件页 / 案件管理页同一套语言,配色圆角全取 ai.less 的 token */
+    &__grid {
+      display: grid;
+      grid-template-columns: repeat(auto-fill, minmax(320px, 1fr));
+      gap: var(--ai-sp-4);
+    }
+
+    &__card {
+      display: flex;
+      flex-direction: column;
+      gap: var(--ai-sp-2);
+      min-width: 0;
+      padding: var(--ai-sp-4);
       background: var(--ai-bg);
+      border: 1px solid var(--ai-border);
+      border-radius: var(--ai-radius-lg);
+      transition:
+        border-color 0.2s ease,
+        box-shadow 0.2s ease,
+        transform 0.2s ease;
+
+      &:hover {
+        border-color: var(--ai-border-strong);
+        box-shadow: var(--ai-shadow);
+        transform: translateY(-2px);
+      }
+    }
+
+    &__card-head {
+      display: flex;
+      align-items: center;
+      justify-content: space-between;
+      gap: var(--ai-sp-3);
+      min-width: 0;
+    }
+
+    &__card-meta {
+      display: flex;
+      align-items: center;
+      gap: var(--ai-sp-2);
+      min-width: 0;
+
+      /* 一行里两个 mono:厂商 chip 不收缩,长的那段(Base URL)自己省略 */
+      > span:last-child {
+        flex: 1 1 auto;
+        min-width: 0;
+      }
+    }
+
+    &__empty {
+      padding: var(--ai-sp-6) var(--ai-sp-4);
+      font-size: 12.5px;
+      line-height: 1.7;
+      text-align: center;
+      color: var(--ai-text-2);
+      background-color: var(--ai-bg-subtle);
+      border: 1px dashed var(--ai-border);
+      border-radius: var(--ai-radius-lg);
     }
 
     &__name {
       display: flex;
       align-items: center;
       gap: var(--ai-sp-2);
+      min-width: 0;
     }
 
     &__name-text {
@@ -364,9 +402,11 @@
     }
 
     &__badge {
+      flex: 0 0 auto;
       padding: 0 6px;
       font-size: 11px;
       line-height: 17px;
+      white-space: nowrap;
       color: var(--ai-primary);
       background: var(--ai-primary-soft);
       border-radius: var(--ai-radius-sm);
@@ -381,9 +421,11 @@
 
     &__chip {
       display: inline-block;
+      flex: 0 0 auto;
       padding: 1px 8px;
       font-size: 12px;
       line-height: 20px;
+      white-space: nowrap;
       color: var(--ai-text);
       background: var(--ai-bg-subtle);
       border: 1px solid var(--ai-border);
@@ -405,7 +447,9 @@
     }
 
     &__status {
+      flex: 0 0 auto;
       font-size: 12px;
+      white-space: nowrap;
 
       &.is-on {
         color: @success-color;
@@ -420,10 +464,13 @@
       }
     }
 
-    &__ops {
+    &__card-ops {
       display: flex;
       align-items: center;
       gap: var(--ai-sp-1);
+      margin-top: auto;
+      padding-top: var(--ai-sp-2);
+      border-top: 1px solid var(--ai-border);
     }
   }
 </style>

+ 474 - 320
ai-electron/frontend/src/ai/views/aiPlugin/index.vue

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

+ 16 - 1
ai-electron/frontend/src/core/components/Button/src/BasicButton.vue

@@ -33,7 +33,22 @@
     ];
   });
 
-  const getBindValue = computed(() => ({ ...unref(attrs), ...props }));
+  /**
+   * 透传给内部 antd Button 的属性。
+   *
+   * ⚠️ 必须剔除 `onClick`:模板上已用 `@click="handleClick"` 单独绑定了一份,
+   * 若这里再带一份,Vue 的 `mergeProps` 命中 `isOn(key)` 分支会把两者拼成
+   * `[onClick, handleClick]` 数组,一次点击就会**执行两次**父级 handler
+   * (handleClick 内部还会再调一次 `props.onClick`)。
+   *
+   * 实测症状:`<a-button @click="create">` 点一次创建出两条记录
+   * (AI 会话列表「新建对话」即由此暴露)。剔除后统一由 handleClick 派发一次。
+   */
+  const getBindValue = computed(() => {
+    const bindValue = { ...unref(attrs), ...props } as Record<string, any>;
+    delete bindValue.onClick;
+    return bindValue;
+  });
 
   function handleClick(event: MouseEvent) {
     const handler = props.onClick as Fn | null;

+ 1 - 6
ai-server/src/main/resources/application.yaml

@@ -1,11 +1,6 @@
 server:
   port: 8980
   servlet:
-    # 注意:Spring Boot 要求 context-path 以 '/' 开头且**不能**以 '/' 结尾,
-    # 否则 TomcatServletWebServerFactory.setContextPath() 会抛
-    # IllegalArgumentException: ContextPath must start with '/' and not end with '/'。
-    # 原值 "!/js/a/" 为 Solon 时代写法('!' 表示精确匹配),在 Spring Boot 下无法启动,
-    # 已按实际生效路径修正为 /js/a(与前端 ctxPath(/js) + adminPath(/a) 一致)。
     context-path: /js/a
 
 spring:
@@ -25,7 +20,7 @@ spring:
       server:
         enabled: true
         type: async              # 异步模式:工具在 boundedElastic 线程上执行,不占用 HTTP 线程
-        protocol: SSE            # 传输协议,默认即 SSE;改成 STREAMABLE 时生效的是下面的 mcp-endpoint
+        protocol: STREAMABLE            # 传输协议,默认即 SSE;改成 STREAMABLE 时生效的是下面的 mcp-endpoint
         name: my-mcp-server      # MCP 服务名称(客户端 tools/list 里能看到)
         version: 1.0.0           # 服务版本
         # ★ 必须与 server.context-path 一致:SSE 首帧会把 message 端点按「相对路径」下发给客户端