cc 1 هفته پیش
والد
کامیت
b091e02178

+ 28 - 0
ai-electron/frontend/src/ai/api/agentApi.ts

@@ -0,0 +1,28 @@
+/**
+ * 专家(智能体)管理接口 —— 后端 AgentExpertController(/agents/**,Result 包装)。
+ *
+ * <p>隔离口径在后端:列表 = 内置 + 自己的;内置不可编辑/删除。
+ * 雪花/自增 ID 一律按字符串传递(前端 ID 约定,禁止转 number)。
+ */
+import { aiHttp } from './http';
+import type { AiExpert, SaveExpertParams } from './types';
+
+/** 专家列表(内置 + 我的,后端按登录用户过滤) */
+export function listExperts(): Promise<AiExpert[]> {
+  return aiHttp.get<AiExpert[]>('/agents');
+}
+
+/** 新建自建专家 */
+export function createExpert(params: SaveExpertParams): Promise<AiExpert> {
+  return aiHttp.postJson<AiExpert>('/agents', params as unknown as Record<string, any>);
+}
+
+/** 编辑专家(内置会被后端拒绝) */
+export function updateExpert(id: string, params: SaveExpertParams): Promise<AiExpert> {
+  return aiHttp.putJson<AiExpert>(`/agents/${id}`, params as unknown as Record<string, any>);
+}
+
+/** 删除专家(内置会被后端拒绝) */
+export function deleteExpert(id: string): Promise<void> {
+  return aiHttp.delete<void>(`/agents/${id}`);
+}

+ 278 - 0
ai-electron/frontend/src/ai/components/AgentFormModal.vue

