Explorar o código

研判方案工具组并入工具包,弹框默认窗口态并加方案详情

- 研判方案弹框删掉「可用工具组」区(与内置工具包同名同工具):编辑旧方案时按组名把
  tools_allow_json 映射为同名内置包勾选,映射不到的回落写回,不丢原有限制
- 新建研判方案 / 新建工具包弹框去掉 default-fullscreen,默认窗口态打开,仍可手动全屏
- 方案卡片加眼睛图标,新增只读详情弹框(提示词限高滚动 + 工具包/技能勾选 chip)
- 列表工具摘要兼容 toolPackIds,内置方案显示实际绑定的工具包
- 后端 updateExpert 改走 writeExpert 显式 set 可编辑列:MyBatis-Plus updateById
  按 NOT_NULL 策略跳过 null,白名单「从有到无」清不掉;解绑工具包清最后一个包同理
- 测试同步:AgentService 构造补 ToolPackService(历史遗留编译不过)、编辑路径改打桩 writeExpert
cc hai 14 horas
pai
achega
b796115f9f

+ 3 - 2
ai-electron/frontend/src/ai/api/types.ts

@@ -52,11 +52,11 @@ export interface AiExpert {
   builtin: boolean;
   /** 是否当前用户的自建研判方案 */
   mine: boolean;
-  /** 启用的业务工具组;null/空 = 不限制(全部组可用,默认装备人员画像组) */
+  /** 旧版工具组编码白名单(组 = 同名内置工具包;编辑时会自动映射进 toolPackIds,仅映射不到才保留) */
   toolGroups?: string[] | null;
   /** 允许的技能白名单;null/空 = 不限制 */
   skillNames?: string[] | null;
-  /** 勾选的工具包 id 列表;与 toolGroups 并集构成业务工具白名单 */
+  /** 勾选的工具包 id 列表;构成该方案的业务工具白名单 */
   toolPackIds?: string[] | null;
 }
 
