cc před 1 týdnem
rodič
revize
472fd39530

+ 2 - 2
.zcode/agents/agentscope.md

@@ -2,8 +2,8 @@
 name: "AgentScope"
 description: "AgentScope Java 2.0 文档查询"
 color: orange
-model: 5b781519-29a2-4edd-adda-5211b8469a72/deepseek-flash
-thoughtLevel: max
+model: 6f404f12-9d86-4c9e-bb5d-a477cf707135/mimo-v2.6-flash
+thoughtLevel: enabled
 injectAgentsMd: false
 ---
 

+ 22 - 1
ai-electron/frontend/src/ai/api/capabilityApi.ts

@@ -10,7 +10,7 @@
 import { defHttp } from '@/core/utils/http/axios';
 import { AI_ADMIN_PATH } from '../utils/constants';
 import { aiHttp } from './http';
-import type { AgentTool, McpServerInfo, Skill, SkillDetail } from './types';
+import type { AgentTool, McpServerInfo, SaveSkillParams, Skill, SkillDetail } from './types';
 
 /**
  * 工具清单(只读):读启动时同步到 `agent_tool` 的那份数据。
@@ -61,6 +61,27 @@ export async function installSkillZip(file: File, overwrite = false): Promise<Sk
   return (res && typeof res === 'object' && 'name' in res ? res : res?.data) as Skill;
 }
 
+/**
+ * 新建技能(JSON 直写 SKILL.md,免打 zip 包)。
+ *
+ * <p>「专家弹窗 → 添加技能」用;同名已存在时后端返回 400。
+ */
+export function createSkill(params: SaveSkillParams): Promise<Skill> {
+  return aiHttp.postJson<Skill>('/capability/skills', params as unknown as Record<string, any>);
+}
+
+/**
+ * 修改技能(覆盖写 SKILL.md)。
+ *
+ * <p>「专家弹窗 → 修改技能」用;技能名以路径参数为准(目录名不可变)。
+ */
+export function updateSkill(name: string, params: SaveSkillParams): Promise<Skill> {
+  return aiHttp.putJson<Skill>(
+    `/capability/skills/${encodeURIComponent(name)}`,
+    params as unknown as Record<string, any>,
+  );
+}
+
 /** 卸载技能(删除用户技能目录;配置仓库里的技能不可卸载) */
 export function deleteSkill(name: string): Promise<void> {
   return aiHttp.get<void>(`/capability/skills/${encodeURIComponent(name)}/delete`);

+ 9 - 0
ai-electron/frontend/src/ai/api/types.ts

@@ -67,6 +67,15 @@ export interface SaveExpertParams {
   skillNames?: string[];
 }
 
+/** 新建/修改技能参数(专家弹窗内「添加技能 / 修改技能」) */
+export interface SaveSkillParams {
+  /** 技能名;修改时以后端路径参数为准,这里传同值占位校验 */
+  name: string;
+  description?: string;
+  /** SKILL.md 正文(Markdown;自带 frontmatter 时后端原样保留) */
+  content: string;
+}
+
 /** 消息角色 */
 export type MessageRole = 'user' | 'assistant' | 'system';
 

+ 247 - 16
ai-electron/frontend/src/ai/components/AgentFormModal.vue

@@ -1,10 +1,14 @@
 <template>
-  <a-modal
+  <!--
+    项目标准弹框 BasicModal:右上角自带「放大/还原 + 关闭」(与其它弹框一致),
+    ModalWrapper 按视口自动限高并在内部滚动 —— 内容再多也不会把弹框拉出屏幕。
+  -->
+  <BasicModal
     :open="open"
     :title="isEdit ? '编辑专家' : '新建专家'"
     :width="640"
     :confirm-loading="submitting"
-    :mask-closable="false"
+    :body-style="bodyStyle"
     ok-text="保存"
     cancel-text="取消"
     @ok="submit"
@@ -47,25 +51,70 @@
         </a-spin>
       </a-form-item>
 
-      <!-- 技能:名称白名单。不勾 = 不限制;勾选 = 只允许所选技能 -->
-      <a-form-item label="可用技能">
+      <!-- 技能:名称白名单 + 直接在弹窗里新建/修改技能(写入当前用户的技能目录) -->
+      <a-form-item>
+        <div class="ai-expert-form__sec-head">
+          <span class="ai-expert-form__sec-title">可用技能</span>
+          <a-button size="small" @click="openSkillEditor(null)">
+            <template #icon><Icon icon="mdi:plus" :size="14" /></template>
+            添加技能
+          </a-button>
+        </div>
         <div class="ai-expert-form__hint"
-          >不勾选 = 不限制(该账号安装的技能全部可用);勾选后该专家只能看到所选技能(新装技能需回这里补勾)。</div
+          >不勾选 =
+          不限制(该账号安装的技能全部可用);勾选后该专家只能看到所选技能。技能可直接在这里新建、修改(保存即写入你的技能目录)。</div
         >
+
+        <!-- 技能编辑器:新建/修改共用;修改时技能名锁定(目录名不可变) -->
+        <div v-if="skillEditor.open" class="ai-expert-form__skill-editor">
+          <div class="ai-expert-form__skill-editor-title">
+            {{ skillEditor.editing ? `修改技能:${skillEditor.editing}` : '添加技能' }}
+          </div>
+          <a-input
+            v-model:value="skillEditor.name"
+            :disabled="!!skillEditor.editing"
+            :maxlength="64"
+            placeholder="技能名(中英文 / 数字 / ._-,最多 64 字符)"
+          />
+          <a-input
+            v-model:value="skillEditor.description"
+            :maxlength="500"
+            placeholder="一句话描述(可选,会出现在模型可见的技能目录里)"
+          />
+          <a-textarea
+            v-model:value="skillEditor.content"
+            :rows="8"
+            placeholder="技能正文(Markdown):适用场景、操作步骤、口径与注意事项"
+          />
+          <div class="ai-expert-form__skill-editor-ops">
+            <a-button size="small" @click="closeSkillEditor">取消</a-button>
+            <a-button size="small" type="primary" :loading="skillEditor.saving" @click="saveSkill">
+              {{ skillEditor.editing ? '保存修改' : '创建技能' }}
+            </a-button>
+          </div>
+        </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 v-for="s in skills" :key="s.name" class="ai-expert-form__skill-row">
+              <label 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>
+              <button type="button" class="ai-expert-form__skill-edit" title="修改技能" @click="openSkillEditor(s)">
+                <Icon icon="mdi:pencil-outline" :size="14" />
+              </button>
+            </div>
+          </div>
+          <div v-else class="ai-expert-form__hint">
+            还没有技能。点上方「添加技能」新建第一个(也可到「插件与技能」页上传 zip 安装)。
           </div>
-          <div v-else class="ai-expert-form__hint"> 暂无已安装技能。到「插件与技能」页安装后,这里即可勾选绑定。 </div>
         </a-spin>
       </a-form-item>
     </a-form>
-  </a-modal>
+  </BasicModal>
 </template>
 
 <script lang="ts" setup>
@@ -80,14 +129,15 @@
    * —— 白名单只对用户技能目录生效(SkillStore 按 userId 隔离)。
    */
   import { computed, reactive, ref, watch } from 'vue';
-  import { Checkbox, Form, FormItem, Input, Modal, Spin, Textarea } from 'ant-design-vue';
+  import { Checkbox, Form, FormItem, Input, Spin, Textarea } from 'ant-design-vue';
+  import { BasicModal } from '@/core/components/Modal';
+  import Icon from '@/core/components/Icon/src/Icon.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 type { AiExpert, SaveExpertParams, SaveSkillParams, 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;
@@ -109,6 +159,15 @@
 
   const isEdit = computed(() => !!props.record);
   const submitting = ref(false);
+
+  /**
+   * 正文限高 + 内部滚动(兜底):BasicModal 的 wrapper 在居中弹框下可能算不出视口余量,
+   * 内容一多就会把弹框拉出屏幕 —— 这里显式按视口限高,滚动条只出现在正文区。
+   */
+  const bodyStyle = computed(() => ({
+    maxHeight: 'calc(100vh - 220px)',
+    overflowY: 'auto' as const,
+  }));
   const toolsLoading = ref(false);
   const skillsLoading = ref(false);
   const formRef = ref();
@@ -197,6 +256,108 @@
   const pickedGroups = computed(() => TOOL_GROUP_KEYS.filter((g) => checkedGroups[g]));
   const pickedSkills = computed(() => skills.value.map((s) => s.name).filter((n) => checkedSkills[n]));
 
+  /**
+   * 技能编辑器(弹窗内的「添加技能 / 修改技能」子面板)。
+   * 新建/修改都直接写当前用户的技能目录,与专家是否保存无关。
+   */
+  const skillEditor = reactive({
+    open: false,
+    /** 正在修改的技能名;null = 新建 */
+    editing: null as string | null,
+    name: '',
+    description: '',
+    content: '',
+    saving: false,
+  });
+
+  /** 与后端 SkillStore.NAME_PATTERN 对齐:中英文/数字/._-,1~64 */
+  const SKILL_NAME_RE = /^[一-龥A-Za-z0-9._-]{1,64}$/;
+
+  function openSkillEditor(skill: Skill | null) {
+    skillEditor.open = true;
+    skillEditor.editing = skill?.name || null;
+    skillEditor.name = skill?.name || '';
+    skillEditor.description = skill?.description || '';
+    skillEditor.content = '';
+    if (skill) void prefillSkill(skill.name);
+  }
+
+  function closeSkillEditor() {
+    skillEditor.open = false;
+  }
+
+  /** 修改前读磁盘上的完整 SKILL.md,剥掉 frontmatter 还原成「描述 + 正文」 */
+  async function prefillSkill(name: string) {
+    try {
+      const detail = await capabilityApi.readSkill(name);
+      const split = splitSkillMd(detail?.content || '');
+      if (split.description) skillEditor.description = split.description;
+      skillEditor.content = split.body;
+    } catch {
+      // 读失败不挡编辑:描述保留列表里的值,正文留空(保存时按现有正文重写)
+    }
+  }
+
+  /** 拆 frontmatter:返回描述与正文;没有 frontmatter 时整段当正文 */
+  function splitSkillMd(raw: string): { description: string; body: string } {
+    const text = raw.replace(/\r\n/g, '\n');
+    const m = text.match(/^---\n([\s\S]*?)\n---\n?/);
+    if (!m) return { description: '', body: text.trim() };
+    let description = '';
+    const dm = m[1].match(/^description:\s*(.*)$/m);
+    if (dm)
+      description = dm[1]
+        .trim()
+        .replace(/^["']|["']$/g, '')
+        .replace(/\\"/g, '"');
+    const body = text.slice(m[0].length).replace(/^\n+/, '').replace(/\s+$/, '');
+    return { description, body };
+  }
+
+  async function saveSkill() {
+    const name = skillEditor.name.trim();
+    if (!name) return showMessage('请输入技能名', 'warning');
+    if (!SKILL_NAME_RE.test(name)) return showMessage('技能名只能含中英文、数字和 ._-,最多 64 字符', 'warning');
+    if (!skillEditor.content.trim()) return showMessage('请输入技能正文', 'warning');
+    if (!skillEditor.editing && skills.value.some((s) => s.name === name)) {
+      return showMessage('同名技能已存在', 'warning');
+    }
+    const payload: SaveSkillParams = {
+      name,
+      description: skillEditor.description.trim(),
+      content: skillEditor.content,
+    };
+    skillEditor.saving = true;
+    try {
+      if (skillEditor.editing) {
+        await capabilityApi.updateSkill(skillEditor.editing, payload);
+        showMessage('技能已更新', 'success');
+      } else {
+        await capabilityApi.createSkill(payload);
+        showMessage('技能已创建', 'success');
+      }
+      closeSkillEditor();
+      // 重载清单(修改时勾选态按名字保留);新建的技能自动勾上
+      await reloadSkills(skillEditor.editing || name);
+    } catch (e: any) {
+      showMessage(e?.message || '保存技能失败', 'error');
+    } finally {
+      skillEditor.saving = false;
+    }
+  }
+
+  /** 只重载技能清单(不动工具组);autoCheckName:重载后补勾该技能 */
+  async function reloadSkills(autoCheckName?: string) {
+    skillsLoading.value = true;
+    try {
+      const list = await capabilityApi.listSkills().catch(() => [] as Skill[]);
+      skills.value = (list || []).filter((s) => s.source === 'user');
+      if (autoCheckName) checkedSkills[autoCheckName] = true;
+    } finally {
+      skillsLoading.value = false;
+    }
+  }
+
   function close() {
     emit('update:open', false);
   }
@@ -274,5 +435,75 @@
       line-height: 1.6;
       color: var(--ai-text-2);
     }
+
+    &__sec-head {
+      display: flex;
+      align-items: center;
+      justify-content: space-between;
+      margin-bottom: 4px;
+    }
+
+    &__sec-title {
+      font-size: 14px;
+      font-weight: 600;
+      color: var(--ai-text);
+    }
+
+    &__skill-row {
+      display: flex;
+      align-items: flex-start;
+      gap: 6px;
+
+      .ai-expert-form__check {
+        flex: 1;
+        min-width: 0;
+      }
+    }
+
+    &__skill-edit {
+      flex: 0 0 auto;
+      display: inline-flex;
+      align-items: center;
+      justify-content: center;
+      width: 26px;
+      height: 26px;
+      margin-top: 4px;
+      padding: 0;
+      border: 1px solid transparent;
+      border-radius: 6px;
+      background: transparent;
+      color: var(--ai-text-2);
+      cursor: pointer;
+      transition: all 0.15s;
+
+      &:hover {
+        border-color: var(--ai-border);
+        background: var(--ai-bg-hover);
+        color: var(--ai-primary);
+      }
+    }
+
+    &__skill-editor {
+      display: flex;
+      flex-direction: column;
+      gap: 8px;
+      margin-bottom: 10px;
+      padding: 10px;
+      border: 1px solid var(--ai-primary);
+      border-radius: 8px;
+      background: var(--ai-bg-hover);
+    }
+
+    &__skill-editor-title {
+      font-size: 12.5px;
+      font-weight: 600;
+      color: var(--ai-text);
+    }
+
+    &__skill-editor-ops {
+      display: flex;
+      justify-content: flex-end;
+      gap: 8px;
+    }
   }
 </style>

+ 1 - 1
ai-electron/frontend/src/ai/components/ChatComposer.vue

@@ -18,7 +18,7 @@
       <div class="ai-composer__bar">
         <!-- 专家 chip(只读):会话创建时绑定所选专家,绑定后不可更换 -->
         <span v-if="expertName" class="ai-composer__expert" title="该会话已绑定此专家,不可更换(要换请新建会话)">
-          <Icon icon="mdi:account-tie-outline" :size="14" />
+          <Icon icon="mdi:brain" :size="14" />
           <span class="ai-composer__expert-name">{{ expertName }}</span>
         </span>
 

+ 92 - 81
ai-electron/frontend/src/ai/views/aiExpert/index.vue

@@ -20,57 +20,41 @@
     </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 v-if="experts.length" class="ai-expert__grid">
+        <article v-for="e in experts" :key="e.id" class="ai-expert__card">
+          <span class="ai-expert__badge" :class="e.builtin ? 'is-builtin' : 'is-custom'">
+            {{ e.builtin ? '内置' : '自定义' }}
+          </span>
+          <div class="ai-expert__card-head">
+            <span class="ai-expert__name" :title="e.name">{{ e.name }}</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>
+            <span class="ai-expert__card-ops-sp"></span>
+            <!-- 内置只可对话(编辑/删除入口根本不渲染,后端另有双重校验) -->
+            <template v-if="!e.builtin">
+              <a-tooltip title="编辑专家">
+                <button class="ai-expert__iconbtn" type="button" aria-label="编辑专家" @click="openEdit(e)">
+                  <Icon icon="mdi:pencil-outline" :size="15" />
+                </button>
+              </a-tooltip>
+              <a-tooltip title="删除专家">
+                <button class="ai-expert__iconbtn is-danger" type="button" aria-label="删除专家" @click="onDelete(e)">
+                  <Icon icon="mdi:trash-can-outline" :size="15" />
+                </button>
+              </a-tooltip>
+            </template>
+          </div>
+        </article>
+      </div>
+
+      <div v-if="!loading && !experts.length" class="ai-expert__empty">
         还没有专家。点击右上角「新建专家」创建第一个。
       </div>
     </a-spin>
@@ -81,17 +65,17 @@
 
 <script lang="ts" setup name="AiExpertIndex">
   /**
-   * 专家(智能体)管理页:卡片列表,区分内置 / 我的。
+   * 专家(智能体)管理页:卡片混合列表,右上角标签区分「内置 / 自定义」。
    *
-   * 对接 AgentExpertController(/agents/**,Result 包装):
-   * 列表本身就是「内置 + 自己的」(后端过滤),这里再按 builtin 分两组展示。
+   * 对接 AgentExpertController(/agents/**,Result 包装):列表本身就是「内置 + 自己的」
+   * (后端过滤,内置在前),页面不再分组,只靠卡片角标区分来源。
    *
    * 操作语义:
-   *  - 去对话:向上层 emit(工作台切到对话视图并预选该专家,下一条消息创建的会话即绑定它);
+   *  - 开始分析:向上层 emit(工作台切到对话视图并预选该专家,下一条消息创建的会话即绑定它);
    *  - 编辑/删除:仅自建卡片出现,内置的按钮根本不渲染(后端另有双重校验)。
    */
   import { computed, onMounted, ref } from 'vue';
-  import { Button, Modal, Spin } from 'ant-design-vue';
+  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 * as agentApi from '../../api/agentApi';
@@ -101,6 +85,7 @@
 
   const AButton = Button;
   const ASpin = Spin;
+  const ATooltip = Tooltip;
 
   const emit = defineEmits<{ chat: [expert: AiExpert] }>();
 
@@ -191,6 +176,8 @@
     display: flex;
     flex-direction: column;
     gap: var(--ai-sp-3);
+    /* 页级内边距与模型/插件页内容区同档(8px),四边不贴边 */
+    padding: var(--ai-sp-2);
 
     &__bar {
       display: flex;
@@ -225,24 +212,6 @@
       }
     }
 
-    &__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));
@@ -251,6 +220,7 @@
     }
 
     &__card {
+      position: relative;
       display: flex;
       flex-direction: column;
       gap: 8px;
@@ -276,6 +246,7 @@
 
     &__name {
       overflow: hidden;
+      padding-right: 64px;
       font-size: 14px;
       font-weight: 700;
       color: var(--ai-text);
@@ -283,16 +254,22 @@
       text-overflow: ellipsis;
     }
 
+    /* 右上角来源标签:内置(全局)/ 自定义(我的) */
     &__badge {
-      flex: 0 0 auto;
-      padding: 0 6px;
+      position: absolute;
+      top: 10px;
+      right: 12px;
+      padding: 0 7px;
       font-size: 11px;
       line-height: 18px;
-      color: var(--ai-text-2);
-      background: var(--ai-bg-subtle);
       border-radius: 99px;
 
-      &.is-mine {
+      &.is-builtin {
+        color: var(--ai-text-2);
+        background: var(--ai-bg-subtle);
+      }
+
+      &.is-custom {
         color: var(--ai-primary);
         background: var(--ai-primary-soft);
       }
@@ -332,8 +309,42 @@
       display: flex;
       align-items: center;
       gap: var(--ai-sp-1);
-      padding-top: 4px;
-      border-top: 1px dashed var(--ai-border);
+      padding-top: 8px;
+      /* 卡片等高时把操作行压到底部,多张卡片的操作行对齐 */
+      margin-top: auto;
+      border-top: 1px solid var(--ai-border);
+    }
+
+    &__card-ops-sp {
+      flex: 1 1 auto;
+    }
+
+    /* 编辑/删除收成图标按钮:与主按钮同排但视觉退后一档,hover 才显形 */
+    &__iconbtn {
+      display: inline-flex;
+      align-items: center;
+      justify-content: center;
+      width: 26px;
+      height: 26px;
+      padding: 0;
+      color: var(--ai-text-2);
+      background: transparent;
+      border: none;
+      border-radius: 6px;
+      cursor: pointer;
+      transition:
+        color 0.15s ease,
+        background 0.15s ease;
+
+      &:hover {
+        color: var(--ai-text);
+        background: var(--ai-bg-subtle);
+      }
+
+      &.is-danger:hover {
+        color: @error-color;
+        background: fade(@error-color, 8%);
+      }
     }
 
     &__empty {

+ 3 - 2
ai-electron/frontend/src/ai/views/aiWorkbench/index.vue

@@ -115,8 +115,9 @@
 
   const RAIL_ITEMS: Array<{ key: WorkbenchView; icon: string; label: string }> = [
     { key: 'chat', icon: 'mdi:robot-outline', label: '对话' },
-    { key: 'agent', icon: 'mdi:account-tie-outline', label: '专家' },
-    { key: 'model', icon: 'mdi:brain', label: '模型管理' },
+    // 专家沿用原「模型管理」的 brain 图标;模型管理改用芯片图标(与输入框的模型 chip 同形)
+    { key: 'agent', icon: 'mdi:brain', label: '专家' },
+    { key: 'model', icon: 'mdi:chip', label: '模型管理' },
     { key: 'plugin', icon: 'mdi:puzzle-outline', label: '插件与技能' },
   ];
 

+ 5 - 0
ai-electron/frontend/uno.config.ts

@@ -90,6 +90,11 @@ export default defineConfig({
     // Codex 模块(Icon 运行时加 i- 前缀,动态拼接的图标必须在此白名单)
     'i-mdi:star-four-points-outline',
     'i-mdi:view-dashboard-outline',
+    // 产物栏与消息内产物卡片(面板头/触发条/空态/预览下载/定位)
+    'i-mdi:file-delimited-outline',
+    'i-mdi:file-table-outline',
+    'i-mdi:chevron-double-right',
+    'i-mdi:arrow-top-left-bold-outline',
 
     // Codex 模块(Icon 组件运行时加 i- 前缀,动态图标必须在此白名单)
     'i-mdi:bookmark-multiple-outline',

+ 20 - 0
ai-server/src/main/java/com/zsjz/ai/module/agent/controller/AgentCapabilityController.java

@@ -1,12 +1,14 @@
 package com.zsjz.ai.module.agent.controller;
 
 import com.zsjz.ai.common.config.Result;
+import com.zsjz.ai.module.agent.dto.SaveSkillDTO;
 import com.zsjz.ai.module.agent.service.AgentCapabilityService;
 import com.zsjz.ai.module.agent.service.AgentToolCatalogService;
 import com.zsjz.ai.module.agent.vo.McpServerInfoVO;
 import com.zsjz.ai.module.agent.vo.SkillDetailVO;
 import com.zsjz.ai.module.agent.vo.AgentToolVO;
 import com.zsjz.ai.module.agent.vo.SkillVO;
+import jakarta.validation.Valid;
 import lombok.RequiredArgsConstructor;
 import lombok.extern.slf4j.Slf4j;
 import org.springframework.web.bind.annotation.*;
@@ -67,6 +69,24 @@ public class AgentCapabilityController {
         return Result.succeed(agentCapabilityService.readSkill(name, caseId));
     }
 
+    /**
+     * 新建技能(JSON 直写 SKILL.md,免上传 zip)——「专家弹窗 → 添加技能」用。
+     */
+    @PostMapping("/skills")
+    public Result<SkillVO> createSkill(@Valid @RequestBody SaveSkillDTO dto) {
+        return Result.succeed(agentCapabilityService.createSkill(
+                dto.getName().trim(), dto.getDescription(), dto.getContent()));
+    }
+
+    /**
+     * 修改技能(覆盖写 SKILL.md,技能名以路径参数为准)——「专家弹窗 → 修改技能」用。
+     */
+    @PutMapping("/skills/{name}")
+    public Result<SkillVO> updateSkill(@PathVariable String name, @Valid @RequestBody SaveSkillDTO dto) {
+        return Result.succeed(agentCapabilityService.updateSkill(
+                name, dto.getDescription(), dto.getContent()));
+    }
+
     /**
      * 上传 zip 安装技能到当前用户的技能目录。
      *

+ 29 - 0
ai-server/src/main/java/com/zsjz/ai/module/agent/dto/SaveSkillDTO.java

@@ -0,0 +1,29 @@
+package com.zsjz.ai.module.agent.dto;
+
+import jakarta.validation.constraints.NotBlank;
+import jakarta.validation.constraints.Size;
+import lombok.Data;
+
+/**
+ * 新建/修改技能请求 DTO(专家弹窗内的「添加技能 / 修改技能」)。
+ *
+ * <p>直接写 {@code SKILL.md}(frontmatter + 正文),免去打 zip 包;
+ * 修改时技能名以路径参数为准(目录名不可变,避免留下孤儿目录)。
+ */
+@Data
+public class SaveSkillDTO {
+
+    /** 技能名(中英文/数字/._-,1~64;修改时忽略,以后端路径参数为准) */
+    @NotBlank(message = "技能名不能为空")
+    @Size(max = 64, message = "技能名长度不能超过64")
+    private String name;
+
+    /** 一句话描述:会出现在模型可见的技能目录里 */
+    @Size(max = 500, message = "描述长度不能超过500")
+    private String description;
+
+    /** 技能正文(Markdown);自带 frontmatter 时原样保留 */
+    @NotBlank(message = "技能正文不能为空")
+    @Size(max = 1_048_576, message = "技能正文长度不能超过1MB")
+    private String content;
+}

+ 14 - 0
ai-server/src/main/java/com/zsjz/ai/module/agent/service/AgentCapabilityService.java

@@ -41,6 +41,20 @@ public interface AgentCapabilityService {
      */
     SkillVO installSkillZip(MultipartFile file, boolean overwrite);
 
+    /**
+     * 新建技能:直接写 SKILL.md(frontmatter + 正文)到当前用户技能目录,免打 zip 包。
+     *
+     * @return 创建后的技能
+     */
+    SkillVO createSkill(String name, String description, String content);
+
+    /**
+     * 修改技能(仅用户技能):覆盖写 SKILL.md;技能名以入参为准(与目录一致)。
+     *
+     * @return 保存后的技能
+     */
+    SkillVO updateSkill(String name, String description, String content);
+
     /**
      * 卸载技能(删目录)。
      */

+ 26 - 0
ai-server/src/main/java/com/zsjz/ai/module/agent/service/impl/AgentCapabilityServiceImpl.java

@@ -169,6 +169,32 @@ public class AgentCapabilityServiceImpl implements AgentCapabilityService {
                 .orElseThrow(() -> new ServerException(500, "技能已写入但读取失败: " + name));
     }
 
+    @Override
+    public SkillVO createSkill(String name, String description, String content) {
+        Path userRoot = userSkillsPath();
+        skillStore.save(userRoot, name, description, content, false);
+        return findUserSkill(userRoot, name);
+    }
+
+    @Override
+    public SkillVO updateSkill(String name, String description, String content) {
+        Path userRoot = userSkillsPath();
+        // 只允许改用户技能:仓库技能只读(与删除的口径一致)
+        if (skillStore.resolveDir(userRoot, name).isEmpty()) {
+            throw new ServerException(404, "用户技能目录里没有这个技能: " + name);
+        }
+        skillStore.save(userRoot, name, description, content, true);
+        return findUserSkill(userRoot, name);
+    }
+
+    /** 保存后回读(与 installSkillZip 同一姿势),保证页面拿到的是磁盘上的真实状态 */
+    private SkillVO findUserSkill(Path userRoot, String name) {
+        return skillStore.list(userRoot, SkillStore.SOURCE_USER, true, true).stream()
+                .filter(s -> name.equals(s.getName()))
+                .findFirst()
+                .orElseThrow(() -> new ServerException(500, "技能已保存但读取失败: " + name));
+    }
+
     @Override
     public void deleteSkill(String name) {
         Path userRoot = userSkillsPath();

+ 56 - 2
ai-server/src/main/java/com/zsjz/ai/module/agent/skill/SkillStore.java

@@ -181,6 +181,57 @@ public class SkillStore {
         }
     }
 
+    /**
+     * 新建/覆盖一个技能:直接写 {@code SKILL.md}(frontmatter name/description + 正文),
+     * 免去打 zip 包。供「专家弹窗 → 添加/修改技能」使用。
+     *
+     * @param overwrite false = 同名已存在则报错(新建);true = 覆盖(修改)
+     * @return 技能名
+     */
+    public String save(Path skillsRoot, String name, String description, String content, boolean overwrite) {
+        validateName(name);
+        if (content == null || content.isBlank()) {
+            throw new ServerException(400, "技能正文不能为空");
+        }
+        try {
+            Files.createDirectories(skillsRoot);
+            Path dir = skillsRoot.resolve(name).normalize();
+            if (!dir.startsWith(skillsRoot.normalize())) {
+                throw new ServerException(400, "技能名非法: " + name);
+            }
+            if (Files.exists(dir)) {
+                if (!overwrite) {
+                    throw new ServerException(400, "同名技能已存在: " + name);
+                }
+                deleteDirectory(dir);
+            }
+            Files.createDirectories(dir);
+            Files.writeString(dir.resolve(SKILL_FILE),
+                    buildSkillMd(name, description, content), StandardCharsets.UTF_8);
+            log.info("技能已{}: {} -> {}", overwrite ? "保存" : "创建", name, dir);
+            return name;
+        } catch (IOException e) {
+            throw new ServerException(500, "保存技能失败: " + e.getMessage());
+        }
+    }
+
+    /**
+     * 组装 SKILL.md:frontmatter(name + description)+ 正文。
+     * 正文若自带 frontmatter(用户粘贴完整 SKILL.md)则原样保留。
+     */
+    private static String buildSkillMd(String name, String description, String content) {
+        String body = content.strip();
+        if (body.startsWith("---")) {
+            return body;
+        }
+        String desc = description == null ? ""
+                : description.strip().replace("\r", " ").replace("\n", " ");
+        // description 统一用双引号包裹并转义,避免 YAML 特殊字符(: # 等)破坏 frontmatter
+        String quoted = "\"" + desc.replace("\\", "\\\\").replace("\"", "\\\"") + "\"";
+        return "---\nname: " + name + "\ndescription: " + quoted
+                + "\n---\n\n" + body + "\n";
+    }
+
     /**
      * 卸载技能(删除技能目录)。
      */
@@ -372,8 +423,11 @@ public class SkillStore {
         if (value == null || value.isEmpty()) {
             return value;
         }
-        if ((value.startsWith("\"") && value.endsWith("\"") && value.length() > 1)
-                || (value.startsWith("'") && value.endsWith("'") && value.length() > 1)) {
+        if (value.startsWith("\"") && value.endsWith("\"") && value.length() > 1) {
+            // 双引号写入时做过转义(buildSkillMd),读回要还原,保证编辑往返一致
+            return value.substring(1, value.length() - 1).replace("\\\"", "\"").replace("\\\\", "\\");
+        }
+        if (value.startsWith("'") && value.endsWith("'") && value.length() > 1) {
             return value.substring(1, value.length() - 1);
         }
         return value;

+ 51 - 0
ai-server/src/test/java/com/zsjz/ai/module/agent/skill/SkillStoreTest.java

@@ -155,6 +155,57 @@ class SkillStoreTest {
                 () -> skillStore.delete(root, "night-call")).getCode());
     }
 
+    @Test
+    @DisplayName("新建技能:自动拼 frontmatter,描述里的 YAML 特殊字符读回不走样")
+    void saveCreatesSkillMd() throws IOException {
+        Path root = Files.createTempDirectory("skills-root-");
+        String desc = "含:冒号 # 井号 \"引号\" 反斜杠\\结尾";
+        String name = skillStore.save(root, "夜间通话", desc, "# 正文\n\n1. 第一步\n2. 第二步", false);
+
+        assertEquals("夜间通话", name);
+        Path skillMd = root.resolve("夜间通话/SKILL.md");
+        assertTrue(Files.isRegularFile(skillMd));
+        String text = Files.readString(skillMd, StandardCharsets.UTF_8);
+        assertTrue(text.startsWith("---\nname: 夜间通话\ndescription: \""));
+        assertTrue(text.contains("\n---\n\n# 正文"));
+
+        // 读回:描述与正文和写入时一致(编辑弹窗预填就靠这次往返)
+        SkillDetailVO detail = skillStore.read(root, "夜间通话", SkillStore.SOURCE_USER, true, true);
+        assertEquals(desc, detail.getDescription());
+        assertTrue(detail.getContent().contains("1. 第一步\n2. 第二步"));
+        assertEquals(1, skillStore.list(root, SkillStore.SOURCE_USER, true, true).size());
+    }
+
+    @Test
+    @DisplayName("保存技能:同名默认拒绝,overwrite 才覆盖;正文空/名非法都报 400")
+    void saveOverwriteAndValidation() throws IOException {
+        Path root = Files.createTempDirectory("skills-root-");
+        skillStore.save(root, "night-call", "第一版", "# v1", false);
+
+        assertEquals(400, assertThrows(ServerException.class,
+                () -> skillStore.save(root, "night-call", "第二版", "# v2", false)).getCode());
+        skillStore.save(root, "night-call", "第二版", "# v2", true);
+        assertEquals("第二版",
+                skillStore.read(root, "night-call", SkillStore.SOURCE_USER, true, true).getDescription());
+
+        assertEquals(400, assertThrows(ServerException.class,
+                () -> skillStore.save(root, "blank-body", "", "   ", false)).getCode());
+        assertEquals(400, assertThrows(ServerException.class,
+                () -> skillStore.save(root, "../escaped", "", "# x", false)).getCode());
+        assertFalse(Files.exists(root.getParent().resolve("escaped")), "越界目录不应被写出技能目录");
+    }
+
+    @Test
+    @DisplayName("保存技能:正文自带 frontmatter 时原样保留(用户粘贴完整 SKILL.md)")
+    void saveKeepsUserProvidedFrontmatter() throws IOException {
+        Path root = Files.createTempDirectory("skills-root-");
+        skillStore.save(root, "night-call", "ignored", SKILL_MD, false);
+
+        SkillDetailVO detail = skillStore.read(root, "night-call", SkillStore.SOURCE_USER, true, true);
+        assertEquals("夜间通话分析套路", detail.getDescription());
+        assertTrue(detail.getContent().startsWith("---\nname: night-call\n"));
+    }
+
     @Test
     @DisplayName("目录不存在时列表为空而不是报错(新用户还没装过技能)")
     void listOnMissingRoot() {