@@ -0,0 +1,278 @@
+<template>
+  <a-modal
+    :open="open"
+    :title="isEdit ? '编辑专家' : '新建专家'"
+    :width="640"
+    :confirm-loading="submitting"
+    :mask-closable="false"
+    ok-text="保存"
+    cancel-text="取消"
+    @ok="submit"
+    @cancel="close"
+  >
+    <a-form ref="formRef" class="ai-expert-form" :model="form" :rules="rules" layout="vertical">
+      <a-form-item label="专家名称" name="name">
+        <a-input v-model:value="form.name" placeholder="如:通话研判专家" :maxlength="100" />
+      </a-form-item>
+
+      <a-form-item label="描述" name="description">
+        <a-input v-model:value="form.description" placeholder="一句话说明这个专家擅长什么(可选)" :maxlength="500" />
+      </a-form-item>
+
+      <a-form-item label="系统提示词(人设与分析规则)" name="sysPrompt">
+        <a-textarea
+          v-model:value="form.sysPrompt"
+          :rows="8"
+          :maxlength="30000"
+          placeholder="定义专家的角色、分析口径与回答风格"
+        />
+      </a-form-item>
+
+      <!-- 工具:组级启用。不勾 = 不限制(全部业务组可用);勾选 = 只启用所选组 -->
+      <a-form-item label="可用工具组">
+        <div class="ai-expert-form__hint"
+          >不勾选 = 不限制(全部业务组可用,默认装备人员画像组);勾选后只启用所选组,未选组的工具对模型不可见。</div
+        >
+        <a-spin :spinning="toolsLoading" size="small">
+          <div v-if="toolGroups.length" class="ai-expert-form__checks">
+            <label v-for="g in toolGroups" :key="g.group" class="ai-expert-form__check">
+              <a-checkbox v-model:checked="checkedGroups[g.group]">
+                {{ g.label }}
+                <span class="ai-expert-form__count">{{ g.count }} 个工具</span>
+              </a-checkbox>
+              <div class="ai-expert-form__check-desc">{{ g.description }}</div>
+            </label>
+          </div>
+          <div v-else class="ai-expert-form__hint">暂无可用工具组</div>
+        </a-spin>
+      </a-form-item>
+
+      <!-- 技能:名称白名单。不勾 = 不限制;勾选 = 只允许所选技能 -->
+      <a-form-item label="可用技能">
+        <div class="ai-expert-form__hint"
+          >不勾选 = 不限制(该账号安装的技能全部可用);勾选后该专家只能看到所选技能(新装技能需回这里补勾)。</div
+        >
+        <a-spin :spinning="skillsLoading" size="small">
+          <div v-if="skills.length" class="ai-expert-form__checks">
+            <label v-for="s in skills" :key="s.name" class="ai-expert-form__check">
+              <a-checkbox v-model:checked="checkedSkills[s.name]">
+                {{ s.name }}
+              </a-checkbox>
+              <div class="ai-expert-form__check-desc">{{ s.description || '(无描述)' }}</div>
+            </label>
+          </div>
+          <div v-else class="ai-expert-form__hint"> 暂无已安装技能。到「插件与技能」页安装后,这里即可勾选绑定。 </div>
+        </a-spin>
+      </a-form-item>
+    </a-form>
+  </a-modal>
+</template>
+
+<script lang="ts" setup>
+  /**
+   * 专家新建/编辑弹窗。
+   *
+   * 工具与技能的勾选语义与后端一致:**不勾 = 不限制**(tools_allow_json/skills_allow_json
+   * 落 null,走默认口径),勾选若干 = 只启用所选(后端按白名单注册/过滤,未选的对模型不可见)。
+   *
+   * 候选数据:工具组从 capabilityApi.listTools() 按 groupCode 聚合(与「插件与技能」页同一份数据源),
+   * 技能取当前账号已安装的技能(listSkills),repo 来源的只读技能不出现在勾选里
+   * —— 白名单只对用户技能目录生效(SkillStore 按 userId 隔离)。
+   */
+  import { computed, reactive, ref, watch } from 'vue';
+  import { Checkbox, Form, FormItem, Input, Modal, Spin, Textarea } from 'ant-design-vue';
+  import { useMessage } from '@/core/hooks/web/useMessage';
+  import * as agentApi from '../api/agentApi';
+  import * as capabilityApi from '../api/capabilityApi';
+  import type { AiExpert, SaveExpertParams, Skill } from '../api/types';
+  import { TOOL_GROUP_LABELS, TOOL_GROUP_KEYS, toolGroupLabel } from '../utils/constants';
+
+  const AModal = Modal;
+  const AForm = Form;
+  const AFormItem = FormItem;
+  const AInput = Input;
+  const ATextarea = Textarea;
+  const ACheckbox = Checkbox;
+  const ASpin = Spin;
+
+  const props = defineProps<{
+    open: boolean;
+    /** 编辑目标;null = 新建 */
+    record?: AiExpert | null;
+  }>();
+  const emit = defineEmits<{
+    'update:open': [value: boolean];
+    saved: [];
+  }>();
+
+  const { showMessage } = useMessage();
+
+  const isEdit = computed(() => !!props.record);
+  const submitting = ref(false);
+  const toolsLoading = ref(false);
+  const skillsLoading = ref(false);
+  const formRef = ref();
+
+  const form = reactive({
+    name: '',
+    description: '',
+    sysPrompt: '',
+  });
+
+  const rules: Record<string, any> = {
+    name: [{ required: true, message: '请输入专家名称', trigger: 'blur' }],
+    sysPrompt: [{ required: true, message: '请输入系统提示词', trigger: 'blur' }],
+  };
+
+  interface ToolGroupOption {
+    group: string;
+    label: string;
+    description: string;
+    count: number;
+  }
+
+  const toolGroups = ref<ToolGroupOption[]>([]);
+  const skills = ref<Skill[]>([]);
+  /** 勾选态(key → checked);未出现的 key 视为不勾 */
+  const checkedGroups = reactive<Record<string, boolean>>({});
+  const checkedSkills = reactive<Record<string, boolean>>({});
+
+  function resetChecks() {
+    Object.keys(checkedGroups).forEach((k) => delete checkedGroups[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));
+  }
+
+  /** 从工具清单聚合业务组(保持 TOOL_GROUP_KEYS 顺序,缺失的组用静态标签兜底) */
+  async function loadOptions() {
+    toolsLoading.value = true;
+    skillsLoading.value = true;
+    try {
+      const [tools, skillList] = await Promise.all([
+        capabilityApi.listTools().catch(() => [] as any[]),
+        capabilityApi.listSkills().catch(() => [] as Skill[]),
+      ]);
+      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');
+    } finally {
+      toolsLoading.value = false;
+      skillsLoading.value = false;
+    }
+  }
+
+  watch(
+    () => props.open,
+    (open) => {
+      if (!open) return;
+      Object.assign(form, {
+        name: props.record?.name || '',
+        description: props.record?.description || '',
+        sysPrompt: props.record?.sysPrompt || '',
+      });
+      resetChecks();
+      formRef.value?.clearValidate?.();
+      void loadOptions();
+    },
+  );
+
+  const pickedGroups = computed(() => TOOL_GROUP_KEYS.filter((g) => checkedGroups[g]));
+  const pickedSkills = computed(() => skills.value.map((s) => s.name).filter((n) => checkedSkills[n]));
+
+  function close() {
+    emit('update:open', false);
+  }
+
+  async function submit() {
+    try {
+      await formRef.value?.validate();
+    } catch {
+      return;
+    }
+    const payload: SaveExpertParams = {
+      name: form.name.trim(),
+      description: form.description?.trim() || '',
+      sysPrompt: form.sysPrompt.trim(),
+      toolGroups: pickedGroups.value,
+      skillNames: pickedSkills.value,
+    };
+    submitting.value = true;
+    try {
+      if (isEdit.value && props.record) {
+        await agentApi.updateExpert(props.record.id, payload);
+      } else {
+        await agentApi.createExpert(payload);
+      }
+      showMessage('已保存', 'success');
+      emit('saved');
+      close();
+    } catch (e: any) {
+      showMessage(e?.message || '保存失败', 'error');
+    } finally {
+      submitting.value = false;
+    }
+  }
+</script>
+
+<style lang="less" scoped>
+  .ai-expert-form {
+    &__hint {
+      margin-bottom: 8px;
+      font-size: 12px;
+      line-height: 1.7;
+      color: var(--ai-text-2);
+    }
+
+    &__checks {
+      display: flex;
+      flex-direction: column;
+      gap: 8px;
+      max-height: 220px;
+      overflow-y: auto;
+      padding: 2px;
+    }
+
+    &__check {
+      padding: 6px 8px;
+      border: 1px solid var(--ai-border);
+      border-radius: 8px;
+      cursor: pointer;
+
+      &:hover {
+        background: var(--ai-bg-hover);
+      }
+    }
+
+    &__count {
+      margin-left: 6px;
+      font-size: 11px;
+      color: var(--ai-text-2);
+    }
+
+    &__check-desc {
+      margin-top: 2px;
+      padding-left: 22px;
+      font-size: 11.5px;
+      line-height: 1.6;
+      color: var(--ai-text-2);
+    }
+  }
+</style>

+ 348 - 0
ai-electron/frontend/src/ai/views/aiExpert/index.vue