@@ -65,6 +65,7 @@ export interface SaveExpertParams {
   name: string;
   description?: string;
   sysPrompt: string;
+  /** 旧工具组(UI 已并入工具包;仅映射不到内置包的组才回落写回) */
   toolGroups?: string[];
   skillNames?: string[];
   toolPackIds?: string[];

+ 42 - 95
ai-electron/frontend/src/ai/components/AgentFormModal.vue

@@ -1,14 +1,13 @@
 <template>
   <!--
-    研判方案新建/编辑弹框:内容版式取自 docs/designs/新建方案页-设计稿(基本信息 + 工具组/技能卡片多选),
-    默认全屏打开(defaultFullscreen,右上角可还原为窗口弹框)。BasicModal 右上角自带
-    「还原/全屏 + 关闭」,ModalWrapper 按视口限高并在内部滚动 —— 内容再多也不会撑出屏幕。
+    研判方案新建/编辑弹框:内容版式取自 docs/designs/新建方案页-设计稿(基本信息 + 工具包/技能卡片多选),
+    默认按窗口弹框打开(不再 defaultFullscreen,右上角可随时全屏)。BasicModal 右上角自带
+    「全屏/还原 + 关闭」,bodyStyle 按视口限高并在内部滚动 —— 内容再多也不会撑出屏幕。
   -->
   <BasicModal
     :open="open"
     :title="isEdit ? '编辑研判方案' : '新建研判方案'"
     :width="1100"
-    default-fullscreen
     wrap-class-name="ae-agent-modal"
     :confirm-loading="submitting"
     :body-style="bodyStyle"
@@ -18,7 +17,7 @@
     @cancel="close"
   >
     <div class="ae-form">
-      <p class="ae-form__sub">组装提示词、工具组与技能,定义一个可参与研判的智能体。</p>
+      <p class="ae-form__sub">组装提示词、工具包与技能,定义一个可参与研判的智能体。</p>
 
       <a-form ref="formRef" class="ae-form__form" :model="form" :rules="rules" layout="vertical">
         <!-- 基本信息(左标签 + 控件行) -->
@@ -63,47 +62,7 @@
           </div>
         </section>
 
-        <!-- 可用工具组(卡片多选:不勾 = 不限制) -->
-        <section class="ae-form__panel">
-          <div class="ae-form__sec-head">
-            <span class="ae-form__sec-title">可用工具组</span>
-            <span class="ae-form__count">{{ pickedGroups.length ? `已选 ${pickedGroups.length}` : '不限制' }}</span>
-            <span class="ae-form__sp"></span>
-          </div>
-          <div class="ae-form__hint"
-            >不勾选 =
-            不限制(全部业务组可用,默认装备人员画像组);勾选的工具组与工具包共同构成业务工具白名单,未选的工具对模型不可见。</div
-          >
-          <a-spin :spinning="toolsLoading">
-            <div v-if="toolGroups.length" class="ae-form__grid">
-              <div
-                v-for="g in toolGroups"
-                :key="g.group"
-                class="ae-form__card"
-                :class="{ 'is-on': checkedGroups[g.group] }"
-                role="checkbox"
-                :aria-checked="!!checkedGroups[g.group]"
-                @click="checkedGroups[g.group] = !checkedGroups[g.group]"
-              >
-                <span class="ae-form__ck"><Icon icon="mdi:check" :size="11" /></span>
-                <div class="ae-form__card-name">{{ g.label }}</div>
-                <div class="ae-form__card-desc">{{ g.description }}</div>
-                <div class="ae-form__chips"
-                  ><span>{{ g.count }} 个工具</span></div
-                >
-              </div>
-            </div>
-            <div v-else class="ae-form__hint">暂无可用工具组</div>
-          </a-spin>
-          <div v-if="pickedGroups.length" class="ae-form__selbar">
-            <span class="ae-form__sel-label">已选 {{ pickedGroups.length }} 组:</span>
-            <span v-for="g in pickedGroups" :key="g" class="ae-form__sel-chip">{{ toolGroupLabel(g) }}</span>
-            <span class="ae-form__sp"></span>
-            <button type="button" class="ae-form__clear" @click="clearGroups">清空</button>
-          </div>
-        </section>
-
-        <!-- 可用工具包(用户自定义工具组合;与工具组并集构成业务工具白名单) -->
+        <!-- 可用工具包(卡片多选:不勾 = 不限制);内置 6 个包与业务工具组同构,旧方案的工具组勾选打开时自动映射进来 -->
         <section class="ae-form__panel">
           <div class="ae-form__sec-head">
             <span class="ae-form__sec-title">可用工具包</span>
@@ -111,8 +70,8 @@
             <span class="ae-form__sp"></span>
           </div>
           <div class="ae-form__hint"
-            >工具包在「插件与技能 → 工具包」维护;勾选的工具组与工具包共同构成业务工具白名单(只启用其中的工具),全不勾
-            = 不限制。</div
+            >不勾选 =
+            不限制(全部业务工具可用);勾选的工具包构成业务工具白名单,未选的工具对模型不可见。工具包在「插件与技能 → 工具包」维护。</div
           >
           <a-spin :spinning="packsLoading">
             <div v-if="packs.length" class="ae-form__grid">
@@ -229,17 +188,20 @@
 
 <script lang="ts" setup>
   /**
-   * 研判方案新建/编辑弹框:内容版式取自新建方案页设计稿(纯页面版试过被否,回弹框 + 默认全屏)。
+   * 研判方案新建/编辑弹框:内容版式取自新建方案页设计稿(纯页面版试过被否,回弹框,默认窗口态打开、可手动全屏)。
    *
    * 逻辑与历史弹框版完全一致:
-   *  - 工具/技能/工具包的勾选语义:**不勾 = 不限制**(tools_allow_json/skills_allow_json/tool_packs_json 落 null 走默认口径),
-   *    勾选若干 = 只启用所选(工具组与工具包并集构成业务工具白名单,未选的对模型不可见);
-   *  - 候选数据:工具组从 capabilityApi.listTools() 按 groupCode 聚合(与「插件与技能」页同一数据源),
-   *    技能取当前账号已安装的技能(listSkills),repo 来源的只读技能不参与勾选;
+   *  - 工具/技能/工具包的勾选语义:**不勾 = 不限制**(skills_allow_json/tool_packs_json 落 null 走默认口径),
+   *    勾选若干 = 只启用所选(工具包构成业务工具白名单,未选的对模型不可见);
+   *  - 「工具组就是工具包」:内置 6 个工具包与 6 个业务工具组同名同工具(AgentToolRegistry 逐个相等),
+   *    页面只保留「可用工具包」一区。旧方案的 tools_allow_json(工具组编码)打开时按组名映射成对应
+   *    内置包勾选、保存写 tool_packs_json;映射不到的组(包清单没加载出来等)留在 legacyGroups 回落写回,
+   *    不丢原有限制;
+   *  - 技能取当前账号已安装的技能(listSkills),repo 来源的只读技能不参与勾选;
    *  - 技能编辑器:修改直接写当前用户的技能目录(新建技能在「插件与技能」页进行),与研判方案是否保存无关;
    *  - 校验与保存走 a-form rules + agentApi.createExpert/updateExpert。
    *
-   * 新增的纯前端能力(不影响保存语义):技能名称过滤、分组已选摘要条与「清空」。
+   * 新增的纯前端能力(不影响保存语义):技能名称过滤、已选摘要条与「清空」。
    */
   import { computed, reactive, ref, watch } from 'vue';
   import { Button, Form, FormItem, Input, Spin, Textarea } from 'ant-design-vue';
@@ -250,7 +212,7 @@
   import * as capabilityApi from '../api/capabilityApi';
   import * as toolPackApi from '../api/toolPackApi';
   import type { AiExpert, SaveExpertParams, SaveSkillParams, Skill, ToolPack } from '../api/types';
-  import { TOOL_GROUP_LABELS, TOOL_GROUP_KEYS, toolGroupLabel } from '../utils/constants';
+  import { toolGroupLabel } from '../utils/constants';
 
   const AButton = Button;
   const AForm = Form;
@@ -282,7 +244,6 @@
     maxHeight: 'calc(100vh - 220px)',
     overflowY: 'auto' as const,
   }));
