Selaa lähdekoodia

feat: 技能按目录规范新增/编辑,查看详情改只读弹框

技能落地 SKILL.md + scripts/references/assets/testcases 规范:新增/编辑弹框按规范目录分页签管理附属文件,
查看详情从只读抽屉改为与编辑同构的只读弹框;后端补齐附属文件读写与单文件内容接口,
并修正覆盖保存会删光未提交附属文件的问题。
cc 4 päivää sitten
vanhempi
commit
a28f7bb25b

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

@@ -9,7 +9,7 @@
 import { defHttp } from '@/core/utils/http/axios';
 import { AI_ADMIN_PATH } from '../utils/constants';
 import { aiHttp } from './http';
-import type { AgentTool, SaveSkillParams, Skill, SkillDetail } from './types';
+import type { AgentTool, SaveSkillParams, Skill, SkillDetail, SkillFileContent } from './types';
 
 /**
  * 工具清单(只读):读启动时同步到 `agent_tool` 的那份数据。
@@ -38,6 +38,18 @@ export function readSkill(name: string, caseId?: string | number | null): Promis
   );
 }
 
+/**
+ * 技能内单个文件的内容(详情文件树点开节点时按需读)。
+ *
+ * <p>文本返回 content;图片等二进制返回 base64(供预览);超过预览上限只回体积。
+ */
+export function readSkillFile(name: string, path: string, caseId?: string | number | null): Promise<SkillFileContent> {
+  return aiHttp.get<SkillFileContent>(`/capability/skills/${encodeURIComponent(name)}/file`, {
+    path,
+    ...(caseId ? { caseId: String(caseId) } : {}),
+  });
+}
+
 /**
  * 上传 zip 安装技能(服务端解压到当前登录用户的技能目录)。
  *

+ 27 - 1
ai-electron/frontend/src/ai/api/types.ts

@@ -95,13 +95,24 @@ export interface SaveToolPackParams {
   toolNames: string[];
 }
 
-/** 新建/修改技能参数(研判方案弹窗内「添加技能 / 修改技能」) */
+/** 新建/修改技能时的附属文件(相对技能目录的路径 + 文本内容) */
+export interface SaveSkillFile {
+  /** 相对技能目录的路径,如 scripts/run.py(不允许 .. 或绝对路径) */
+  path: string;
+  content: string;
+}
+
+/** 新建/修改技能参数(「插件与技能」页新增/编辑,以及研判方案弹窗内「修改技能」) */
 export interface SaveSkillParams {
   /** 技能名;修改时以后端路径参数为准,这里传同值占位校验 */
   name: string;
   description?: string;
   /** SKILL.md 正文(Markdown;自带 frontmatter 时后端原样保留) */
   content: string;
+  /** 附属文件:scripts/ references/ assets/ testcases/ 下的文本文件 */
+  files?: SaveSkillFile[];
+  /** 要删除的附属文件相对路径(编辑时用;未提交的文件保持原样) */
+  deletePaths?: string[];
 }
 
 /** 消息角色 */
@@ -556,3 +567,18 @@ export interface SkillDetail extends Skill {
   content?: string;
   files?: SkillFile[];
 }
+
+/** 技能内单个文件的内容(详情文件树点开节点时按需读取) */
+export interface SkillFileContent {
+  /** 相对技能目录的路径 */
+  path: string;
+  size?: number;
+  /** 二进制(true 时 content 为空;未超限时可用 base64 预览,如图片) */
+  binary?: boolean;
+  /** 超过预览上限,未返回内容 */
+  truncated?: boolean;
+  /** 文本内容(binary=false 时有效) */
+  content?: string;
+  /** 二进制内容的 base64(binary=true 且未超限时有效) */
+  base64?: string;
+}

+ 20 - 198
ai-electron/frontend/src/ai/components/AgentFormModal.vue

@@ -71,7 +71,8 @@
           </div>
           <div class="ae-form__hint"
             >不勾选 =
-            不限制(全部业务工具可用);勾选的工具包构成业务工具白名单,未选的工具对模型不可见。工具包在「插件与技能 → 工具包」维护。</div
+            不限制(全部业务工具可用);勾选的工具包构成业务工具白名单,未选的工具对模型不可见。工具包在「插件与技能 →
+            工具包」维护。</div
           >
           <a-spin :spinning="packsLoading">
             <div v-if="packs.length" class="ae-form__grid">
@@ -117,38 +118,10 @@
           </div>
           <div class="ae-form__hint"
             >不勾选 =
-            不限制(该账号安装的技能全部可用);勾选后该研判方案只能看到所选技能。技能可直接在这里修改(保存即写入你的技能目录)。</div
+            不限制(该账号安装的技能全部可用);勾选后该研判方案只能看到所选技能。技能的新增与编辑在「插件与技能 →
+            技能」页进行。</div
           >
 