@@ -0,0 +1,348 @@
+<template>
+  <div class="ai-page ai-expert">
+    <!-- 顶栏:标题 + 摘要 + 操作 -->
+    <div class="ai-expert__bar">
+      <span class="ai-expert__title">专家</span>
+      <span class="ai-expert__meta">{{ 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>
+        新建专家
+      </a-button>
+    </div>
+
+    <div class="ai-expert__hint">
+      专家 = 一套「提示词 + 可用工具组 +
+      可用技能」的智能体。对话会话创建时绑定所选专家,<b>绑定后不可更换</b>(要换请新建会话)。
+    </div>
+
+    <a-spin :spinning="loading">
+      <!-- 内置专家:全局可见,只有「去对话」 -->
+      <template v-if="builtinExperts.length">
+        <div class="ai-expert__group">
+          <span class="ai-expert__group-name">内置专家</span>
+          <span class="ai-expert__group-tip">全局可见 · 不可编辑与删除</span>
+        </div>
+        <div class="ai-expert__grid">
+          <article v-for="e in builtinExperts" :key="e.id" class="ai-expert__card">
+            <div class="ai-expert__card-head">
+              <span class="ai-expert__name" :title="e.name">{{ e.name }}</span>
+              <span class="ai-expert__badge">内置</span>
+            </div>
+            <p class="ai-expert__desc">{{ e.description || '暂无描述' }}</p>
+            <div class="ai-expert__chips">
+              <span class="ai-expert__chip">{{ groupSummary(e) }}</span>
+              <span class="ai-expert__chip">{{ skillSummary(e) }}</span>
+            </div>
+            <div class="ai-expert__card-ops">
+              <a-button type="primary" size="small" @click="onChat(e)">去对话</a-button>
+            </div>
+          </article>
+        </div>
+      </template>
+
+      <!-- 我的专家:可编辑可删除 -->
+      <template v-if="mineExperts.length">
+        <div class="ai-expert__group">
+          <span class="ai-expert__group-name">我的专家</span>
+          <span class="ai-expert__group-tip">仅自己可见 · 可编辑与删除</span>
+        </div>
+        <div class="ai-expert__grid">
+          <article v-for="e in mineExperts" :key="e.id" class="ai-expert__card">
+            <div class="ai-expert__card-head">
+              <span class="ai-expert__name" :title="e.name">{{ e.name }}</span>
+              <span class="ai-expert__badge is-mine">我的</span>
+            </div>
+            <p class="ai-expert__desc">{{ e.description || '暂无描述' }}</p>
+            <div class="ai-expert__chips">
+              <span class="ai-expert__chip">{{ groupSummary(e) }}</span>
+              <span class="ai-expert__chip">{{ skillSummary(e) }}</span>
+            </div>
+            <div class="ai-expert__card-ops">
+              <a-button type="primary" size="small" @click="onChat(e)">去对话</a-button>
+              <a-button type="link" size="small" @click="openEdit(e)">编辑</a-button>
+              <a-button type="link" size="small" danger @click="onDelete(e)">删除</a-button>
+            </div>
+          </article>
+        </div>
+      </template>
+
+      <div v-if="!loading && !builtinExperts.length && !mineExperts.length" class="ai-expert__empty">
+        还没有专家。点击右上角「新建专家」创建第一个。
+      </div>
+    </a-spin>
+
+    <AgentFormModal v-model:open="formOpen" :record="editing" @saved="onSaved" />
+  </div>
+</template>
+
+<script lang="ts" setup name="AiExpertIndex">
+  /**
+   * 专家(智能体)管理页:卡片列表,区分内置 / 我的。
+   *
+   * 对接 AgentExpertController(/agents/**,Result 包装):
+   * 列表本身就是「内置 + 自己的」(后端过滤),这里再按 builtin 分两组展示。
+   *
+   * 操作语义:
+   *  - 去对话:向上层 emit(工作台切到对话视图并预选该专家,下一条消息创建的会话即绑定它);
+   *  - 编辑/删除:仅自建卡片出现,内置的按钮根本不渲染(后端另有双重校验)。
+   */
+  import { computed, onMounted, ref } from 'vue';
+  import { Button, Modal, Spin } from 'ant-design-vue';
+  import Icon from '@/core/components/Icon/src/Icon.vue';
+  import { useMessage } from '@/core/hooks/web/useMessage';
+  import * as agentApi from '../../api/agentApi';
+  import type { AiExpert } from '../../api/types';
+  import AgentFormModal from '../../components/AgentFormModal.vue';
+  import { toolGroupLabel } from '../../utils/constants';
+
+  const AButton = Button;
+  const ASpin = Spin;
+
+  const emit = defineEmits<{ chat: [expert: AiExpert] }>();
+
+  const { showMessage } = useMessage();
+
+  const experts = ref<AiExpert[]>([]);
+  const loading = ref(false);
+  const formOpen = ref(false);
+  const editing = ref<AiExpert | null>(null);
+
+  const builtinExperts = computed(() => experts.value.filter((e) => e.builtin));
+  const mineExperts = computed(() => experts.value.filter((e) => !e.builtin));
+
+  const summary = computed(() => {
+    const parts: string[] = [];
+    if (builtinExperts.value.length) parts.push(`内置 ${builtinExperts.value.length}`);
+    if (mineExperts.value.length) parts.push(`我的 ${mineExperts.value.length}`);
+    return parts.length ? parts.join(' · ') : '尚未创建专家';
+  });
+
+  onMounted(load);
+
+  async function load() {
+    loading.value = true;
+    try {
+      experts.value = (await agentApi.listExperts()) || [];
+    } catch (e: any) {
+      showMessage(e?.message || '加载专家列表失败', 'error');
+    } finally {
+      loading.value = false;
+    }
+  }
+
+  /** 工具组摘要:限制态列组名,不限制显示「全部工具」 */
+  function groupSummary(e: AiExpert): string {
+    const groups = e.toolGroups || [];
+    if (!groups.length) return '工具:不限制';
+    return `工具:${groups.map((g) => toolGroupLabel(g)).join('、')}`;
+  }
+
+  function skillSummary(e: AiExpert): string {
+    const names = e.skillNames || [];
+    if (!names.length) return '技能:不限制';
+    return `技能:${names.length} 个已绑定`;
+  }
+
+  function onChat(e: AiExpert) {
+    emit('chat', e);
+  }
+
+  function openCreate() {
+    editing.value = null;
+    formOpen.value = true;
+  }
+
+  function openEdit(e: AiExpert) {
+    editing.value = e;
+    formOpen.value = true;
+  }
+
+  async function onSaved() {
+    await load();
+  }
+
+  function onDelete(e: AiExpert) {
+    Modal.confirm({
+      title: '删除专家',
+      content: `确认删除「${e.name}」?绑定它的会话将无法继续对话(会得到「智能体不存在或已删除」错误)。`,
+      okText: '删除',
+      okType: 'primary',
+      okButtonProps: { danger: true },
+      cancelText: '取消',
+      onOk: async () => {
+        try {
+          await agentApi.deleteExpert(e.id);
+          showMessage('已删除', 'success');
+          await load();
+        } catch (err: any) {
+          showMessage(err?.message || '删除失败', 'error');
+        }
+      },
+    });
+  }
+</script>
+
+<style lang="less" scoped>
+  .ai-expert {
+    display: flex;
+    flex-direction: column;
+    gap: var(--ai-sp-3);
+
+    &__bar {
+      display: flex;
+      align-items: center;
+      gap: var(--ai-sp-2);
+      flex: 0 0 auto;
+    }
+
+    &__title {
+      font-size: 16px;
+      font-weight: 700;
+      color: var(--ai-text);
+    }
+
+    &__meta {
+      flex: 1 1 auto;
+      font-size: 12.5px;
+      color: var(--ai-text-2);
+    }
+
+    &__hint {
+      flex: 0 0 auto;
+      padding: 8px 12px;
+      font-size: 12px;
+      line-height: 1.7;
+      color: var(--ai-text-2);
+      background: var(--ai-bg-subtle);
+      border-radius: 8px;
+
+      b {
+        color: var(--ai-primary);
+      }
+    }
+
+    &__group {
+      display: flex;
+      align-items: baseline;
+      gap: var(--ai-sp-2);
+      margin-top: var(--ai-sp-2);
+
+      &-name {
+        font-size: 13px;
+        font-weight: 700;
+        color: var(--ai-text);
+      }
+
+      &-tip {
+        font-size: 11.5px;
+        color: var(--ai-text-2);
+      }
+    }
+
+    &__grid {
+      display: grid;
+      grid-template-columns: repeat(auto-fill, minmax(320px, 1fr));
+      gap: var(--ai-sp-2);
+      margin-top: var(--ai-sp-2);
+    }
+
+    &__card {
+      display: flex;
+      flex-direction: column;
+      gap: 8px;
+      padding: 14px;
+      background: var(--ai-bg-card, #fff);
+      border: 1px solid var(--ai-border);
+      border-radius: 10px;
+      transition:
+        box-shadow 0.15s ease,
+        transform 0.15s ease;
+
+      &:hover {
+        box-shadow: var(--ai-shadow, 0 4px 16px rgba(15, 23, 42, 0.08));
+        transform: translateY(-1px);
+      }
+    }
+
+    &__card-head {
+      display: flex;
+      align-items: center;
+      gap: var(--ai-sp-2);
+    }
+
+    &__name {
+      overflow: hidden;
+      font-size: 14px;
+      font-weight: 700;
+      color: var(--ai-text);
+      white-space: nowrap;
+      text-overflow: ellipsis;
+    }
+
+    &__badge {
+      flex: 0 0 auto;
+      padding: 0 6px;
+      font-size: 11px;
+      line-height: 18px;
+      color: var(--ai-text-2);
+      background: var(--ai-bg-subtle);
+      border-radius: 99px;
+
+      &.is-mine {
+        color: var(--ai-primary);
+        background: var(--ai-primary-soft);
+      }
+    }
+
+    &__desc {
+      min-height: 36px;
+      margin: 0;
+      font-size: 12.5px;
+      line-height: 1.6;
+      color: var(--ai-text-2);
+      display: -webkit-box;
+      -webkit-line-clamp: 2;
+      -webkit-box-orient: vertical;
+      overflow: hidden;
+    }
+
+    &__chips {
+      display: flex;
+      flex-wrap: wrap;
+      gap: 6px;
+    }
+
+    &__chip {
+      max-width: 100%;
+      padding: 1px 8px;
+      overflow: hidden;
+      font-size: 11.5px;
+      color: var(--ai-text-2);
+      white-space: nowrap;
+      text-overflow: ellipsis;
+      background: var(--ai-bg-subtle);
+      border-radius: 99px;
+    }
+
+    &__card-ops {
+      display: flex;
+      align-items: center;
+      gap: var(--ai-sp-1);
+      padding-top: 4px;
+      border-top: 1px dashed var(--ai-border);
+    }
+
+    &__empty {
+      padding: 48px;
+      font-size: 13px;
+      color: var(--ai-text-2);
+      text-align: center;
+      border: 1px dashed var(--ai-border);
+      border-radius: 10px;
+    }
+  }
+</style>

+ 72 - 0
ai-server/src/main/java/com/zsjz/ai/module/agent/controller/AgentExpertController.java

@@ -0,0 +1,72 @@
+package com.zsjz.ai.module.agent.controller;
+
+import cn.dev33.satoken.stp.StpUtil;
+import com.zsjz.ai.common.config.Result;
+import com.zsjz.ai.module.agent.dto.SaveExpertDTO;
+import com.zsjz.ai.module.agent.service.AgentService;
+import com.zsjz.ai.module.agent.vo.ExpertVO;
+import jakarta.validation.Valid;
+import lombok.RequiredArgsConstructor;
+import lombok.extern.slf4j.Slf4j;
+import org.springframework.web.bind.annotation.DeleteMapping;
+import org.springframework.web.bind.annotation.GetMapping;
+import org.springframework.web.bind.annotation.PathVariable;
+import org.springframework.web.bind.annotation.PostMapping;
+import org.springframework.web.bind.annotation.PutMapping;
+import org.springframework.web.bind.annotation.RequestBody;
+import org.springframework.web.bind.annotation.RequestMapping;
+import org.springframework.web.bind.annotation.RestController;
+
+import java.util.List;
+
+/**
+ * 专家(智能体)管理控制器。
+ *
+ * <p>口径(详见 {@link AgentService} 的专家管理区):
+ * <ul>
+ *   <li>列表 = 内置(全局可见)+ 当前用户自建,<b>后端</b>按登录用户过滤;</li>
+ *   <li>内置专家只可「去对话」:编辑/删除接口按 is_builtin 双重拒绝(不靠前端隐藏);</li>
+ *   <li>配置(提示词/工具组/技能)保存后由服务端驱逐 agent 实例池,新对话立即生效;</li>
+ *   <li>会话-智能体绑定不在本控制器职责内:会话创建时写入 agent_id 后无任何修改通道。</li>
+ * </ul>
+ *
+ * <p>userId 取自 sa-token 登录态后<b>显式传参</b>给 service(而非在 service 里读请求上下文),
+ * 是为了让隔离/归属校验可以脱离 Web 层单测。
+ */
+@Slf4j
+@RestController
+@RequestMapping("/agents")
+@RequiredArgsConstructor
+public class AgentExpertController {
+
+    private final AgentService agentService;
+
+    /** 专家列表:内置 + 我的 */
+    @GetMapping
+    public Result<List<ExpertVO>> listExperts() {
+        return Result.succeed(agentService.listExperts(currentUserId()));
+    }
+
+    /** 新建自建专家 */
+    @PostMapping
+    public Result<ExpertVO> createExpert(@Valid @RequestBody SaveExpertDTO dto) {
+        return Result.succeed(agentService.createExpert(currentUserId(), dto));
+    }
+
+    /** 编辑专家(内置拒绝) */
+    @PutMapping("/{id}")
+    public Result<ExpertVO> updateExpert(@PathVariable Long id, @Valid @RequestBody SaveExpertDTO dto) {
+        return Result.succeed(agentService.updateExpert(currentUserId(), id, dto));
+    }
+
+    /** 删除专家(内置拒绝;绑定它的会话再对话会得到明确错误) */
+    @DeleteMapping("/{id}")
+    public Result<Void> deleteExpert(@PathVariable Long id) {
+        agentService.deleteExpert(currentUserId(), id);
+        return Result.succeed();
+    }
+
+    private static Long currentUserId() {
+        return StpUtil.getLoginIdAsLong();
+    }
+}

+ 41 - 0
ai-server/src/main/java/com/zsjz/ai/module/agent/dto/SaveExpertDTO.java

@@ -0,0 +1,41 @@
+package com.zsjz.ai.module.agent.dto;
+
+import jakarta.validation.constraints.NotBlank;
+import jakarta.validation.constraints.Size;
+import lombok.Data;
+
+import java.util.List;
+
+/**
+ * 新建/编辑专家(智能体)请求 DTO。
+ *
+ * <p>创建与编辑共用:编辑时由服务端校验归属(非内置 + 属于当前用户),
+ * 不接受 builtin/userId 字段 —— 这两个维度只能由服务端按登录态决定。
+ */
+@Data
+public class SaveExpertDTO {
+
+    @NotBlank(message = "专家名称不能为空")
+    @Size(max = 100, message = "名称长度不能超过100")
+    private String name;
+
+    @Size(max = 500, message = "描述长度不能超过500")
+    private String description;
+
+    /** 系统提示词(人设与分析规则)—— 专家的核心配置 */
+    @NotBlank(message = "系统提示词不能为空")
+    @Size(max = 30_000, message = "系统提示词长度不能超过30000")
+    private String sysPrompt;
+
+    /**
+     * 启用的业务工具组(如 person/call/trans/track/graph/file)。
+     * 空/不传 = 默认口径(全部组可切换、初始装备 person 组)。
+     */
+    private List<String> toolGroups;
+
+    /**
+     * 允许使用的技能名(白名单)。
+     * 空/不传 = 不限制(该用户安装的技能全部可见)。
+     */
+    private List<String> skillNames;
+}

+ 36 - 0
ai-server/src/main/java/com/zsjz/ai/module/agent/vo/ExpertVO.java

@@ -0,0 +1,36 @@
+package com.zsjz.ai.module.agent.vo;
+
+import lombok.Data;
+
+import java.util.List;
+
+/**
+ * 专家(智能体)列表响应 VO。
+ *
+ * <p>雪花/自增主键一律下发字符串(前端 ID 约定,避免 JS 精度丢失)。
+ */
+@Data
+public class ExpertVO {
+
+    /** 主键(字符串) */
+    private String id;
+
+    private String name;
+
+    private String description;
+
+    /** 系统提示词(列表页可折叠展示,编辑弹窗回填) */
+    private String sysPrompt;
+
+    /** 是否内置(全局可见、不可编辑删除) */
+    private boolean builtin;
+
+    /** 是否当前用户的自建专家(决定卡片上是否出编辑/删除) */
+    private boolean mine;
+
+    /** 启用的业务工具组;空 = 默认口径 */
+    private List<String> toolGroups;
+
+    /** 允许的技能白名单;空 = 不限制 */
+    private List<String> skillNames;
+}

+ 362 - 0
ai-server/src/test/java/com/zsjz/ai/module/agent/service/AgentExpertTest.java

@@ -0,0 +1,362 @@
+package com.zsjz.ai.module.agent.service;
+
+import com.zsjz.ai.common.exception.ServerException;
+import com.zsjz.ai.module.agent.WorkspaceManagerFactory;
+import com.zsjz.ai.module.agent.artifact.ArtifactService;
+import com.zsjz.ai.module.agent.config.ArtifactProperties;
+import com.zsjz.ai.module.agent.config.EtlProperties;
+import com.zsjz.ai.module.agent.dto.SaveExpertDTO;
+import com.zsjz.ai.module.agent.entity.AgentEntity;
+import com.zsjz.ai.module.agent.followup.FollowupService;
+import com.zsjz.ai.module.agent.intent.IntentService;
+import com.zsjz.ai.module.agent.mapper.AgentChatSessionMapper;
+import com.zsjz.ai.module.agent.mapper.AgentModelMapper;
+import com.zsjz.ai.module.agent.mapper.SqlQueryMapper;
+import com.zsjz.ai.module.agent.python.PythonExecutor;
+import com.zsjz.ai.module.agent.rag.RagSchemaService;
+import com.zsjz.ai.module.agent.tools.AgentToolRegistry;
+import com.zsjz.ai.module.agent.vo.ExpertVO;
+import com.zsjz.ai.module.call.service.CallContinuousService;
+import com.zsjz.ai.module.call.service.CallNightService;
+import com.zsjz.ai.module.call.service.CallRecordService;
+import com.zsjz.ai.module.call.service.CallSensitiveService;
+import com.zsjz.ai.module.dm.ai.FileAiProfileService;
+import com.zsjz.ai.module.graph.service.GraphService;
+import com.zsjz.ai.module.person.service.DataProfileService;
+import com.zsjz.ai.module.person.service.IntimacyService;
+import com.zsjz.ai.module.person.service.PersonBasicInfoService;
+import com.zsjz.ai.module.plat.mapper.CaseInfoMapper;
+import com.zsjz.ai.module.plat.mapper.TableFieldMapper;
+import com.zsjz.ai.module.plat.mapper.TableInfoMapper;
+import com.zsjz.ai.module.track.service.TrackCellTowerService;
+import com.zsjz.ai.module.track.service.TrackEnLocalService;
+import com.zsjz.ai.module.track.service.TrackExpressInfoService;
+import com.zsjz.ai.module.track.service.TrackMeetService;
+import com.zsjz.ai.module.track.service.TrackTogetherLiveService;
+import com.zsjz.ai.module.track.service.TrackTogetherTravelService;
+import com.zsjz.ai.module.trans.service.TransBigService;
+import com.zsjz.ai.module.trans.service.TransCardHoldService;
+import com.zsjz.ai.module.trans.service.TransCashFlowService;
+import com.zsjz.ai.module.trans.service.TransContinuousService;
+import com.zsjz.ai.module.trans.service.TransFastFundFlowService;
+import com.zsjz.ai.module.trans.service.TransFinancialService;
+import com.zsjz.ai.module.trans.service.TransFixedDepositService;
+import com.zsjz.ai.module.trans.service.TransFrequencyService;
+import com.zsjz.ai.module.trans.service.TransFundFlowService;
+import com.zsjz.ai.module.trans.service.TransRecordService;
+import com.zsjz.ai.module.trans.service.TransSensitiveService;
+import io.agentscope.harness.agent.HarnessAgent;
+import org.junit.jupiter.api.AfterEach;
+import org.junit.jupiter.api.BeforeEach;
+import org.junit.jupiter.api.DisplayName;
+import org.junit.jupiter.api.Test;
+import org.springframework.test.util.ReflectionTestUtils;
+
+import java.nio.file.Files;
+import java.nio.file.Path;
+import java.util.List;
+import java.util.Map;
+import java.util.Optional;
+
+import static org.junit.jupiter.api.Assertions.assertEquals;
+import static org.junit.jupiter.api.Assertions.assertFalse;
+import static org.junit.jupiter.api.Assertions.assertNotNull;
+import static org.junit.jupiter.api.Assertions.assertNull;
+import static org.junit.jupiter.api.Assertions.assertThrows;
+import static org.junit.jupiter.api.Assertions.assertTrue;
+import static org.mockito.ArgumentMatchers.any;
+import static org.mockito.ArgumentMatchers.anyString;
+import static org.mockito.Mockito.doAnswer;
+import static org.mockito.Mockito.doReturn;
+import static org.mockito.Mockito.mock;
+import static org.mockito.Mockito.never;
+import static org.mockito.Mockito.spy;
+import static org.mockito.Mockito.verify;
+
+/**
+ * 专家(智能体)管理:用户隔离 / 内置不可改删 / 配置校验 / 实例池驱逐。
+ *
+ * <p>DAO 层(save/updateById/removeById/getById)与查询缝(loadAllExperts)全部 stub,
+ * 本测试只验证业务规则本身,不依赖数据库。
+ */
+class AgentExpertTest {
+
+    private static final Long ME = 7L;
+
+    private AgentService service;
+
+    /** getById 的返回目标(模拟库里那行) */
+    private AgentEntity byId;
+
+    /** save/updateById 捕获的实体(断言配置是否落对) */
+    private AgentEntity captured;
+
+    /** 实例池(反射取真实池,验证驱逐) */
+    @SuppressWarnings("unchecked")
+    private Map<String, HarnessAgent> pool;
+
+    @BeforeEach
+    void setUp() {
+        service = spy(new AgentService(
+                mock(AgentModelFactory.class),
+                mock(AgentModelMapper.class),
+                Optional.empty(),
+                mock(RagSchemaService.class),
+                mock(EtlProperties.class),
+                mock(IntentService.class),
+                mock(FollowupService.class),
+                mock(SqlQueryMapper.class),
+                mock(TableInfoMapper.class),
+                mock(TableFieldMapper.class),
+                mock(PythonExecutor.class),
+                mock(AgentChatSessionMapper.class),
+                mock(CaseInfoMapper.class),
+                new WorkspaceManagerFactory(),
+                new AgentToolRegistry(
+                        mock(CallRecordService.class), mock(CallNightService.class),
+                        mock(CallContinuousService.class), mock(CallSensitiveService.class),
+                        mock(TransBigService.class), mock(TransCardHoldService.class),
+                        mock(TransCashFlowService.class), mock(TransContinuousService.class),
+                        mock(TransFastFundFlowService.class), mock(TransFinancialService.class),
+                        mock(TransFixedDepositService.class), mock(TransFrequencyService.class),
+                        mock(TransFundFlowService.class), mock(TransRecordService.class),
+                        mock(TransSensitiveService.class),
+                        mock(TrackCellTowerService.class), mock(TrackEnLocalService.class),
+                        mock(TrackExpressInfoService.class), mock(TrackMeetService.class),
+                        mock(TrackTogetherLiveService.class), mock(TrackTogetherTravelService.class),
+                        mock(GraphService.class),
+                        mock(DataProfileService.class), mock(IntimacyService.class),
+                        mock(PersonBasicInfoService.class),
+                        mock(FileAiProfileService.class),
+                        new ArtifactService(new ArtifactProperties())),
+                new ArtifactService(new ArtifactProperties())));
+        pool = (Map<String, HarnessAgent>) ReflectionTestUtils.getField(service, "agentPool");
+
+        // create 路径:库里那行还没建,回读刚 save 的实体(rowId 已被 save 桩回填)
+        doAnswer(inv -> byId != null ? byId : captured).when(service).getById(any());
+        doAnswer(inv -> {
+            captured = inv.getArgument(0);
+            captured.setRowId(99L); // 模拟 DB 回填自增主键
+            return true;
+        }).when(service).save(any(AgentEntity.class));
+        doAnswer(inv -> {
+            captured = inv.getArgument(0);
+            return true;
+        }).when(service).updateById(any(AgentEntity.class));
+        doReturn(true).when(service).removeById(any(java.io.Serializable.class));
+    }
+
+    @AfterEach
+    void tearDown() throws Exception {
+        // 专家技能验证会临时建目录,清掉不留垃圾
+        Path skillsRoot = Path.of(System.getProperty("user.dir"), ".agentscope", "users",
+                String.valueOf(ME), "skills");
+        if (Files.isDirectory(skillsRoot)) {
+            try (var stream = Files.list(skillsRoot)) {
+                for (Path p : stream.toList()) {
+                    try (var inner = Files.list(p)) {
+                        inner.forEach(x -> deleteQuietly(x));
+                    }
+                    deleteQuietly(p);
+                }
+            }
+        }
+    }
+
+    // ==================== 列表隔离 ====================
+
+    @Test
+    @DisplayName("列表 = 内置(全局)+ 自己的;别人的不可见(后端过滤,不是前端隐藏)")
+    void listFiltersToBuiltinAndMine() {
+        AgentEntity builtin = agent(1L, "数刃", null, 1);
+        AgentEntity mine = agent(2L, "我的专家", ME, 0);
+        AgentEntity others = agent(3L, "别人的专家", 99L, 0);
+        doReturn(List.of(builtin, others, mine)).when(service).loadAllExperts();
+
+        List<ExpertVO> list = service.listExperts(ME);
+
+        assertEquals(2, list.size(), "内置 + 我的,别人的必须被过滤");
+        assertEquals("1", list.get(0).getId());
+        assertTrue(list.get(0).isBuiltin());
+        assertFalse(list.get(0).isMine());
+        assertEquals("2", list.get(1).getId());
+        assertFalse(list.get(1).isBuiltin());
+        assertTrue(list.get(1).isMine());
+        assertTrue(list.stream().noneMatch(v -> "别人的专家".equals(v.getName())));
+    }
+
+    // ==================== 内置不可改删 ====================
+
+    @Test
+    @DisplayName("内置专家:编辑与删除都被拒绝(400),且不触碰 DAO")
+    void builtinRejectsEditAndDelete() {
+        byId = agent(1L, "数刃", 1L, 1);
+        SaveExpertDTO dto = dto("改名", "新提示词");
+
+        ServerException edit = assertThrows(ServerException.class,
+                () -> service.updateExpert(ME, 1L, dto));
+        assertEquals(400, edit.getCode());
+        assertTrue(edit.getMsg().contains("内置专家不可编辑"), edit.getMsg());
+        verify(service, never()).updateById(any());
+
+        ServerException del = assertThrows(ServerException.class,
+                () -> service.deleteExpert(ME, 1L));
+        assertEquals(400, del.getCode());
+        assertTrue(del.getMsg().contains("内置专家不可删除"), del.getMsg());
+        verify(service, never()).removeById(any(java.io.Serializable.class));
+    }
+
+    @Test
+    @DisplayName("自建专家:不能改别人的(无权操作)")
+    void cannotTouchOthersExpert() {
+        byId = agent(3L, "别人的专家", 99L, 0);
+
+        ServerException ex = assertThrows(ServerException.class,
+                () -> service.updateExpert(ME, 3L, dto("x", "y")));
+        assertEquals(400, ex.getCode());
+        assertTrue(ex.getMsg().contains("无权操作该专家"), ex.getMsg());
+
+        assertThrows(ServerException.class, () -> service.deleteExpert(ME, 3L));
+    }
+
+    // ==================== 编辑/删除自己的 ====================
+
+    @Test
+    @DisplayName("编辑自己的专家:配置落库 + 驱逐该专家的池化实例(别的专家不受影响)")
+    void updateOwnedEvictsPool() {
+        byId = agent(2L, "我的专家", ME, 0);
+        pool.put("w5-a2-m9-u7", mock(HarnessAgent.class));
+        pool.put("w5-a9-m9-u7", mock(HarnessAgent.class));
+
+        SaveExpertDTO dto = dto("我的专家-改", "新的系统提示词");
+        dto.setToolGroups(List.of("call", "trans"));
+        dto.setSkillNames(List.of()); // 空 = 不限制,落库必须是 null(而不是 "[]")
+        ExpertVO vo = service.updateExpert(ME, 2L, dto);
+
+        assertNotNull(captured);
+        assertEquals("我的专家-改", captured.getName());
+        assertEquals("新的系统提示词", captured.getSysPrompt());
+        assertEquals("[\"call\",\"trans\"]", captured.getToolsAllowJson());
+        assertNull(captured.getSkillsAllowJson(), "空白名单要写 null,走默认口径");
+        assertEquals("2", vo.getId());
+        assertFalse(vo.isBuiltin());
+
+        assertFalse(pool.containsKey("w5-a2-m9-u7"), "配置变更后本专家的池实例必须被驱逐");
+        assertTrue(pool.containsKey("w5-a9-m9-u7"), "其它专家的实例不能被误删");
+    }
+
+    @Test
+    @DisplayName("删除自己的专家:移除记录并驱逐池实例")
+    void deleteOwnedRemovesAndEvicts() {
+        byId = agent(2L, "我的专家", ME, 0);
+        pool.put("w5-a2-m9-u7", mock(HarnessAgent.class));
+
+        service.deleteExpert(ME, 2L);
+
+        verify(service).removeById(2L);
+        assertFalse(pool.containsKey("w5-a2-m9-u7"));
+    }
+
+    // ==================== 新建与校验 ====================
+
+    @Test
+    @DisplayName("新建:落 user_id=我、is_builtin=0,配置序列化正确")
+    void createSetsOwnershipAndConfig() {
+        SaveExpertDTO dto = dto("新专家", "你是XX领域的专家");
+        dto.setToolGroups(List.of("call"));
+        dto.setSkillNames(null);
+
+        ExpertVO vo = service.createExpert(ME, dto);
+
+        assertNotNull(captured);
+        assertEquals(ME, captured.getUserId());
+        assertEquals(0, captured.getIsBuiltin());
+        assertEquals("新专家", captured.getName());
+        assertEquals("你是XX领域的专家", captured.getSysPrompt());
+        assertEquals("[\"call\"]", captured.getToolsAllowJson());
+        assertNull(captured.getSkillsAllowJson());
+        assertEquals("99", vo.getId(), "create 后应回读带主键的实体");
+        assertTrue(vo.isMine());
+        assertFalse(vo.isBuiltin());
+    }
+
+    @Test
+    @DisplayName("校验:未知工具组拒绝;技能名非法/未安装拒绝")
+    void validatesGroupsAndSkills() {
+        SaveExpertDTO badGroup = dto("x", "y");
+        badGroup.setToolGroups(List.of("hack_group"));
+        ServerException groupEx = assertThrows(ServerException.class,
+                () -> service.createExpert(ME, badGroup));
+        assertEquals(400, groupEx.getCode());
+        assertTrue(groupEx.getMsg().contains("未知工具组"), groupEx.getMsg());
+
+        SaveExpertDTO badSkill = dto("x", "y");
+        badSkill.setSkillNames(List.of("../etc"));
+        ServerException nameEx = assertThrows(ServerException.class,
+                () -> service.createExpert(ME, badSkill));
+        assertTrue(nameEx.getMsg().contains("非法技能名"), nameEx.getMsg());
+
+        SaveExpertDTO missingSkill = dto("x", "y");
+        missingSkill.setSkillNames(List.of("不存在的技能abc"));
+        ServerException missEx = assertThrows(ServerException.class,
+                () -> service.createExpert(ME, missingSkill));
+        assertTrue(missEx.getMsg().contains("技能不存在"), missEx.getMsg());
+    }
+
+    @Test
+    @DisplayName("校验:已安装的技能可以通过白名单校验")
+    void acceptsInstalledSkill() throws Exception {
+        Path skillsRoot = Path.of(System.getProperty("user.dir"), ".agentscope", "users",
+                String.valueOf(ME), "skills");
+        Path skillDir = skillsRoot.resolve("测试技能");
+        Files.createDirectories(skillDir);
+        try {
+            SaveExpertDTO dto = dto("x", "y");
+            dto.setSkillNames(List.of("测试技能"));
+            ExpertVO vo = service.createExpert(ME, dto);
+            assertEquals("[\"测试技能\"]", vo.getSkillNames() == null ? null
+                    : com.zsjz.ai.common.utils.Json.toStr(vo.getSkillNames()));
+        } finally {
+            Files.deleteIfExists(skillDir);
+        }
+    }
+
+    // ==================== JSON 工具 ====================
+
+    @Test
+    @DisplayName("readStringList:null/空/坏 JSON 一律降级为 null(走默认口径)")
+    void readStringListDegrades() {
+        assertNull(AgentService.readStringList(null));
+        assertNull(AgentService.readStringList(""));
+        assertNull(AgentService.readStringList("   "));
+        assertNull(AgentService.readStringList("{不是数组}"));
+        assertEquals(List.of("a", "b"), AgentService.readStringList("[\"a\",\"b\"]"));
+    }
+
+    // ==================== 辅助 ====================
+
+    private static AgentEntity agent(Long rowId, String name, Long userId, int builtin) {
+        AgentEntity e = new AgentEntity();
+        e.setRowId(rowId);
+        e.setName(name);
+        e.setUserId(userId);
+        e.setIsBuiltin(builtin);
+        e.setSysPrompt("原始提示词");
+        return e;
+    }
+
+    private static SaveExpertDTO dto(String name, String sysPrompt) {
+        SaveExpertDTO d = new SaveExpertDTO();
+        d.setName(name);
+        d.setSysPrompt(sysPrompt);
+        return d;
+    }
+
+    private static void deleteQuietly(Path path) {
+        try {
+            Files.deleteIfExists(path);
+        } catch (Exception ignored) {
+            /* 测试清理尽力而为 */
+        }
+    }
+}

+ 47 - 0
sql/agent_expert.sql

@@ -0,0 +1,47 @@
+-- =====================================================================
+-- 专家(智能体)管理:用户隔离 + 内置标记
+--
+-- 幂等脚本(可重复执行)。项目无 Flyway,按 sql/ 目录既有约定手工执行。
+--
+-- 语义:
+--   is_builtin = 1  内置专家:全局可见、不可编辑、不可删除
+--   is_builtin = 0  用户自建:仅创建者可见(user_id 过滤)、可编辑可删除
+--   user_id         创建者(sa-token 登录用户 ID);内置专家为 1(平台管理员)
+--
+-- 注:skills_allow_json / tools_allow_json 两列实体早已映射且对话链路可用,
+--     正常部署应已存在;这里仅做兜底核对。
+-- =====================================================================
+
+ALTER TABLE agent ADD COLUMN IF NOT EXISTS user_id BIGINT;
+ALTER TABLE agent ADD COLUMN IF NOT EXISTS is_builtin SMALLINT NOT NULL DEFAULT 0;
+
+ALTER TABLE agent ADD COLUMN IF NOT EXISTS skills_allow_json TEXT;
+ALTER TABLE agent ADD COLUMN IF NOT EXISTS tools_allow_json TEXT;
+
+CREATE INDEX IF NOT EXISTS idx_agent_user_id ON agent(user_id);
+
+-- 存量 agent(含默认 agent row_id=1)一律视为内置、归属平台管理员
+UPDATE agent SET is_builtin = 1, user_id = COALESCE(user_id, 1) WHERE user_id IS NULL;
+
+-- =====================================================================
+-- 修复存量 identity 序列与主键(幂等)
+--
+-- 背景:存量 row_id=1(数刃)是显式插入的,没走 identity 序列,导致
+--   ① 序列仍从 1 起,新建专家拿到重复的 row_id;
+--   ② 表上没有主键,重复 id 直接落库,selectById 报「found: 2」。
+-- =====================================================================
+
+-- 清掉重复 id 的脏行(保留 is_builtin=1 的内置行)
+DELETE FROM agent WHERE row_id = 1 AND is_builtin = 0;
+
+-- 序列对齐到当前最大 id(下一次 nextval 返回 max+1)
+SELECT setval('public.dataagent_agent_row_id_seq',
+              GREATEST((SELECT COALESCE(MAX(row_id), 0) FROM agent), 1));
+
+-- 补主键:行 id 唯一是 selectById/会话绑定的前提
+DO $$
+BEGIN
+  IF NOT EXISTS (SELECT 1 FROM pg_constraint WHERE conrelid = 'agent'::regclass AND contype = 'p') THEN
+    ALTER TABLE agent ADD CONSTRAINT agent_pkey PRIMARY KEY (row_id);
+  END IF;
+END $$;