-  const toolsLoading = ref(false);
   const skillsLoading = ref(false);
   const formRef = ref();
 
@@ -297,21 +258,17 @@
     sysPrompt: [{ required: true, message: '请输入系统提示词', trigger: 'blur' }],
   };
 
-  interface ToolGroupOption {
-    group: string;
-    label: string;
-    description: string;
-    count: number;
-  }
-
-  const toolGroups = ref<ToolGroupOption[]>([]);
   const skills = ref<Skill[]>([]);
   const packs = ref<ToolPack[]>([]);
   const packsLoading = ref(false);
   /** 勾选态(key → checked);未出现的 key 视为不勾 */
-  const checkedGroups = reactive<Record<string, boolean>>({});
   const checkedSkills = reactive<Record<string, boolean>>({});
   const checkedPacks = reactive<Record<string, boolean>>({});
+  /**
+   * 打开时旧方案的工具组编码(tools_allow_json):按组名映射到同名内置工具包后清空;
+   * 映射不到的留在这里,保存时回落写回 toolGroups,原有限制不丢。
+   */
+  const legacyGroups = ref<string[]>([]);
 
   /** 技能名称过滤(纯前端展示,不参与保存语义) */
   const skillKeyword = ref('');
@@ -328,49 +285,43 @@
   });
 
   function resetChecks() {
-    Object.keys(checkedGroups).forEach((k) => delete checkedGroups[k]);
     Object.keys(checkedPacks).forEach((k) => delete checkedPacks[k]);
     Object.keys(checkedSkills).forEach((k) => delete checkedSkills[k]);
     // 不勾 = 不限制(与后端 null 口径一致)
-    const groups = props.record?.toolGroups || [];
-    groups.forEach((g) => (checkedGroups[g] = true));
     const names = props.record?.skillNames || [];
     names.forEach((n) => (checkedSkills[n] = true));
     const packIds = props.record?.toolPackIds || [];
     packIds.forEach((id) => (checkedPacks[id] = true));
+    // 旧工具组:先挂起,工具包清单加载回来后映射成同名内置包勾选
+    legacyGroups.value = [...(props.record?.toolGroups || [])];
+  }
+
+  /** 旧工具组 → 同名内置工具包;映射到的从 legacyGroups 摘掉,映射不到的留着回落写回 */
+  function mapLegacyGroups() {
+    if (!legacyGroups.value.length) return;
+    const rest: string[] = [];
+    for (const g of legacyGroups.value) {
+      const hit = packs.value.find((p) => p.name === toolGroupLabel(g));
+      if (hit) checkedPacks[hit.id] = true;
+      else rest.push(g);
+    }
+    legacyGroups.value = rest;
   }
 