-          <!-- 技能编辑器(修改技能):技能名锁定(目录名不可变) -->
-          <div v-if="skillEditor.open" class="ae-form__seditor">
-            <div class="ae-form__seditor-title">
-              {{ `修改技能:${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="ae-form__seditor-ops">
-              <a-button @click="closeSkillEditor">取消</a-button>
-              <a-button type="primary" :loading="skillEditor.saving" @click="saveSkill">
-                {{ skillEditor.editing ? '保存修改' : '创建技能' }}
-              </a-button>
-            </div>
-          </div>
-
           <a-spin :spinning="skillsLoading">
             <div v-if="filteredSkills.length" class="ae-form__grid">
               <div
@@ -163,16 +136,12 @@
                 <span class="ae-form__ck"><Icon icon="mdi:check" :size="11" /></span>
                 <div class="ae-form__card-name" :title="s.name">{{ s.name }}</div>
                 <div class="ae-form__card-desc">{{ s.description || '(无描述)' }}</div>
-                <div class="ae-form__card-ops">
-                  <span class="ae-form__sp"></span>
-                  <button type="button" class="ae-form__iconbtn" title="修改技能" @click.stop="openSkillEditor(s)">
-                    <Icon icon="mdi:pencil-outline" :size="14" />
-                  </button>
-                </div>
               </div>
             </div>
             <div v-else-if="skills.length" class="ae-form__hint"> 没有匹配「{{ skillKeyword.trim() }}」的技能。 </div>
-            <div v-else class="ae-form__hint"> 还没有技能。到「插件与技能」页上传 zip 安装后再回来勾选。 </div>
+            <div v-else class="ae-form__hint">
+              还没有技能。到「插件与技能」页「新增技能」或上传 zip 安装后再回来勾选。
+            </div>
           </a-spin>
           <div v-if="pickedSkills.length" class="ae-form__selbar">
             <span class="ae-form__sel-label">已选 {{ pickedSkills.length }} 个:</span>
@@ -198,23 +167,22 @@
    *    内置包勾选、保存写 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';
+  import { 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 * as toolPackApi from '../api/toolPackApi';
-  import type { AiExpert, SaveExpertParams, SaveSkillParams, Skill, ToolPack } from '../api/types';
+  import type { AiExpert, SaveExpertParams, Skill, ToolPack } from '../api/types';
   import { toolGroupLabel } from '../utils/constants';
 
-  const AButton = Button;
   const AForm = Form;
   const AFormItem = FormItem;
   const AInput = Input;
@@ -360,108 +328,6 @@
     pickedSkills.value.forEach((n) => delete checkedSkills[n]);
   }
 
-  /**
-   * 技能编辑器(「修改技能」子面板;新建技能在「插件与技能」页进行)。
-   * 修改直接写当前用户的技能目录,与研判方案是否保存无关。
-   */
-  const skillEditor = reactive({
-    open: false,
-    /** 正在修改的技能名 */
-    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) {
-    skillEditor.open = true;
-    skillEditor.editing = skill.name;
-    skillEditor.name = skill.name;
-    skillEditor.description = skill.description || '';
-    skillEditor.content = '';
-    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);
   }
@@ -516,6 +382,16 @@
       flex-direction: column;
       gap: var(--ai-sp-3);
       min-height: 0;
+
+      /*
+       * antd 的 form-item 默认留 24px 下边距(给校验提示留位)。
+       * 本弹框的字段名是自己画的 label(在 form-item 外面),再叠这 24px 会让行距凭空翻倍 ——
+       * 压平它,行距统一交给 __row 的 margin 控制;
+       * 校验失败时 explain 会把 form-item 撑高,不会压到下一行。
+       */
+      :deep(.ant-form-item) {
+        margin-bottom: 0;
+      }
     }
 
     /* ── 面板(白卡) ── */
@@ -764,37 +640,6 @@
       }
     }
 
-    &__card-ops {
-      display: flex;
-      align-items: center;
-      gap: var(--ai-sp-1);
-      padding-top: 8px;
-      margin-top: auto;
-      border-top: 1px solid var(--ai-border);
-    }
-
-    &__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-primary);
-        background: var(--ai-bg-subtle);
-      }
-    }
-
     /* ── 已选摘要条 ── */
     &__selbar {
       display: flex;
@@ -839,29 +684,6 @@
         background: rgba(207, 34, 46, 0.06);
       }
     }
-
-    /* ── 技能编辑器子面板 ── */
-    &__seditor {
-      display: flex;
-      flex-direction: column;
-      gap: 8px;
-      padding: 12px;
-      background: var(--ai-bg-subtle);
-      border: 1px solid var(--ai-primary);
-      border-radius: var(--ai-radius);
-    }
-
-    &__seditor-title {
-      font-size: 12.5px;
-      font-weight: 600;
-      color: var(--ai-text);
-    }
-
-    &__seditor-ops {
-      display: flex;
-      justify-content: flex-end;
-      gap: 8px;
-    }
   }
 </style>
 

+ 293 - 0
ai-electron/frontend/src/ai/components/SkillDetailModal.vue

@@ -0,0 +1,293 @@
+<template>
+  <BasicModal
+    :open="open"
+    :title="detail?.name || skill?.name || '技能详情'"
+    :width="900"
+    wrap-class-name="skill-detail-modal"
+    :footer="null"
+    :body-style="{ maxHeight: '64vh', overflowY: 'auto' }"
+    @cancel="close"
+  >
+    <a-spin :spinning="loading">
+      <div v-if="detail" class="skill-detail">
+        <p class="skill-detail__sub">
+          技能是一个目录:<b>SKILL.md</b> 必需(元数据 + 执行指令);<b>scripts/</b> <b>references/</b> <b>assets/</b>
+          <b>testcases/</b> 可选。下面是磁盘上的实际内容,只读。
+        </p>
+
+        <div class="skill-detail__field">
+          <label class="skill-detail__label">技能名</label>
+          <a-input :value="detail.name" readonly />
+        </div>
+        <div class="skill-detail__field">
+          <label class="skill-detail__label">描述</label>
+          <a-input :value="detail.description || '(未填写描述)'" readonly />
+        </div>
+
+        <a-tabs v-model:active-key="activeTab" size="small">
+          <a-tab-pane key="SKILL.md">
+            <template #tab>
+              <span>SKILL.md</span>
+              <span class="skill-detail__badge is-need">必需</span>
+            </template>
+            <div class="skill-detail__field">
+              <label class="skill-detail__label">正文(Markdown)</label>
+              <a-textarea :value="body" readonly :rows="14" />
+            </div>
+          </a-tab-pane>
+
+          <a-tab-pane v-for="dir in SKILL_STD_DIRS" :key="dir.key">
+            <template #tab>
+              <span>{{ dir.key }}/</span>
+              <span class="skill-detail__badge">{{ filesOf(dir.key).length }}</span>
+            </template>
+
+            <p class="skill-detail__dir-desc">{{ dir.desc }}</p>
+
+            <p v-if="!filesOf(dir.key).length" class="skill-detail__empty">该目录未使用</p>
+
+            <div v-for="file in filesOf(dir.key)" :key="file.path" class="skill-detail__file">
+              <div class="skill-detail__file-head">
+                <Icon icon="mdi:file-outline" :size="14" />
+                <span class="skill-detail__file-name" :title="file.path">{{ leafPath(file.path, dir.key) }}</span>
+                <span class="skill-detail__badge">{{ formatFileSize(file.size) }}</span>
+              </div>
+              <img
+                v-if="isImagePath(file.path) && contents[file.path]?.base64"
+                class="skill-detail__img"
+                :src="`data:${imageMimeOf(file.path)};base64,${contents[file.path].base64}`"
+                :alt="file.path"
+              />
+              <p v-else-if="contents[file.path]?.truncated" class="skill-detail__note">
+                文件较大({{ formatFileSize(contents[file.path]?.size) }}),超过预览上限,未展示内容。
+              </p>
+              <p v-else-if="contents[file.path]?.binary" class="skill-detail__note">
+                二进制文件({{ formatFileSize(contents[file.path]?.size) }}),不支持在线预览。
+              </p>
+              <a-textarea v-else :value="contents[file.path]?.content ?? ''" readonly :rows="7" />
+            </div>
+          </a-tab-pane>
+        </a-tabs>
+
+        <p v-if="foreignFiles.length" class="skill-detail__foreign">
+          规范目录之外的文件({{ foreignFiles.length }} 个):{{ foreignFiles.join('、') }}
+        </p>
+      </div>
+    </a-spin>
+  </BasicModal>
+</template>
+
+<script lang="ts" setup name="SkillDetailModal">
+  /**
+   * 技能详情(只读)—— 与「新增 / 编辑技能」弹框同构:
+   * 同样的 900 宽弹框、同样的 SKILL.md + scripts/references/assets/testcases 页签,
+   * 只是所有控件都只读、底部没有确认按钮。
+   *
+   * 打开时读一次详情(SKILL.md 正文 + 附属文件清单),再并发把每个附属文件的内容拉回来
+   * (文本走 content,图片走 base64)。技能文件通常个位数,一次拉完比点开再等更顺手;
+   * 单文件超过后端预览上限时只回体积,这里给明确提示。
+   */
+  import { computed, ref, watch } from 'vue';
+  import { Input, Spin, Tabs, 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 { readSkill, readSkillFile } from '../api/capabilityApi';
+  import type { Skill, SkillDetail, SkillFileContent } from '../api/types';
+  import {
+    SKILL_STD_DIRS,
+    SKILL_STD_DIR_KEYS,
+    formatFileSize,
+    imageMimeOf,
+    isImagePath,
+    leafPath,
+    splitSkillMd,
+    topDirOf,
+  } from '../utils/skill';
+
+  const AInput = Input;
+  const ATextarea = Textarea;
+  const ATabs = Tabs;
+  const ATabPane = Tabs.TabPane;
+  const ASpin = Spin;
+
+  const props = defineProps<{
+    open: boolean;
+    /** 要查看的技能;为空时不请求 */
+    skill?: Skill | null;
+    /** 案件ID,可选:只影响是否附带该案件配置的只读技能仓库 */
+    caseId?: string | number | null;
+  }>();
+  const emit = defineEmits<{
+    (e: 'update:open', value: boolean): void;
+  }>();
+
+  const { showMessage } = useMessage();
+
+  const detail = ref<SkillDetail | null>(null);
+  /** SKILL.md 正文(已剥掉 frontmatter,与编辑弹框展示的是同一段) */
+  const body = ref('');
+  /** 附属文件内容:相对路径 → 内容 */
+  const contents = ref<Record<string, SkillFileContent>>({});
+  const activeTab = ref<string>('SKILL.md');
+  const loading = ref(false);
+
+  function filesOf(dir: string) {
+    return (detail.value?.files ?? []).filter((f) => f.path.startsWith(`${dir}/`));
+  }
+
+  /** 规范目录之外的文件(例如根目录的 readme.txt):不在页签里,只在底部提示 */
+  const foreignFiles = computed(() =>
+    (detail.value?.files ?? []).map((f) => f.path).filter((p) => !SKILL_STD_DIR_KEYS.includes(topDirOf(p))),
+  );
+
+  async function load(skill: Skill) {
+    loading.value = true;
+    detail.value = null;
+    contents.value = {};
+    activeTab.value = 'SKILL.md';
+    try {
+      const data = await readSkill(skill.name, props.caseId);
+      detail.value = data;
+      body.value = splitSkillMd(data?.content || '').body;
+
+      const files = data?.files ?? [];
+      const loaded = await Promise.all(
+        files.map(async (f) => {
+          const content = await readSkillFile(skill.name, f.path, props.caseId).catch(() => null);
+          return [f.path, content] as const;
+        }),
+      );
+      const map: Record<string, SkillFileContent> = {};
+      for (const [path, content] of loaded) {
+        if (content) map[path] = content;
+      }
+      contents.value = map;
+    } catch (e: any) {
+      showMessage(e?.message || '读取技能失败', 'error');
+    } finally {
+      loading.value = false;
+    }
+  }
+
+  watch(
+    () => props.open,
+    (opened) => {
+      if (!opened || !props.skill) return;
+      void load(props.skill);
+    },
+  );
+
+  function close() {
+    emit('update:open', false);
+  }
+</script>
+
+<style lang="less" scoped>
+  /* 与 SkillEditorModal 保持同一套视觉:label 在上、控件在下 */
+  .skill-detail {
+    display: flex;
+    flex-direction: column;
+    gap: 10px;
+
+    &__sub {
+      margin: 0;
+      font-size: 12.5px;
+      line-height: 1.7;
+      color: var(--ai-text-2, #656d76);
+
+      b {
+        font-weight: 600;
+        color: var(--ai-text, #24292f);
+      }
+    }
+
+    &__field {
+      display: flex;
+      flex-direction: column;
+      gap: 4px;
+    }
+
+    &__label {
+      font-size: 12.5px;
+      color: var(--ai-text-2, #656d76);
+    }
+
+    &__badge {
+      margin-left: 4px;
+      padding: 0 5px;
+      font-size: 11px;
+      line-height: 16px;
+      color: var(--ai-text-3, #9aa1ac);
+      background: var(--ai-fill-2, #f2f3f5);
+      border-radius: 8px;
+
+      &.is-need {
+        color: #d4380d;
+        background: #fff2e8;
+      }
+    }
+
+    &__dir-desc {
+      margin: 0 0 8px;
+      font-size: 12px;
+      color: var(--ai-text-3, #9aa1ac);
+    }
+
+    &__empty {
+      margin: 0;
+      font-size: 12.5px;
+      color: var(--ai-text-3, #9aa1ac);
+    }
+
+    &__file {
+      margin-bottom: 10px;
+      padding: 8px 10px;
+      background: var(--ai-fill-1, #fafafa);
+      border: 1px solid var(--ai-border, #eaecef);
+      border-radius: 6px;
+    }
+
+    &__file-head {
+      display: flex;
+      gap: 6px;
+      align-items: center;
+      margin-bottom: 6px;
+      color: var(--ai-text-2, #656d76);
+    }
+
+    &__file-name {
+      flex: 1;
+      min-width: 0;
+      overflow: hidden;
+      font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace;
+      font-size: 12.5px;
+      color: var(--ai-text, #24292f);
+      white-space: nowrap;
+      text-overflow: ellipsis;
+    }
+
+    &__img {
+      display: block;
+      max-width: 100%;
+      max-height: 420px;
+      object-fit: contain;
+      background: #fff;
+      border: 1px solid var(--ai-border, #eaecef);
+      border-radius: 4px;
+    }
+
+    &__note {
+      margin: 0;
+      font-size: 12px;
+      color: var(--ai-text-3, #9aa1ac);
+    }
+
+    &__foreign {
+      margin: 0;
+      font-size: 12px;
+      line-height: 1.7;
+      color: var(--ai-text-3, #9aa1ac);
+    }
+  }
+</style>

+ 417 - 0
ai-electron/frontend/src/ai/components/SkillEditorModal.vue

@@ -0,0 +1,417 @@
+<template>
+  <BasicModal
+    :open="open"
+    :title="isEdit ? `编辑技能:${editing?.name}` : '新增技能'"
+    :width="900"
+    wrap-class-name="skill-create-modal"
+    :confirm-loading="saving"
+    :ok-text="isEdit ? '保存修改' : '创建技能'"
+    cancel-text="取消"
+    :body-style="{ maxHeight: '64vh', overflowY: 'auto' }"
+    @ok="save"
+    @cancel="close"
+  >
+    <a-spin :spinning="loading">
+      <div class="skill-create">
+        <p class="skill-create__sub">
+          技能是一个目录:<b>SKILL.md</b> 必需(元数据 + 执行指令);<b>scripts/</b> <b>references/</b> <b>assets/</b>
+          <b>testcases/</b> 可选。保存即写入当前账号的技能目录,下一轮对话就能被模型调用。
+        </p>
+
+        <div class="skill-create__field">
+          <label class="skill-create__label">
+            <i class="skill-create__req">*</i>
+            技能名
+          </label>
+          <a-input
+            v-model:value="name"
+            :disabled="isEdit"
+            :maxlength="64"
+            placeholder="中英文 / 数字 / ._-,最多 64 字符"
+          />
+        </div>
+        <div class="skill-create__field">
+          <label class="skill-create__label">描述</label>
+          <a-input
+            v-model:value="description"
+            :maxlength="500"
+            placeholder="一句话说明这个技能干什么(会出现在模型可见的技能目录里)"
+          />
+        </div>
+
+        <a-tabs v-model:active-key="activeTab" size="small">
+          <a-tab-pane key="SKILL.md">
+            <template #tab>
+              <span>SKILL.md</span>
+              <span class="skill-create__badge is-need">必需</span>
+            </template>
+            <div class="skill-create__field">
+              <label class="skill-create__label">
+                <i class="skill-create__req">*</i>
+                正文(Markdown)
+              </label>
+              <a-textarea
+                v-model:value="content"
+                :rows="14"
+                placeholder="这个技能什么时候用、分几步做、口径与注意事项"
+              />
+            </div>
+          </a-tab-pane>
+
+          <a-tab-pane v-for="dir in SKILL_STD_DIRS" :key="dir.key">
+            <template #tab>
+              <span>{{ dir.key }}/</span>
+              <span class="skill-create__badge">{{ filesOf(dir.key).length }}</span>
+            </template>
+
+            <p class="skill-create__dir-desc">{{ dir.desc }}</p>
+
+            <div v-for="file in filesOf(dir.key)" :key="file.uid" class="skill-create__file">
+              <div class="skill-create__file-head">
+                <Icon icon="mdi:file-outline" :size="14" />
+                <label class="skill-create__label is-inline">文件名</label>
+                <input v-model="file.name" class="skill-create__file-name" placeholder="如 run.py(可含子目录)" />
+                <span v-if="file.binary" class="skill-create__badge is-bin">
+                  {{ formatFileSize(file.size) }} · 二进制
+                </span>
+                <a-button type="link" size="small" danger @click="removeFile(file)">删除</a-button>
+              </div>
+              <div v-if="!file.binary" class="skill-create__field">
+                <label class="skill-create__label">内容</label>
+                <a-textarea v-model:value="file.content" :rows="7" placeholder="文件内容(文本)" />
+              </div>
+              <p v-else class="skill-create__file-hint">二进制文件不参与在线编辑,保存时原样保留。</p>
+            </div>
+
+            <a-button size="small" @click="addFile(dir.key)">
+              <template #icon><Icon icon="mdi:plus" :size="13" /></template>
+              添加文件
+            </a-button>
+          </a-tab-pane>
+        </a-tabs>
+
+        <p v-if="foreignFiles.length" class="skill-create__foreign">
+          规范目录之外的文件({{ foreignFiles.length }} 个)不参与在线编辑,保存时原样保留:{{
+            foreignFiles.join('、')
+          }}
+        </p>
+      </div>
+    </a-spin>
+  </BasicModal>
+</template>
+
+<script lang="ts" setup name="SkillEditorModal">
+  /**
+   * 技能编辑器(新增 / 编辑共用)—— 「插件与技能 → 技能」页的顶栏「新增技能」与卡片「编辑」。
+   *
+   * 按技能目录规范落地:SKILL.md 必需(技能名 + 描述 + 正文),
+   * scripts/ references/ assets/ testcases/ 可选(各目录下的文本文件)。
+   * 保存走 capabilityApi.createSkill / updateSkill,落到当前账号的技能目录。
+   *
+   * 编辑已有技能时:读详情后逐个拉文本文件内容(二进制只读展示),
+   * 保存只提交「本次新增或改动过」的文件 + 显式删除的路径,二进制与规范外文件原样保留 ——
+   * 后端 save 也是按「未提交的文件不动」的口径落盘的。
+   */
+  import { computed, ref, watch } from 'vue';
+  import { Button, Input, Spin, Tabs, 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 { createSkill, readSkill, readSkillFile, updateSkill } from '../api/capabilityApi';
+  import type { SaveSkillFile, Skill } from '../api/types';
+  import { SKILL_NAME_RE, SKILL_STD_DIR_KEYS, SKILL_STD_DIRS, formatFileSize, splitSkillMd } from '../utils/skill';
+
+  const AInput = Input;
+  const ATextarea = Textarea;
+  const ATabs = Tabs;
+  const ATabPane = Tabs.TabPane;
+  const AButton = Button;
+  const ASpin = Spin;
+
+  const props = defineProps<{
+    open: boolean;
+    /** 已安装的技能名,用于创建前的同名预判 */
+    existing?: string[];
+    /** 传入 = 编辑该技能;不传 / 为 null = 新建 */
+    editing?: Skill | null;
+  }>();
+  const emit = defineEmits<{
+    (e: 'update:open', value: boolean): void;
+    /** 保存成功(父层据此刷新技能清单) */
+    (e: 'saved', skill: Skill): void;
+  }>();
+
+  const { showMessage } = useMessage();
+
+  interface EditorFile {
+    uid: number;
+    /** 所属规范目录(scripts / references / assets / testcases) */
+    dir: string;
+    /** 目录内相对路径(可含子目录) */
+    name: string;
+    content: string;
+    /** 编辑已有技能时的原始路径(改名后用于删除旧文件) */
+    originalPath?: string;
+    /** 二进制文件:只读,保存时不提交(后端保留原文件) */
+    binary?: boolean;
+    size?: number;
+  }
+
+  const name = ref('');
+  const description = ref('');
+  const content = ref('');
+  const activeTab = ref<string>('SKILL.md');
+  const editorFiles = ref<EditorFile[]>([]);
+  /** 用户显式删除的已有文件(原始路径),保存时下发 deletePaths */
+  const removedPaths = ref<string[]>([]);
+  /** 规范之外的文件:不参与在线编辑,保存时原样保留,仅提示 */
+  const foreignFiles = ref<string[]>([]);
+  const saving = ref(false);
+  const loading = ref(false);
+
+  let uidSeed = 0;
+
+  const isEdit = computed(() => !!props.editing);
+
+  function filesOf(dir: string) {
+    return editorFiles.value.filter((f) => f.dir === dir);
+  }
+
+  function addFile(dir: string) {
+    editorFiles.value.push({ uid: ++uidSeed, dir, name: '', content: '' });
+  }
+
+  function removeFile(file: EditorFile) {
+    // 只有磁盘上已存在的文件才需要下发删除;新建后没保存过的直接丢掉即可
+    if (file.originalPath) removedPaths.value.push(file.originalPath);
+    editorFiles.value = editorFiles.value.filter((f) => f.uid !== file.uid);
+  }
+
+  function resetForm() {
+    name.value = '';
+    description.value = '';
+    content.value = '';
+    activeTab.value = 'SKILL.md';
+    editorFiles.value = [];
+    removedPaths.value = [];
+    foreignFiles.value = [];
+  }
+
+  /** 编辑模式:读详情 + 逐个拉文本文件内容(二进制只记路径,只读展示) */
+  async function loadForEdit(skill: Skill) {
+    loading.value = true;
+    try {
+      const detail = await readSkill(skill.name);
+      const split = splitSkillMd(detail?.content || '');
+      name.value = skill.name;
+      description.value = split.description || detail?.description || skill.description || '';
+      content.value = split.body;
+
+      const stdDirs = new Set<string>(SKILL_STD_DIR_KEYS);
+      const files: EditorFile[] = [];
+      const foreign: string[] = [];
+      for (const item of detail?.files ?? []) {
+        const idx = item.path.indexOf('/');
+        const top = idx > 0 ? item.path.slice(0, idx) : '';
+        if (!stdDirs.has(top)) {
+          foreign.push(item.path);
+          continue;
+        }
+        const file = await readSkillFile(skill.name, item.path).catch(() => null);
+        files.push({
+          uid: ++uidSeed,
+          dir: top,
+          name: item.path.slice(top.length + 1),
+          content: file?.content ?? '',
+          originalPath: item.path,
+          binary: file?.binary === true,
+          size: file?.size ?? item.size,
+        });
+      }
+      editorFiles.value = files;
+      foreignFiles.value = foreign;
+    } catch (e: any) {
+      showMessage(e?.message || '读取技能失败', 'error');
+    } finally {
+      loading.value = false;
+    }
+  }
+
+  // 每次打开都是干净表单:新建清空,编辑按磁盘上的真实内容预填
+  watch(
+    () => props.open,
+    (opened) => {
+      if (!opened) return;
+      resetForm();
+      if (props.editing) void loadForEdit(props.editing);
+    },
+  );
+
+  function close() {
+    emit('update:open', false);
+  }
+
+  async function save() {
+    // 技能名以路径参数为准(目录名不可变),编辑时忽略输入框
+    const skillName = (props.editing?.name || name.value).trim();
+    if (!isEdit.value) {
+      if (!skillName) return showMessage('请输入技能名', 'warning');
+      if (!SKILL_NAME_RE.test(skillName)) return showMessage('技能名只能含中英文、数字和 ._-,最多 64 字符', 'warning');
+      if (props.existing?.includes(skillName)) return showMessage('同名技能已存在', 'warning');
+    }
+    if (!content.value.trim()) return showMessage('请输入 SKILL.md 正文', 'warning');
+
+    const files: SaveSkillFile[] = [];
+    const deletePaths = new Set(removedPaths.value);
+    const seen = new Set<string>();
+    for (const file of editorFiles.value) {
+      // 二进制不进 JSON:后端 save 不删未提交的文件,原文件自然保留
+      if (file.binary) continue;
+      const leaf = file.name.trim().replace(/^\/+/, '');
+      if (!leaf) continue;
+      const path = `${file.dir}/${leaf}`;
+      if (seen.has(path)) return showMessage(`文件路径重复:${path}`, 'warning');
+      seen.add(path);
+      files.push({ path, content: file.content });
+      // 改了文件名:旧路径要删掉,否则会留下两份
+      if (file.originalPath && file.originalPath !== path) deletePaths.add(file.originalPath);
+    }
+
+    saving.value = true;
+    try {
+      const payload = {
+        name: skillName,
+        description: description.value.trim(),
+        content: content.value,
+        files,
+        deletePaths: [...deletePaths],
+      };
+      const skill = isEdit.value ? await updateSkill(skillName, payload) : await createSkill(payload);
+      showMessage(
+        isEdit.value ? `技能「${skill?.name || skillName}」已更新` : `技能「${skill?.name || skillName}」已创建`,
+        'success',
+      );
+      emit('saved', skill);
+      close();
+    } catch (e: any) {
+      // 同名已存在 / 名字含路径非法字符等由服务端拒绝,message 由 defHttp 统一弹
+      showMessage(e?.message || '保存技能失败', 'error');
+    } finally {
+      saving.value = false;
+    }
+  }
+</script>
+
+<style lang="less" scoped>
+  .skill-create {
+    display: flex;
+    flex-direction: column;
+    gap: 10px;
+
+    &__sub {
+      margin: 0;
+      font-size: 12.5px;
+      line-height: 1.7;
+      color: var(--ai-text-2, #656d76);
+
+      b {
+        font-weight: 600;
+        color: var(--ai-text, #24292f);
+      }
+    }
+
+    /* 表单项:label 在上、控件在下(单看 placeholder 不知道要填什么) */
+    &__field {
+      display: flex;
+      flex-direction: column;
+      gap: 4px;
+    }
+
+    &__label {
+      font-size: 12.5px;
+      color: var(--ai-text-2, #656d76);
+
+      &.is-inline {
+        flex: 0 0 auto;
+      }
+    }
+
+    &__req {
+      margin-right: 2px;
+      font-style: normal;
+      color: #d4380d;
+    }
+
+    &__badge {
+      margin-left: 4px;
+      padding: 0 5px;
+      font-size: 11px;
+      line-height: 16px;
+      color: var(--ai-text-3, #9aa1ac);
+      background: var(--ai-fill-2, #f2f3f5);
+      border-radius: 8px;
+
+      &.is-need {
+        color: #d4380d;
+        background: #fff2e8;
+      }
+
+      &.is-bin {
+        color: var(--ai-text-3, #9aa1ac);
+      }
+    }
+
+    &__dir-desc {
+      margin: 0 0 8px;
+      font-size: 12px;
+      color: var(--ai-text-3, #9aa1ac);
+    }
+
+    &__file {
+      margin-bottom: 10px;
+      padding: 8px 10px;
+      background: var(--ai-fill-1, #fafafa);
+      border: 1px solid var(--ai-border, #eaecef);
+      border-radius: 6px;
+    }
+
+    &__file-head {
+      display: flex;
+      gap: 6px;
+      align-items: center;
+      margin-bottom: 6px;
+      color: var(--ai-text-2, #656d76);
+    }
+
+    &__file-name {
+      flex: 1;
+      min-width: 0;
+      height: 26px;
+      padding: 0 8px;
+      font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace;
+      font-size: 12.5px;
+      color: var(--ai-text, #24292f);
+      background: #fff;
+      border: 1px solid var(--ai-border, #eaecef);
+      border-radius: 4px;
+      outline: none;
+
+      &:focus {
+        border-color: var(--ai-primary, #1677ff);
+      }
+    }
+
+    &__file-hint {
+      margin: 0;
+      font-size: 12px;
+      color: var(--ai-text-3, #9aa1ac);
+    }
+
+    &__foreign {
+      margin: 0;
+      font-size: 12px;
+      line-height: 1.7;
+      color: var(--ai-text-3, #9aa1ac);
+    }
+  }
+</style>

+ 111 - 0
ai-electron/frontend/src/ai/utils/skill.ts

@@ -0,0 +1,111 @@
+/**
+ * 技能目录规范与共用小工具。
+ *
+ * 「插件与技能 → 技能」的**查看详情**与**新增/编辑**两个弹框必须保持同一套口径
+ * (同样的目录规范、同样的 frontmatter 拆解、同样的体积展示),所以集中在这里,
+ * 避免两边各写一份后悄悄漂移。
+ */
+import type { SkillFile } from '../api/types';
+
+/** 四个可选规范子目录(顺序即页面展示顺序) */
+export const SKILL_STD_DIRS = [
+  { key: 'scripts', desc: '自动化脚本或工具调用逻辑' },
+  { key: 'references', desc: '长文档、规范、API 说明' },
+  { key: 'assets', desc: '模板、示例、图片、配置' },
+  { key: 'testcases', desc: '测试用例、验证数据' },
+] as const;
+
+/** 规范目录名集合:用来判断某个附属文件是否落在规范目录内 */
+export const SKILL_STD_DIR_KEYS: readonly string[] = SKILL_STD_DIRS.map((d) => d.key);
+
+/** 技能名合法字符(与后端 SkillStore.NAME_PATTERN 对齐:中英文/数字/._-,1~64) */
+export const SKILL_NAME_RE = /^[一-龥A-Za-z0-9._-]{1,64}$/;
+
+/** 文件大小展示 */
+export function formatFileSize(size?: number): string {
+  if (size == null) return '';
+  if (size < 1024) return `${size} B`;
+  if (size < 1024 * 1024) return `${(size / 1024).toFixed(1)} KB`;
+  return `${(size / 1024 / 1024).toFixed(1)} MB`;
+}
+
+/**
+ * 拆 SKILL.md 的 frontmatter:返回描述与正文;没有 frontmatter 时整段当正文。
+ *
+ * 后端 buildSkillMd 会把 description 用双引号包裹并转义,这里要还原,保证编辑往返一致。
+ */
+export 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 };
+}
+
+/** 文件所属的第一层目录;根目录散文件返回空串 */
+export function topDirOf(path: string): string {
+  const idx = path.indexOf('/');
+  return idx > 0 ? path.slice(0, idx) : '';
+}
+
+/** 去掉规范目录前缀后的目录内相对路径(文件行展示用) */
+export function leafPath(path: string, dirKey: string): string {
+  return path.startsWith(`${dirKey}/`) ? path.slice(dirKey.length + 1) : path;
+}
+
+/** 把附属文件按第一层目录分组,规范目录打头(空的也占位),其余目录按字典序排在后面 */
+export function groupSkillFiles(files: SkillFile[]) {
+  const byDir = new Map<string, SkillFile[]>();
+  for (const file of files) {
+    const dir = topDirOf(file.path);
+    const bucket = byDir.get(dir);
+    if (bucket) bucket.push(file);
+    else byDir.set(dir, [file]);
+  }
+  const rootFiles = byDir.get('') ?? [];
+  byDir.delete('');
+  const dirs = SKILL_STD_DIRS.map((d) => ({
+    key: d.key as string,
+    desc: d.desc as string,
+    files: byDir.get(d.key) ?? [],
+  }));
+  for (const d of SKILL_STD_DIRS) byDir.delete(d.key);
+  for (const [key, list] of [...byDir.entries()].sort((a, b) => a[0].localeCompare(b[0]))) {
+    dirs.push({ key, desc: '', files: list });
+  }
+  return { dirs, rootFiles };
+}
+
+/** 图片后缀 → mime。只列位图:svg 是文本、后端会当 content 返回,走不到 base64 分支 */
+const IMAGE_MIME: Record<string, string> = {
+  png: 'image/png',
+  jpg: 'image/jpeg',
+  jpeg: 'image/jpeg',
+  gif: 'image/gif',
+  webp: 'image/webp',
+  bmp: 'image/bmp',
+  ico: 'image/x-icon',
+};
+
+function extOf(path: string) {
+  const idx = path.lastIndexOf('.');
+  return idx > 0 ? path.slice(idx + 1).toLowerCase() : '';
+}
+
+/** 是否可用 <img> 预览的图片文件 */
+export function isImagePath(path: string): boolean {
+  return !!IMAGE_MIME[extOf(path)];
+}
+
+/** 图片文件的 mime(非图片回落到 octet-stream) */
+export function imageMimeOf(path: string): string {
+  return IMAGE_MIME[extOf(path)] || 'application/octet-stream';
+}

+ 73 - 95
ai-electron/frontend/src/ai/views/aiPlugin/index.vue

@@ -8,7 +8,11 @@
             <template #icon><Icon icon="mdi:refresh" :size="14" /></template>
             刷新
           </a-button>
-          <!-- 顶栏主操作按页签提供(与模型页同构):技能=上传 zip 安装,工具包=新建工具包;工具只读无顶栏操作 -->
+          <!-- 顶栏主操作按页签提供(与模型页同构):技能=新增技能 + 上传 zip 安装,工具包=新建工具包;工具只读无顶栏操作 -->
+          <a-button v-if="activeTab === 'skill'" :disabled="installing" @click="openSkillCreator">
+            <template #icon><Icon icon="mdi:plus" :size="14" /></template>
+            新增技能
+          </a-button>
           <a-button
             v-if="activeTab === 'skill'"
             type="primary"
@@ -53,11 +57,10 @@
 
               <p class="ai-plugin__card-desc">{{ skill.description || '(SKILL.md 里没有 description)' }}</p>
 
-              <div class="ai-plugin__sub ai-plugin__ellipsis" :title="skill.path">{{ skill.path }}</div>
-
               <div class="ai-plugin__card-ops">
-                <a-button type="link" size="small" :loading="skillLoading === skill.name" @click="openSkill(skill)">
-                  查看 SKILL.md
+                <a-button type="link" size="small" @click="openSkill(skill)">查看详情</a-button>
+                <a-button v-if="skill.managed" type="link" size="small" @click="openSkillEditor(skill)">
+                  编辑
                 </a-button>
                 <a-button v-if="skill.managed" type="link" size="small" danger @click="confirmRemove(skill)">
                   卸载
@@ -66,7 +69,8 @@
             </article>
           </div>
           <div v-else class="ai-plugin__empty">
-            还没有技能。上传一个包含 <b>SKILL.md</b> 的 zip 即可安装(包里只有一层技能目录时最省事)。
+            还没有技能。点右上角「新增技能」在页面上直接创建,或上传一个包含 <b>SKILL.md</b> 的 zip
+            安装(包里只有一层技能目录时最省事)。
           </div>
         </a-spin>
       </a-tab-pane>
@@ -208,20 +212,18 @@
           <a-select
             v-model:value="groupFilter"
             class="ai-plugin__select"
-            size="small"
             :options="groupOptions"
             placeholder="全部分类"
           />
           <a-input
             v-model:value="toolKeyword"
             class="ai-plugin__search"
-            size="small"
             allow-clear
             placeholder="搜索工具名,如 stat_call_night"
           >
             <template #prefix><Icon icon="mdi:magnify" :size="14" /></template>
           </a-input>
-          <span class="ai-plugin__mono ai-plugin__filter-count">
+          <span class="ai-plugin__filter-count">
             {{
               filteredTools.length === tools.length
                 ? `共 ${tools.length} 个`
@@ -321,28 +323,16 @@
       </div>
     </a-modal>
 
-    <!-- 抽屉:SKILL.md -->
-    <a-drawer
-      v-model:open="skillOpen"
-      :title="skillDetail?.name || 'SKILL.md'"
-      :width="720"
-      placement="right"
-      destroy-on-close
-    >
-      <div v-if="skillDetail" class="ai-plugin__preview">
-        <div class="ai-plugin__preview-meta">
-          <span class="ai-plugin__chip" :class="sourceMeta(skillDetail).cls">{{ sourceMeta(skillDetail).label }}</span>
-          <span class="ai-plugin__mono ai-plugin__ellipsis" :title="skillDetail.path">{{ skillDetail.path }}</span>
-        </div>
-        <div v-if="skillDetail.files?.length" class="ai-plugin__preview-files">
-          <span class="ai-plugin__mono">附属文件:</span>
-          <span v-for="file in skillDetail.files" :key="file.path" class="ai-plugin__chip" :title="`${file.size} 字节`">
-            {{ file.path }}
-          </span>
-        </div>
-        <pre class="ai-plugin__pre">{{ skillDetail.content }}</pre>
-      </div>
-    </a-drawer>
+    <!-- 弹框:新增 / 编辑技能(SKILL.md + scripts/ references/ assets/ testcases/ 附属文件) -->
+    <SkillEditorModal
+      v-model:open="skillCreateOpen"
+      :existing="skillNames"
+      :editing="skillEditing"
+      @saved="onSkillSaved"
+    />
+
+    <!-- 弹框:技能详情(只读,与「新增 / 编辑技能」同构) -->
+    <SkillDetailModal v-model:open="skillOpen" :skill="skillViewing" :case-id="caseId" />
   </div>
 </template>
 
@@ -361,20 +351,21 @@
    * 原「MCP 接入」页签已按需求移除(服务端 MCP Server 能力本身保留,仅不再在页面展示)。
    */
   import { computed, onMounted, reactive, ref } from 'vue';
-  import { Button, Checkbox, Drawer, Input, Modal, Select, Spin, Switch, Tabs } from 'ant-design-vue';
+  import { Button, Checkbox, Input, Modal, Select, Spin, Switch, Tabs } 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 { useUserStore } from '@/core/store/modules/case';
-  import { deleteSkill, installSkillZip, listSkills, listTools, readSkill, toggleSkill } from '../../api/capabilityApi';
+  import { deleteSkill, installSkillZip, listSkills, listTools, toggleSkill } from '../../api/capabilityApi';
   import { createToolPack, deleteToolPack, listToolPacks, updateToolPack } from '../../api/toolPackApi';
-  import type { AgentTool, SaveToolPackParams, Skill, SkillDetail, ToolPack } from '../../api/types';
+  import SkillDetailModal from '../../components/SkillDetailModal.vue';
+  import SkillEditorModal from '../../components/SkillEditorModal.vue';
+  import type { AgentTool, SaveToolPackParams, Skill, ToolPack } from '../../api/types';
 
   const ATabs = Tabs;
   const ATabPane = Tabs.TabPane;
   const AButton = Button;
   const ACheckbox = Checkbox;
-  const ADrawer = Drawer;
   const AInput = Input;
   const AModal = Modal;
   const ASelect = Select;
@@ -400,8 +391,12 @@
   const groupFilter = ref('');
 
   const skillOpen = ref(false);
-  const skillDetail = ref<SkillDetail | null>(null);
-  const skillLoading = ref('');
+  /** 正在查看的技能(详情弹框只读展示,内容由弹框自己按需拉取) */
+  const skillViewing = ref<Skill | null>(null);
+  /** 技能编辑器弹框(顶栏「新增技能」与卡片「编辑」共用) */
+  const skillCreateOpen = ref(false);
+  /** 正在编辑的技能;null = 新建 */
+  const skillEditing = ref<Skill | null>(null);
   const fileRef = ref<HTMLInputElement>();
 
   /** 当前案件ID:只用于附带展示「该案件 agent 配置的只读技能仓库」,技能本身与案件无关 */
@@ -411,6 +406,9 @@
     () => `${skills.value.length} 个技能 · ${packs.value.length} 个工具包 · ${tools.value.length} 个工具`,
   );
 
+  /** 已安装技能名:新增技能弹框创建前做同名预判(服务端仍会再校验一次) */
+  const skillNames = computed(() => skills.value.map((s) => s.name));
+
   /**
    * 分类下拉:按表里的 groupSort 排(业务组按注册顺序,基础工具最后),选项文案用组中文名。
    * 页面不再把工具按组折叠展示,分类只用于筛选。
@@ -487,10 +485,32 @@
       : { label: skill.managed ? '用户技能' : '技能', cls: '' };
   }
 
+  /**
+   * 技能目录规范、frontmatter 拆解、体积展示都收在 utils/skill 里 ——
+   * 详情弹框与编辑弹框必须同一套口径,这里不再各留一份。
+   */
+
   function pickZip() {
     fileRef.value?.click();
   }
 
+  /** 顶栏「新增技能」:清掉编辑态,走新建 */
+  function openSkillCreator() {
+    skillEditing.value = null;
+    skillCreateOpen.value = true;
+  }
+
+  /** 卡片「编辑」:带技能进编辑态(弹框打开后读磁盘上的真实内容预填) */
+  function openSkillEditor(skill: Skill) {
+    skillEditing.value = skill;
+    skillCreateOpen.value = true;
+  }
+
+  /** 新增/编辑保存成功:重载清单让卡片、描述与顶部计数跟上 */
+  async function onSkillSaved() {
+    await reload();
+  }
+
   async function onZipPicked(event: Event) {
     const input = event.target as HTMLInputElement;
     const file = input.files?.[0];
@@ -539,17 +559,12 @@
     });
   }
 
-  async function openSkill(skill: Skill) {
-    skillLoading.value = skill.name;
-    try {
-      skillDetail.value = await readSkill(skill.name, caseId.value);
-      skillOpen.value = true;
-    } catch {
-      // 读取失败原因由 defHttp 弹出
-    } finally {
-      skillLoading.value = '';
-    }
+  /** 打开技能详情:只把技能交出去,正文与附属文件由详情弹框自己按需拉取 */
+  function openSkill(skill: Skill) {
+    skillViewing.value = skill;
+    skillOpen.value = true;
   }
+
   /* ---------------- 工具包 ---------------- */
 
   const packs = ref<ToolPack[]>([]);
@@ -759,9 +774,12 @@
       border-radius: 999px;
     }
 
+    /* 筛选栏:三件套同一行。必须居中——flex 默认 stretch 会把下拉框外壳拉到行高,
+       内胆却仍是它自己的高度,看起来就是控件没对齐 */
     &__ops {
       display: flex;
       gap: var(--ai-sp-2);
+      align-items: center;
       margin-bottom: var(--ai-sp-2);
     }
 
@@ -871,16 +889,6 @@
       color: var(--ai-text);
     }
 
-    &__sub {
-      margin-top: 2px;
-      overflow: hidden;
-      font-family: 'SFMono-Regular', Consolas, monospace;
-      font-size: 11.5px;
-      color: var(--ai-text-2);
-      white-space: nowrap;
-      text-overflow: ellipsis;
-    }
-
     &__chip {
       display: inline-block;
       flex: 0 0 auto;
@@ -925,6 +933,11 @@
     /* 筛选栏:分类下拉 + 工具名搜索 + 计数 */
     &__select {
       min-width: 180px;
+
+      /* 下拉选择器圆角走 antd 默认 6px,与旁边 8px 的搜索框对不齐,这里统一到 --ai-radius */
+      :deep(.ant-select-selector) {
+        border-radius: var(--ai-radius);
+      }
     }
 
     &__search {
@@ -965,9 +978,12 @@
       }
     }
 
+    /* 计数:靠右单独一列,字号与两侧控件里的文字一致(原先是 mono 12px,中英文混排会窜字体) */
     &__filter-count {
       margin-left: auto;
+      font-size: 12.5px;
       white-space: nowrap;
+      color: var(--ai-text-2);
     }
 
     /* 工具卡片整卡可点开详情:手型 + 轻微上浮(与技能卡片的 hover 一致) */
@@ -1015,44 +1031,6 @@
       white-space: pre-wrap;
       word-break: break-word;
     }
-
-    &__preview {
-      display: flex;
-      flex-direction: column;
-      gap: var(--ai-sp-2);
-      min-height: 0;
-    }
-
-    &__preview-meta {
-      display: flex;
-      align-items: center;
-      gap: var(--ai-sp-2);
-      min-width: 0;
-    }
-
-    &__preview-files {
-      display: flex;
-      flex-wrap: wrap;
-      gap: var(--ai-sp-1);
-      align-items: center;
-      min-width: 0;
-    }
-
-    &__pre {
-      flex: 1 1 auto;
-      margin: 0;
-      padding: var(--ai-sp-3);
-      overflow: auto;
-      font-family: 'SFMono-Regular', Consolas, monospace;
-      font-size: 12.5px;
-      line-height: 1.8;
-      color: var(--ai-text);
-      white-space: pre-wrap;
-      word-break: break-word;
-      background: var(--ai-bg-subtle);
-      border: 1px solid var(--ai-border);
-      border-radius: var(--ai-radius);
-    }
   }
   /* ---------- 工具包:编辑弹框(表单 + 卡片勾选) ---------- */
   .ai-plugin__pack-form {

+ 30 - 4
ai-server/src/main/java/com/zsjz/ai/module/agent/controller/AgentCapabilityController.java

@@ -4,8 +4,10 @@ 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.skill.SkillStore;
 import com.zsjz.ai.module.agent.vo.McpServerInfoVO;
 import com.zsjz.ai.module.agent.vo.SkillDetailVO;
+import com.zsjz.ai.module.agent.vo.SkillFileContentVO;
 import com.zsjz.ai.module.agent.vo.AgentToolVO;
 import com.zsjz.ai.module.agent.vo.SkillVO;
 import jakarta.validation.Valid;
@@ -70,21 +72,45 @@ public class AgentCapabilityController {
     }
 
     /**
-     * 新建技能(JSON 直写 SKILL.md,免上传 zip)——「专家弹窗 → 添加技能」用。
+     * 技能内单个文件的内容:详情页文件树点开节点时用(文本返回正文,图片等返回 base64)。
+     *
+     * @param path 相对技能目录的路径(正斜杠),如 {@code references/style.md}
+     */
+    @GetMapping("/skills/{name}/file")
+    public Result<SkillFileContentVO> readSkillFile(@PathVariable String name,
+                                                    @RequestParam String path,
+                                                    @RequestParam(required = false) Long caseId) {
+        return Result.succeed(agentCapabilityService.readSkillFile(name, path, 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()));
+                dto.getName().trim(), dto.getDescription(), dto.getContent(),
+                toFileEntries(dto), dto.getDeletePaths()));
     }
 
     /**
-     * 修改技能(覆盖写 SKILL.md,技能名以路径参数为准)——「专家弹窗 → 修改技能」用。
+     * 修改技能(重写 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()));
+                name, dto.getDescription(), dto.getContent(),
+                toFileEntries(dto), dto.getDeletePaths()));
+    }
+
+    /** DTO 的附属文件 → 技能仓库的写入条目(DTO 只承载文本内容) */
+    private static List<SkillStore.FileEntry> toFileEntries(SaveSkillDTO dto) {
+        if (dto.getFiles() == null || dto.getFiles().isEmpty()) {
+            return List.of();
+        }
+        return dto.getFiles().stream()
+                .map(f -> new SkillStore.FileEntry(f.getPath(), f.getContent()))
+                .toList();
     }
 
     /**

+ 33 - 2
ai-server/src/main/java/com/zsjz/ai/module/agent/dto/SaveSkillDTO.java

@@ -1,13 +1,17 @@
 package com.zsjz.ai.module.agent.dto;
 
+import jakarta.validation.Valid;
 import jakarta.validation.constraints.NotBlank;
 import jakarta.validation.constraints.Size;
 import lombok.Data;
 
+import java.util.List;
+
 /**
- * 新建/修改技能请求 DTO(专家弹窗内的「添加技能 / 修改技能」)。
+ * 新建/修改技能请求 DTO(「插件与技能 → 技能」页的新增/编辑,以及专家弹窗内的「修改技能」)。
  *
- * <p>直接写 {@code SKILL.md}(frontmatter + 正文),免去打 zip 包;
+ * <p>技能按目录规范落地:{@code SKILL.md} 必需(本 DTO 的 description + content),
+ * {@code scripts/} {@code references/} {@code assets/} {@code testcases/} 可选(本 DTO 的 files)。
  * 修改时技能名以路径参数为准(目录名不可变,避免留下孤儿目录)。
  */
 @Data
@@ -26,4 +30,31 @@ public class SaveSkillDTO {
     @NotBlank(message = "技能正文不能为空")
     @Size(max = 1_048_576, message = "技能正文长度不能超过1MB")
     private String content;
+
+    /**
+     * 附属文件(可选):相对技能目录的路径 + 文本内容,如 {@code scripts/run.py}。
+     *
+     * <p>只支持文本 —— 二进制资源(图片等)走 zip 安装,避免 JSON 请求体里塞 base64。
+     */
+    @Valid
+    private List<FileItem> files;
+
+    /** 要删除的附属文件相对路径(可选,编辑时用;不会删除未提交的文件) */
+    private List<String> deletePaths;
+
+    /**
+     * 单个附属文件
+     */
+    @Data
+    public static class FileItem {
+
+        /** 相对技能目录的路径(正斜杠),不允许 {@code ..} 或绝对路径 */
+        @NotBlank(message = "附属文件路径不能为空")
+        @Size(max = 512, message = "附属文件路径过长")
+        private String path;
+
+        /** 文件内容(文本) */
+        @Size(max = 1_048_576, message = "单个附属文件内容不能超过1MB")
+        private String content;
+    }
 }

+ 19 - 4
ai-server/src/main/java/com/zsjz/ai/module/agent/service/AgentCapabilityService.java

@@ -2,7 +2,9 @@ package com.zsjz.ai.module.agent.service;
 
 import com.zsjz.ai.module.agent.vo.McpServerInfoVO;
 import com.zsjz.ai.module.agent.vo.SkillDetailVO;
+import com.zsjz.ai.module.agent.vo.SkillFileContentVO;
 import com.zsjz.ai.module.agent.vo.SkillVO;
+import com.zsjz.ai.module.agent.skill.SkillStore;
 import io.agentscope.core.skill.SkillFilter;
 import org.springframework.web.multipart.MultipartFile;
 
@@ -33,6 +35,13 @@ public interface AgentCapabilityService {
      */
     SkillDetailVO readSkill(String name, Long caseId);
 
+    /**
+     * 读取技能内单个文件的内容(详情文件树点开节点时用)。
+     *
+     * @param path 相对技能目录的路径(正斜杠),{@code SKILL.md} 也允许
+     */
+    SkillFileContentVO readSkillFile(String name, String path, Long caseId);
+
     /**
      * 上传 zip 安装技能到<b>当前登录用户</b>的技能目录。
      *
@@ -42,18 +51,24 @@ public interface AgentCapabilityService {
     SkillVO installSkillZip(MultipartFile file, boolean overwrite);
 
     /**
-     * 新建技能:直接写 SKILL.md(frontmatter + 正文)到当前用户技能目录,免打 zip 包。
+     * 新建技能:写 SKILL.md(frontmatter + 正文)与可选附属文件到当前用户技能目录,免打 zip 包。
      *
+     * @param files       附属文件(scripts/ references/ assets/ testcases/ 下的文本文件),可为空
+     * @param deletePaths 要删除的附属文件路径,可为空(新建时通常为空)
      * @return 创建后的技能
      */
-    SkillVO createSkill(String name, String description, String content);
+    SkillVO createSkill(String name, String description, String content,
+                        List<SkillStore.FileEntry> files, List<String> deletePaths);
 
     /**
-     * 修改技能(仅用户技能):覆盖写 SKILL.md;技能名以入参为准(与目录一致)。
+     * 修改技能(仅用户技能):重写 SKILL.md,并写入/删除附属文件;技能名以入参为准(与目录一致)。
      *
+     * @param files       要写入(或覆盖)的附属文件,可为空
+     * @param deletePaths 要删除的附属文件路径,可为空;未提交的文件保持原样
      * @return 保存后的技能
      */
-    SkillVO updateSkill(String name, String description, String content);
+    SkillVO updateSkill(String name, String description, String content,
+                        List<SkillStore.FileEntry> files, List<String> deletePaths);
 
     /**
      * 卸载技能(删目录)。

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

@@ -12,6 +12,7 @@ import com.zsjz.ai.module.agent.service.AgentService;
 import com.zsjz.ai.module.agent.skill.SkillStore;
 import com.zsjz.ai.module.agent.vo.McpServerInfoVO;
 import com.zsjz.ai.module.agent.vo.SkillDetailVO;
+import com.zsjz.ai.module.agent.vo.SkillFileContentVO;
 import com.zsjz.ai.module.agent.vo.SkillVO;
 import io.agentscope.core.skill.SkillFilter;
 import lombok.RequiredArgsConstructor;
@@ -150,6 +151,20 @@ public class AgentCapabilityServiceImpl implements AgentCapabilityService {
         throw new ServerException(404, "技能不存在: " + name);
     }
 
+    @Override
+    public SkillFileContentVO readSkillFile(String name, String relPath, Long caseId) {
+        Path userRoot = userSkillsPath();
+        if (skillStore.resolveDir(userRoot, name).isPresent()) {
+            return skillStore.readFile(userRoot, name, relPath);
+        }
+        for (Path repoDir : configuredRepoPaths(caseId)) {
+            if (skillStore.resolveDir(repoDir, name).isPresent()) {
+                return skillStore.readFile(repoDir, name, relPath);
+            }
+        }
+        throw new ServerException(404, "技能不存在: " + name);
+    }
+
     @Override
     public SkillVO installSkillZip(MultipartFile file, boolean overwrite) {
         if (file == null || file.isEmpty()) {
@@ -170,20 +185,22 @@ public class AgentCapabilityServiceImpl implements AgentCapabilityService {
     }
 
     @Override
-    public SkillVO createSkill(String name, String description, String content) {
+    public SkillVO createSkill(String name, String description, String content,
+                               List<SkillStore.FileEntry> files, List<String> deletePaths) {
         Path userRoot = userSkillsPath();
-        skillStore.save(userRoot, name, description, content, false);
+        skillStore.save(userRoot, name, description, content, files, deletePaths, false);
         return findUserSkill(userRoot, name);
     }
 
     @Override
-    public SkillVO updateSkill(String name, String description, String content) {
+    public SkillVO updateSkill(String name, String description, String content,
+                               List<SkillStore.FileEntry> files, List<String> deletePaths) {
         Path userRoot = userSkillsPath();
         // 只允许改用户技能:仓库技能只读(与删除的口径一致)
         if (skillStore.resolveDir(userRoot, name).isEmpty()) {
             throw new ServerException(404, "用户技能目录里没有这个技能: " + name);
         }
-        skillStore.save(userRoot, name, description, content, true);
+        skillStore.save(userRoot, name, description, content, files, deletePaths, true);
         return findUserSkill(userRoot, name);
     }
 

+ 178 - 6
ai-server/src/main/java/com/zsjz/ai/module/agent/skill/SkillStore.java

@@ -2,6 +2,7 @@ package com.zsjz.ai.module.agent.skill;
 
 import com.zsjz.ai.common.exception.ServerException;
 import com.zsjz.ai.module.agent.vo.SkillDetailVO;
+import com.zsjz.ai.module.agent.vo.SkillFileContentVO;
 import com.zsjz.ai.module.agent.vo.SkillVO;
 import lombok.extern.slf4j.Slf4j;
 import org.springframework.stereotype.Component;
@@ -9,10 +10,14 @@ import org.springframework.stereotype.Component;
 import java.io.ByteArrayInputStream;
 import java.io.IOException;
 import java.io.InputStream;
+import java.nio.ByteBuffer;
+import java.nio.charset.CharacterCodingException;
+import java.nio.charset.CodingErrorAction;
 import java.nio.charset.StandardCharsets;
 import java.nio.file.Files;
 import java.nio.file.Path;
 import java.util.ArrayList;
+import java.util.Base64;
 import java.util.Comparator;
 import java.util.List;
 import java.util.Optional;
@@ -61,12 +66,24 @@ public class SkillStore {
     /** frontmatter 解析的读取上限:技能定义是说明文档,超过这个体积的多半不是技能 */
     private static final long MAX_SKILL_MD_BYTES = 1024 * 1024;
 
+    /** 单个附属文件的预览上限:超过只回体积,不下发内容(图片预览的 base64 也受此约束) */
+    private static final long MAX_PREVIEW_BYTES = 2L * 1024 * 1024;
+
     /** 技能来源:用户技能目录(页面可管理) */
     public static final String SOURCE_USER = "user";
 
     /** 技能来源:agent 配置的技能仓库(只读) */
     public static final String SOURCE_REPO = "repo";
 
+    /**
+     * 附属文件写入条目:相对技能目录的路径 + 文本内容。
+     *
+     * <p>只支持文本(scripts/references/testcases 里的脚本与文档,以及文本类 assets);
+     * 二进制资源请走 zip 安装,避免 JSON 里塞 base64 把请求体撑爆。
+     */
+    public record FileEntry(String path, String content) {
+    }
+
     /**
      * 列出目录下的全部技能。
      *
@@ -124,6 +141,50 @@ public class SkillStore {
         return vo;
     }
 
+    /**
+     * 读取技能内单个文件的内容 —— 技能详情文件树点开节点时用。
+     *
+     * <p>文本文件返回 {@code content};二进制(图片、压缩包等)返回 {@code base64} 供前端预览;
+     * 超过 {@value #MAX_PREVIEW_BYTES} 字节只回体积({@code truncated=true}),避免详情接口被大文件拖垮。
+     * 路径按不可信输入处理,挡住 {@code ..} 与绝对路径。
+     */
+    public SkillFileContentVO readFile(Path skillsRoot, String name, String relPath) {
+        Path dir = resolveDir(skillsRoot, name)
+                .orElseThrow(() -> new ServerException(404, "技能不存在: " + name));
+        if (relPath == null || relPath.isBlank()) {
+            throw new ServerException(400, "文件路径不能为空");
+        }
+        Path file = resolveInside(dir, relPath);
+        if (!Files.isRegularFile(file)) {
+            throw new ServerException(404, "文件不存在: " + relPath);
+        }
+
+        SkillFileContentVO vo = new SkillFileContentVO();
+        vo.setPath(dir.relativize(file).toString().replace('\\', '/'));
+        vo.setBinary(false);
+        vo.setTruncated(false);
+        try {
+            long size = Files.size(file);
+            vo.setSize(size);
+            if (size > MAX_PREVIEW_BYTES) {
+                vo.setBinary(true);
+                vo.setTruncated(true);
+                return vo;
+            }
+            byte[] bytes = Files.readAllBytes(file);
+            String text = decodeUtf8(bytes);
+            if (text != null) {
+                vo.setContent(text);
+            } else {
+                vo.setBinary(true);
+                vo.setBase64(Base64.getEncoder().encodeToString(bytes));
+            }
+            return vo;
+        } catch (IOException e) {
+            throw new ServerException(500, "读取文件失败: " + e.getMessage());
+        }
+    }
+
     /**
      * 安装一个技能包(zip 字节);返回安装后的技能名。
      *
@@ -189,6 +250,23 @@ public class SkillStore {
      * @return 技能名
      */
     public String save(Path skillsRoot, String name, String description, String content, boolean overwrite) {
+        return save(skillsRoot, name, description, content, null, null, overwrite);
+    }
+
+    /**
+     * 新建/覆盖一个技能(含附属文件)—— 「插件与技能」页新增/编辑技能的落地方法。
+     *
+     * <p>覆盖({@code overwrite=true})时<b>不删除技能目录</b>:只重写 SKILL.md、写入本次提交的
+     * 附属文件、删除 {@code deletePaths} 指定的文件。这样页面没动过的文件(例如 assets 里的图片,
+     * 二进制不便在前端往返)会被原样保留 —— 旧实现整目录删重建,编辑一次就会把附属文件全丢掉。
+     *
+     * @param files       要写入(或覆盖)的附属文件,可为空
+     * @param deletePaths 要删除的附属文件相对路径,可为空
+     * @param overwrite   false = 同名已存在则报错(新建);true = 覆盖(编辑)
+     * @return 技能名
+     */
+    public String save(Path skillsRoot, String name, String description, String content,
+                       List<FileEntry> files, List<String> deletePaths, boolean overwrite) {
         validateName(name);
         if (content == null || content.isBlank()) {
             throw new ServerException(400, "技能正文不能为空");
@@ -199,16 +277,35 @@ public class SkillStore {
             if (!dir.startsWith(skillsRoot.normalize())) {
                 throw new ServerException(400, "技能名非法: " + name);
             }
-            if (Files.exists(dir)) {
-                if (!overwrite) {
-                    throw new ServerException(400, "同名技能已存在: " + name);
-                }
-                deleteDirectory(dir);
+            if (Files.exists(dir) && !overwrite) {
+                throw new ServerException(400, "同名技能已存在: " + name);
             }
             Files.createDirectories(dir);
             Files.writeString(dir.resolve(SKILL_FILE),
                     buildSkillMd(name, description, content), StandardCharsets.UTF_8);
-            log.info("技能已{}: {} -> {}", overwrite ? "保存" : "创建", name, dir);
+
+            // 先删后写:同一路径既在 deletePaths 又在 files 时以 files 为准
+            for (String rel : nullToEmpty(deletePaths)) {
+                if (rel == null || rel.isBlank() || SKILL_FILE.equals(normalizeRel(rel))) {
+                    continue;
+                }
+                Files.deleteIfExists(resolveInside(dir, rel));
+            }
+            for (FileEntry entry : nullToEmpty(files)) {
+                if (entry == null || entry.path() == null || entry.path().isBlank()) {
+                    continue;
+                }
+                if (SKILL_FILE.equals(normalizeRel(entry.path()))) {
+                    throw new ServerException(400, "SKILL.md 请用正文编辑,不能作为附属文件提交");
+                }
+                Path target = resolveInside(dir, entry.path());
+                Files.createDirectories(target.getParent());
+                Files.writeString(target, entry.content() == null ? "" : entry.content(),
+                        StandardCharsets.UTF_8);
+            }
+            pruneEmptyDirs(dir);
+            log.info("技能已{}: {} -> {}(附属文件 {} 个)", overwrite ? "保存" : "创建", name, dir,
+                    files == null ? 0 : files.size());
             return name;
         } catch (IOException e) {
             throw new ServerException(500, "保存技能失败: " + e.getMessage());
@@ -394,6 +491,81 @@ public class SkillStore {
         }
     }
 
+    /**
+     * 把「相对技能目录的路径」解析成绝对路径,并挡住路径穿越。
+     *
+     * <p>这些路径来自页面输入,必须当作不可信数据 —— 否则 {@code ../../x} 能写到技能目录之外。
+     */
+    private static Path resolveInside(Path baseDir, String relPath) {
+        String rel = normalizeRel(relPath);
+        if (rel.isEmpty() || rel.startsWith("/") || rel.matches("^[A-Za-z]:.*")) {
+            throw new ServerException(400, "附属文件路径非法: " + relPath);
+        }
+        for (String seg : rel.split("/")) {
+            if ("..".equals(seg)) {
+                throw new ServerException(400, "附属文件路径非法: " + relPath);
+            }
+        }
+        Path target = baseDir.resolve(rel).normalize();
+        if (!target.startsWith(baseDir.normalize())) {
+            throw new ServerException(400, "附属文件路径非法: " + relPath);
+        }
+        return target;
+    }
+
+    /** 相对路径统一成「正斜杠、无首尾空白」,便于与 SKILL.md 之类的常量比较 */
+    private static String normalizeRel(String relPath) {
+        return relPath == null ? "" : relPath.strip().replace('\\', '/');
+    }
+
+    private static <T> List<T> nullToEmpty(List<T> list) {
+        return list == null ? List.of() : list;
+    }
+
+    /** 删除技能目录下的空目录(删掉附属文件后可能残留空壳,留着会误导「该目录已使用」) */
+    private void pruneEmptyDirs(Path skillDir) throws IOException {
+        List<Path> dirs;
+        try (Stream<Path> walk = Files.walk(skillDir)) {
+            dirs = walk.filter(Files::isDirectory)
+                    .filter(p -> !p.equals(skillDir))
+                    .sorted(Comparator.reverseOrder())
+                    .toList();
+        }
+        for (Path dir : dirs) {
+            try (Stream<Path> children = Files.list(dir)) {
+                if (children.findAny().isEmpty()) {
+                    Files.deleteIfExists(dir);
+                }
+            }
+        }
+    }
+
+    /**
+     * 按 UTF-8 严格解码;含 NUL 字节或非法序列即判定为二进制,返回 null。
+     *
+     * <p>用「能不能解码」而不是扩展名白名单判断:技能包里的脚本没有固定后缀
+     * (.py/.js/.sh/.md/.csv…),扩展名白名单一定会漏。
+     */
+    private static String decodeUtf8(byte[] bytes) {
+        if (bytes.length == 0) {
+            return "";
+        }
+        for (byte b : bytes) {
+            if (b == 0) {
+                return null;
+            }
+        }
+        try {
+            return StandardCharsets.UTF_8.newDecoder()
+                    .onMalformedInput(CodingErrorAction.REPORT)
+                    .onUnmappableCharacter(CodingErrorAction.REPORT)
+                    .decode(ByteBuffer.wrap(bytes))
+                    .toString();
+        } catch (CharacterCodingException e) {
+            return null;
+        }
+    }
+
     /**
      * 读 SKILL.md 的 frontmatter(只取 name / description)。
      *

+ 31 - 0
ai-server/src/main/java/com/zsjz/ai/module/agent/vo/SkillFileContentVO.java

@@ -0,0 +1,31 @@
+package com.zsjz.ai.module.agent.vo;
+
+import lombok.Data;
+
+/**
+ * 技能附属文件内容响应VO —— 技能详情文件树点开单个文件时用。
+ *
+ * <p>文本文件返回 {@code content};二进制(图片、压缩包等)返回 {@code base64} 供前端预览;
+ * 超过预览上限时只回体积,置 {@code truncated=true}(不下发内容,避免详情接口被大文件拖垮)。
+ */
+@Data
+public class SkillFileContentVO {
+
+    /** 相对技能目录的路径(正斜杠,如 {@code references/style.md}) */
+    private String path;
+
+    /** 字节数 */
+    private Long size;
+
+    /** 是否二进制(true 时 {@code content} 为空) */
+    private Boolean binary;
+
+    /** 是否因超过预览上限而未返回内容 */
+    private Boolean truncated;
+
+    /** 文本内容({@code binary=false} 时有效) */
+    private String content;
+
+    /** 二进制内容的 base64({@code binary=true} 且未超限时有效,供前端图片预览) */
+    private String base64;
+}

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

@@ -2,6 +2,7 @@ package com.zsjz.ai.module.agent.skill;
 
 import com.zsjz.ai.common.exception.ServerException;
 import com.zsjz.ai.module.agent.vo.SkillDetailVO;
+import com.zsjz.ai.module.agent.vo.SkillFileContentVO;
 import com.zsjz.ai.module.agent.vo.SkillVO;
 import org.junit.jupiter.api.DisplayName;
 import org.junit.jupiter.api.Test;
@@ -11,12 +12,14 @@ import java.io.IOException;
 import java.nio.charset.StandardCharsets;
 import java.nio.file.Files;
 import java.nio.file.Path;
+import java.util.Base64;
 import java.util.List;
 import java.util.zip.ZipEntry;
 import java.util.zip.ZipOutputStream;
 
 import static org.junit.jupiter.api.Assertions.assertEquals;
 import static org.junit.jupiter.api.Assertions.assertFalse;
+import static org.junit.jupiter.api.Assertions.assertNull;
 import static org.junit.jupiter.api.Assertions.assertThrows;
 import static org.junit.jupiter.api.Assertions.assertTrue;
 
@@ -213,4 +216,78 @@ class SkillStoreTest {
         assertTrue(skillStore.list(missing, SkillStore.SOURCE_USER, true, true).isEmpty());
         assertTrue(skillStore.disabledNames(missing).isEmpty());
     }
+
+    @Test
+    @DisplayName("保存技能:附属文件按规范写进 scripts/references/testcases,并能逐个读回")
+    void saveWithAttachmentFiles() throws IOException {
+        Path root = Files.createTempDirectory("skills-root-");
+        skillStore.save(root, "with-files", "带附属文件", "# 正文",
+                List.of(new SkillStore.FileEntry("scripts/run.py", "print('hi')"),
+                        new SkillStore.FileEntry("references/spec.md", "# 规范"),
+                        new SkillStore.FileEntry("testcases/case1.json", "{}")),
+                null, false);
+
+        assertTrue(Files.isRegularFile(root.resolve("with-files/scripts/run.py")));
+
+        SkillFileContentVO file = skillStore.readFile(root, "with-files", "scripts/run.py");
+        assertFalse(file.getBinary());
+        assertEquals("scripts/run.py", file.getPath());
+        assertEquals("print('hi')", file.getContent());
+
+        SkillDetailVO detail = skillStore.read(root, "with-files", SkillStore.SOURCE_USER, true, true);
+        assertEquals(List.of("references/spec.md", "scripts/run.py", "testcases/case1.json"),
+                detail.getFiles().stream().map(SkillDetailVO.FileItem::getPath).toList());
+    }
+
+    @Test
+    @DisplayName("保存技能:附属文件路径穿越被拒,SKILL.md 不允许当附属文件提交")
+    void saveRejectsIllegalAttachmentPath() throws IOException {
+        Path root = Files.createTempDirectory("skills-root-");
+        assertEquals(400, assertThrows(ServerException.class,
+                () -> skillStore.save(root, "evil", "", "# x",
+                        List.of(new SkillStore.FileEntry("../escaped.py", "x")), null, false)).getCode());
+        assertFalse(Files.exists(root.getParent().resolve("escaped.py")), "越界文件不应被写出技能目录");
+
+        assertEquals(400, assertThrows(ServerException.class,
+                () -> skillStore.save(root, "evil2", "", "# x",
+                        List.of(new SkillStore.FileEntry("SKILL.md", "x")), null, false)).getCode());
+    }
+
+    @Test
+    @DisplayName("编辑技能:只增删本次提交的附属文件,未提交的保持原样(旧实现会整目录删重建)")
+    void updateKeepsUnsubmittedFiles() throws IOException {
+        Path root = Files.createTempDirectory("skills-root-");
+        skillStore.save(root, "keep", "v1", "# v1",
+                List.of(new SkillStore.FileEntry("scripts/a.py", "a"),
+                        new SkillStore.FileEntry("assets/logo.png", "pseudo-binary")),
+                null, false);
+
+        skillStore.save(root, "keep", "v2", "# v2",
+                List.of(new SkillStore.FileEntry("scripts/a.py", "a2")),
+                List.of("assets/logo.png"), true);
+
+        assertEquals("a2", skillStore.readFile(root, "keep", "scripts/a.py").getContent());
+        assertFalse(Files.exists(root.resolve("keep/assets/logo.png")));
+        assertFalse(Files.exists(root.resolve("keep/assets")), "删空后不应残留空目录");
+        assertEquals("v2",
+                skillStore.read(root, "keep", SkillStore.SOURCE_USER, true, true).getDescription());
+    }
+
+    @Test
+    @DisplayName("读取附属文件:二进制返回 base64;不存在的文件报 404")
+    void readFileBinaryAndMissing() throws IOException {
+        Path root = Files.createTempDirectory("skills-root-");
+        skillStore.save(root, "bin", "d", "# x", null, null, false);
+        byte[] png = new byte[] {(byte) 0x89, 'P', 'N', 'G', 0x00, 0x01, 0x02};
+        Files.createDirectories(root.resolve("bin/assets"));
+        Files.write(root.resolve("bin/assets/logo.png"), png);
+
+        SkillFileContentVO vo = skillStore.readFile(root, "bin", "assets/logo.png");
+        assertTrue(vo.getBinary());
+        assertEquals(Base64.getEncoder().encodeToString(png), vo.getBase64());
+        assertNull(vo.getContent());
+
+        assertEquals(404, assertThrows(ServerException.class,
+                () -> skillStore.readFile(root, "bin", "assets/missing.png")).getCode());
+    }
 }