-  /** 从工具清单聚合业务组(保持 TOOL_GROUP_KEYS 顺序,缺失的组用静态标签兜底) */
   async function loadOptions() {
-    toolsLoading.value = true;
     skillsLoading.value = true;
     packsLoading.value = true;
     try {
-      const [tools, skillList, packList] = await Promise.all([
-        capabilityApi.listTools().catch(() => [] as any[]),
+      const [skillList, packList] = await Promise.all([
         capabilityApi.listSkills().catch(() => [] as Skill[]),
         toolPackApi.listToolPacks().catch(() => [] as ToolPack[]),
       ]);
-      const countBy = new Map<string, { count: number; desc: string }>();
-      for (const t of tools || []) {
-        const key = String(t.groupCode || '');
-        if (!TOOL_GROUP_KEYS.includes(key)) continue;
-        const cur = countBy.get(key) || { count: 0, desc: String(t.groupDescription || '') };
-        cur.count += 1;
-        if (!cur.desc && t.groupDescription) cur.desc = String(t.groupDescription);
-        countBy.set(key, cur);
-      }
-      toolGroups.value = TOOL_GROUP_KEYS.map((group) => ({
-        group,
-        label: toolGroupLabel(group),
-        description: countBy.get(group)?.desc || TOOL_GROUP_LABELS[group] || '',
-        count: countBy.get(group)?.count || 0,
-      }));
       // 白名单只对用户技能目录生效:repo 来源是只读技能,不参与勾选
       skills.value = (skillList || []).filter((s) => s.source === 'user');
       packs.value = packList || [];
+      // 工具包清单就位后,把旧工具组映射成同名内置包勾选
+      mapLegacyGroups();
     } finally {
-      toolsLoading.value = false;
       skillsLoading.value = false;
       packsLoading.value = false;
     }
@@ -392,7 +343,6 @@
     },
   );
 
-  const pickedGroups = computed(() => TOOL_GROUP_KEYS.filter((g) => checkedGroups[g]));
   const pickedPackIds = computed(() => packs.value.map((p) => p.id).filter((id) => checkedPacks[id]));
 
   /** 摘要条 chip 显示工具包名(id → 名) */
@@ -406,10 +356,6 @@
   const pickedSkills = computed(() => skills.value.map((s) => s.name).filter((n) => checkedSkills[n]));
 
   /** 摘要条「清空」:取消该区全部勾选(回到不限制),与逐个取消完全等价 */
-  function clearGroups() {
-    pickedGroups.value.forEach((g) => delete checkedGroups[g]);
-  }
-
   function clearSkills() {
     pickedSkills.value.forEach((n) => delete checkedSkills[n]);
   }
@@ -504,7 +450,7 @@
     }
   }
 
-  /** 只重载技能清单(不动工具组);autoCheckName:重载后补勾该技能 */
+  /** 只重载技能清单(不动工具包勾选);autoCheckName:重载后补勾该技能 */
   async function reloadSkills(autoCheckName?: string) {
     skillsLoading.value = true;
     try {
@@ -530,7 +476,8 @@
       name: form.name.trim(),
       description: form.description?.trim() || '',
       sysPrompt: form.sysPrompt.trim(),
-      toolGroups: pickedGroups.value,
+      // 映射成功的旧工具组已并入 toolPackIds;这里只剩映射不到的,回落写回不丢限制
+      toolGroups: legacyGroups.value,
       skillNames: pickedSkills.value,
       toolPackIds: pickedPackIds.value,
     };

+ 195 - 6
ai-electron/frontend/src/ai/views/aiExpert/index.vue

@@ -80,6 +80,17 @@
                 <Icon icon="mdi:arrow-right" :size="13" />
               </button>
               <span class="ai-expert__card-ops-sp"></span>
+              <!-- 查看详情:内置/自定义都可看(只读弹框),放最左与编辑/删除分隔开 -->
+              <a-tooltip title="查看详情">
+                <button
+                  class="ai-expert__iconbtn"
+                  type="button"
+                  aria-label="查看方案详情"
+                  @click="openDetail(e)"
+                >
+                  <Icon icon="mdi:eye-outline" :size="15" />
+                </button>
+              </a-tooltip>
               <!-- 内置只可对话(编辑/删除入口根本不渲染,后端另有双重校验) -->
               <template v-if="!e.builtin">
                 <a-tooltip title="编辑研判方案">
@@ -110,8 +121,50 @@
       </a-spin>
     </section>
 
-    <!-- 新建/编辑:默认全屏弹框(BasicModal defaultFullscreen),内容为新建方案页设计版式 -->
+    <!-- 新建/编辑:默认窗口弹框(可右上角全屏),内容为新建方案页设计版式 -->
     <AgentFormModal v-model:open="formOpen" :record="editing" @saved="onSaved" />
+
+    <!-- 方案详情(只读):卡片眼睛图标打开,展示提示词与勾选的工具包/技能 -->
+    <BasicModal
+      :open="detailOpen"
+      :title="detail ? `方案详情:${detail.name}` : '方案详情'"
+      :width="760"
+      :show-ok-btn="false"
+      cancel-text="关闭"
+      wrap-class-name="ae-detail-modal"
+      @cancel="detailOpen = false"
+    >
+      <div v-if="detail" class="ae-detail">
+        <div class="ae-detail__head">
+          <span class="ae-detail__badge" :class="detail.builtin ? 'is-builtin' : 'is-custom'">
+            {{ detail.builtin ? '内置' : '自定义' }}
+          </span>
+          <span class="ae-detail__name">{{ detail.name }}</span>
+        </div>
+        <p class="ae-detail__desc">{{ detail.description || '暂无描述' }}</p>
+
+        <section class="ae-detail__panel">
+          <div class="ae-detail__label">系统提示词</div>
+          <pre class="ae-detail__prompt">{{ detail.sysPrompt || '(未填写)' }}</pre>
+        </section>
+
+        <section class="ae-detail__panel">
+          <div class="ae-detail__label">可用工具包</div>
+          <div v-if="detailToolChips.length" class="ae-detail__chips">
+            <span v-for="n in detailToolChips" :key="n" class="ae-detail__chip">{{ n }}</span>
+          </div>
+          <div v-else class="ae-detail__none">不限制(全部业务工具可用)</div>
+        </section>
+
+        <section class="ae-detail__panel">
+          <div class="ae-detail__label">可用技能</div>
+          <div v-if="(detail.skillNames || []).length" class="ae-detail__chips">
+            <span v-for="n in detail.skillNames" :key="n" class="ae-detail__chip">{{ n }}</span>
+          </div>
+          <div v-else class="ae-detail__none">不限制(该账号安装的技能全部可用)</div>
+        </section>
+      </div>
+    </BasicModal>
   </div>
 </template>
 
@@ -133,8 +186,10 @@
   import { Button, Modal, Spin, Tooltip } from 'ant-design-vue';
   import Icon from '@/core/components/Icon/src/Icon.vue';
   import { useMessage } from '@/core/hooks/web/useMessage';
+  import { BasicModal } from '@/core/components/Modal';
   import * as agentApi from '../../api/agentApi';
-  import type { AiExpert } from '../../api/types';
+  import * as toolPackApi from '../../api/toolPackApi';
+  import type { AiExpert, ToolPack } from '../../api/types';
   import AgentFormModal from '../../components/AgentFormModal.vue';
   import { toolGroupLabel } from '../../utils/constants';
 
@@ -154,6 +209,32 @@
   const formOpen = ref(false);
   const editing = ref<AiExpert | null>(null);
 
+  /** 方案详情(只读弹框) */
+  const detailOpen = ref(false);
+  const detail = ref<AiExpert | null>(null);
+  /** 工具包清单(id → 名,懒加载一次;详情弹框把 toolPackIds 显示成人话) */
+  const packCatalog = ref<ToolPack[] | null>(null);
+
+  async function openDetail(e: AiExpert) {
+    detail.value = e;
+    detailOpen.value = true;
+    if (!packCatalog.value) {
+      packCatalog.value = (await toolPackApi.listToolPacks().catch(() => [])) || [];
+    }
+  }
+
+  /** 详情里的工具包 chip:内置/自建包按 id 取名;旧方案残留的工具组按组名显示;去重防同名并列 */
+  const detailToolChips = computed(() => {
+    const d = detail.value;
+    if (!d) return [];
+    const chips = new Set<string>();
+    for (const id of d.toolPackIds || []) {
+      chips.add(packCatalog.value?.find((p) => p.id === id)?.name || `工具包 ${id}`);
+    }
+    for (const g of d.toolGroups || []) chips.add(toolGroupLabel(g));
+    return [...chips];
+  });
+
   /** 研判方案名称搜索(纯前端过滤,不改接口与加载逻辑) */
   const keyword = ref('');
 
@@ -186,11 +267,15 @@
     }
   }
 
-  /** 工具组摘要:限制态列组名,不限制显示「全部工具」 */
+  /** 工具摘要:勾了工具包显示包数,旧方案还有工具组的列出组名,都没有 = 不限制 */
   function groupSummary(e: AiExpert): string {
     const groups = e.toolGroups || [];
-    if (!groups.length) return '工具:不限制';
-    return `工具:${groups.map((g) => toolGroupLabel(g)).join('、')}`;
+    const packIds = e.toolPackIds || [];
+    if (!groups.length && !packIds.length) return '工具:不限制';
+    const parts: string[] = [];
+    if (packIds.length) parts.push(`${packIds.length} 个工具包`);
+    if (groups.length) parts.push(groups.map((g) => toolGroupLabel(g)).join('、'));
+    return `工具:${parts.join(' + ')}`;
   }
 
   function skillSummary(e: AiExpert): string {
@@ -433,8 +518,9 @@
         border: none;
         outline: none;
 
+        /* 占位符文字用灰色(--ai-text-3 未在 token 中定义,直接写色值) */
         &::placeholder {
-          color: var(--ai-text-3);
+          color: #9aa1ac;
         }
       }
     }
@@ -636,4 +722,107 @@
       border-radius: 10px;
     }
   }
+
+  /* ── 方案详情弹框(只读):头 + 提示词 + 工具包/技能勾选 ── */
+  .ae-detail {
+    display: flex;
+    flex-direction: column;
+    gap: var(--ai-sp-3);
+
+    &__head {
+      display: flex;
+      align-items: center;
+      gap: var(--ai-sp-2);
+    }
+
+    &__badge {
+      flex: 0 0 auto;
+      padding: 0 7px;
+      font-size: 11px;
+      line-height: 18px;
+      border-radius: 99px;
+
+      &.is-builtin {
+        color: var(--ai-text-2);
+        background: var(--ai-bg-subtle);
+      }
+
+      &.is-custom {
+        color: var(--ai-primary);
+        background: var(--ai-primary-soft);
+      }
+    }
+
+    &__name {
+      overflow: hidden;
+      font-size: 15px;
+      font-weight: 700;
+      color: var(--ai-text);
+      white-space: nowrap;
+      text-overflow: ellipsis;
+    }
+
+    &__desc {
+      margin: 0;
+      font-size: 13px;
+      line-height: 1.7;
+      color: var(--ai-text-2);
+    }
+
+    &__panel {
+      display: flex;
+      flex-direction: column;
+      gap: 8px;
+      padding: 12px 14px;
+      background: var(--ai-bg-card, #fff);
+      border: 1px solid var(--ai-border);
+      border-radius: var(--ai-radius-lg);
+    }
+
+    &__label {
+      font-size: 12.5px;
+      font-weight: 700;
+      color: var(--ai-text);
+    }
+
+    /* 提示词可能很长:限高 + 内部滚动,保留换行 */
+    &__prompt {
+      overflow-y: auto;
+      max-height: 240px;
+      padding: 10px 12px;
+      margin: 0;
+      font-family: inherit;
+      font-size: 12.5px;
+      line-height: 1.7;
+      color: var(--ai-text);
+      white-space: pre-wrap;
+      word-break: break-word;
+      background: var(--ai-bg-subtle);
+      border-radius: var(--ai-radius);
+    }
+
+    &__chips {
+      display: flex;
+      flex-wrap: wrap;
+      gap: 6px;
+    }
+
+    &__chip {
+      overflow: hidden;
+      max-width: 260px;
+      padding: 1px 10px;
+      font-size: 11.5px;
+      font-weight: 500;
+      color: var(--ai-primary);
+      background: var(--ai-primary-soft);
+      border-radius: 99px;
+      white-space: nowrap;
+      text-overflow: ellipsis;
+    }
+
+    &__none {
+      font-size: 12.5px;
+      color: var(--ai-text-2);
+    }
+  }
 </style>

+ 1 - 2
ai-electron/frontend/src/ai/views/aiPlugin/index.vue

@@ -78,12 +78,11 @@
           <span class="ai-plugin__tab-count">{{ packs.length }}</span>
         </template>
 
-        <!-- 工具包编辑器:新建/修改共用,默认全屏弹框;工具按业务组以卡片勾选(基础工具始终可用、不进包) -->
+        <!-- 工具包编辑器:新建/修改共用,默认窗口弹框(可右上角全屏);工具按业务组以卡片勾选(基础工具始终可用、不进包) -->
         <BasicModal
           :open="packEditor.open"
           :title="packEditor.editing ? `修改工具包:${packEditor.name}` : '新建工具包'"
           :width="1100"
-          default-fullscreen
           wrap-class-name="ai-pack-modal"
           :confirm-loading="packEditor.saving"
           ok-text="保存"

+ 23 - 5
ai-server/src/main/java/com/zsjz/ai/module/agent/service/AgentService.java

@@ -1,6 +1,7 @@
 package com.zsjz.ai.module.agent.service;
 
 import cn.dev33.satoken.stp.StpUtil;
+import com.baomidou.mybatisplus.core.conditions.update.LambdaUpdateWrapper;
 import com.baomidou.mybatisplus.spring.service.impl.ServiceImpl;
 import com.fasterxml.jackson.core.JsonProcessingException;
 import com.fasterxml.jackson.core.type.TypeReference;
@@ -390,11 +391,28 @@ public class AgentService extends ServiceImpl<AgentMapper, AgentEntity> {
         validateExpertConfig(userId, dto);
         AgentEntity entity = getById(rowId);
         applyExpertConfig(entity, dto);
-        updateById(entity);
+        writeExpert(entity);
         evictAgentPool(rowId);
         return toExpertVO(getById(rowId), userId);
     }
 
+    /**
+     * 写回一行专家配置(可编辑的 6 列)。
+     *
+     * <p>不能用 {@code updateById}:MyBatis-Plus 默认 NOT_NULL 策略会跳过 null 字段,
+     * 白名单「从有到无」(清空工具组/工具包/技能)时清不掉,旧限制会静默残留 —— 这里显式 set,null 照写。
+     */
+    boolean writeExpert(AgentEntity entity) {
+        return update(new LambdaUpdateWrapper<AgentEntity>()
+                .eq(AgentEntity::getRowId, entity.getRowId())
+                .set(AgentEntity::getName, entity.getName())
+                .set(AgentEntity::getDescription, entity.getDescription())
+                .set(AgentEntity::getSysPrompt, entity.getSysPrompt())
+                .set(AgentEntity::getToolsAllowJson, entity.getToolsAllowJson())
+                .set(AgentEntity::getSkillsAllowJson, entity.getSkillsAllowJson())
+                .set(AgentEntity::getToolPacksJson, entity.getToolPacksJson()));
+    }
+
     /**
      * 删除专家:内置拒绝;绑定它的会话继续对话时由 {@link #getOrCreateAgent} 返回明确错误。
      */
@@ -419,10 +437,10 @@ public class AgentService extends ServiceImpl<AgentMapper, AgentEntity> {
                 continue;
             }
             List<String> rest = ids.stream().filter(v -> !idStr.equals(v)).toList();
-            AgentEntity upd = new AgentEntity();
-            upd.setRowId(agent.getRowId());
-            upd.setToolPacksJson(writeStringList(rest));
-            updateById(upd);
+            // 摘到最后一个包时值为 null:updateById 的 NOT_NULL 策略会跳过,必须显式 set 才能清掉
+            update(new LambdaUpdateWrapper<AgentEntity>()
+                    .eq(AgentEntity::getRowId, agent.getRowId())
+                    .set(AgentEntity::getToolPacksJson, writeStringList(rest)));
             evictAgentPool(agent.getRowId());
             log.info("工具包解绑方案: packId={}, agentRowId={}", packId, agent.getRowId());
         }

+ 2 - 1
ai-server/src/test/java/com/zsjz/ai/module/agent/service/AgentCapabilityToolkitTest.java

@@ -106,7 +106,8 @@ class AgentCapabilityToolkitTest {
                 new WorkspaceManagerFactory(),
                 registry,
                 new ArtifactService(new ArtifactProperties()),
-                mock(GovernTreeService.class));
+                mock(GovernTreeService.class),
+                mock(ToolPackService.class));
     }
 
     @Test

+ 9 - 2
ai-server/src/test/java/com/zsjz/ai/module/agent/service/AgentExpertTest.java

@@ -77,7 +77,7 @@ import static org.mockito.Mockito.verify;
 /**
  * 专家(智能体)管理:用户隔离 / 内置不可改删 / 配置校验 / 实例池驱逐。
  *
- * <p>DAO 层(save/updateById/removeById/getById)与查询缝(loadAllExperts)全部 stub,
+ * <p>DAO 层(save/writeExpert/updateById/removeById/getById)与查询缝(loadAllExperts)全部 stub,
  * 本测试只验证业务规则本身,不依赖数据库。
  */
 class AgentExpertTest {
@@ -131,7 +131,8 @@ class AgentExpertTest {
                         mock(FileAiProfileService.class),
                         new ArtifactService(new ArtifactProperties())),
                 new ArtifactService(new ArtifactProperties()),
-                mock(GovernTreeService.class)));
+                mock(GovernTreeService.class),
+                mock(ToolPackService.class)));
         pool = (com.zsjz.ai.module.agent.service.AgentInstancePool<HarnessAgent>) ReflectionTestUtils.getField(service, "agentPool");
 
         // create 路径:库里那行还没建,回读刚 save 的实体(rowId 已被 save 桩回填)
@@ -145,6 +146,11 @@ class AgentExpertTest {
             captured = inv.getArgument(0);
             return true;
         }).when(service).updateById(any(AgentEntity.class));
+        // 编辑落库走 writeExpert(updateById 跳过 null、白名单清不掉),同一捕获缝
+        doAnswer(inv -> {
+            captured = inv.getArgument(0);
+            return true;
+        }).when(service).writeExpert(any(AgentEntity.class));
         doReturn(true).when(service).removeById(any(java.io.Serializable.class));
     }
 
@@ -200,6 +206,7 @@ class AgentExpertTest {
         assertEquals(400, edit.getCode());
         assertTrue(edit.getMsg().contains("内置专家不可编辑"), edit.getMsg());
         verify(service, never()).updateById(any());
+        verify(service, never()).writeExpert(any());
 
         ServerException del = assertThrows(ServerException.class,
                 () -> service.deleteExpert(ME, 1L));

+ 3 - 1
ai-server/src/test/java/com/zsjz/ai/module/agent/service/impl/AgentToolCatalogServiceImplTest.java

@@ -17,6 +17,7 @@ import com.zsjz.ai.module.agent.rag.RagSchemaService;
 import com.zsjz.ai.module.agent.registry.ToolAnnotationScanner;
 import com.zsjz.ai.module.agent.service.AgentModelFactory;
 import com.zsjz.ai.module.agent.service.AgentService;
+import com.zsjz.ai.module.agent.service.ToolPackService;
 import com.zsjz.ai.module.agent.tools.AgentToolRegistry;
 import com.zsjz.ai.module.call.service.CallContinuousService;
 import com.zsjz.ai.module.call.service.CallNightService;
@@ -120,7 +121,8 @@ class AgentToolCatalogServiceImplTest {
                 new WorkspaceManagerFactory(),
                 toolRegistry(),
                 new ArtifactService(new ArtifactProperties()),
-                mock(GovernTreeService.class));
+                mock(GovernTreeService.class),
+                mock(ToolPackService.class));
     }
 
     @Test