SkillEditorModal.vue 14 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417
  1. <template>
  2. <BasicModal
  3. :open="open"
  4. :title="isEdit ? `编辑技能:${editing?.name}` : '新增技能'"
  5. :width="900"
  6. wrap-class-name="skill-create-modal"
  7. :confirm-loading="saving"
  8. :ok-text="isEdit ? '保存修改' : '创建技能'"
  9. cancel-text="取消"
  10. :body-style="{ maxHeight: '64vh', overflowY: 'auto' }"
  11. @ok="save"
  12. @cancel="close"
  13. >
  14. <a-spin :spinning="loading">
  15. <div class="skill-create">
  16. <p class="skill-create__sub">
  17. 技能由 <b>SKILL.md</b>(元数据 + 执行指令)和可选附属文件 <b>scripts/</b> <b>references/</b>
  18. 组成。保存即写入当前账号的技能库,下一轮对话就能被模型调用。
  19. </p>
  20. <div class="skill-create__field">
  21. <label class="skill-create__label">
  22. <i class="skill-create__req">*</i>
  23. 技能名
  24. </label>
  25. <a-input
  26. v-model:value="name"
  27. :disabled="isEdit"
  28. :maxlength="64"
  29. placeholder="小写字母 / 数字 / 短横线,最多 64 字符"
  30. />
  31. </div>
  32. <div class="skill-create__field">
  33. <label class="skill-create__label">描述</label>
  34. <a-input
  35. v-model:value="description"
  36. :maxlength="500"
  37. placeholder="一句话说明这个技能干什么(会出现在模型可见的技能目录里)"
  38. />
  39. </div>
  40. <a-tabs v-model:active-key="activeTab" size="small">
  41. <a-tab-pane key="SKILL.md">
  42. <template #tab>
  43. <span>SKILL.md</span>
  44. <span class="skill-create__badge is-need">必需</span>
  45. </template>
  46. <div class="skill-create__field">
  47. <label class="skill-create__label">
  48. <i class="skill-create__req">*</i>
  49. 正文(Markdown)
  50. </label>
  51. <a-textarea
  52. v-model:value="content"
  53. :rows="14"
  54. placeholder="这个技能什么时候用、分几步做、口径与注意事项"
  55. />
  56. </div>
  57. </a-tab-pane>
  58. <a-tab-pane v-for="dir in SKILL_STD_DIRS" :key="dir.key">
  59. <template #tab>
  60. <span>{{ dir.key }}/</span>
  61. <span class="skill-create__badge">{{ filesOf(dir.key).length }}</span>
  62. </template>
  63. <p class="skill-create__dir-desc">{{ dir.desc }}</p>
  64. <div v-for="file in filesOf(dir.key)" :key="file.uid" class="skill-create__file">
  65. <div class="skill-create__file-head">
  66. <Icon icon="mdi:file-outline" :size="14" />
  67. <label class="skill-create__label is-inline">文件名</label>
  68. <input v-model="file.name" class="skill-create__file-name" placeholder="如 run.py(可含子目录)" />
  69. <span v-if="file.binary" class="skill-create__badge is-bin">
  70. {{ formatFileSize(file.size) }} · 二进制
  71. </span>
  72. <a-button type="link" size="small" danger @click="removeFile(file)">删除</a-button>
  73. </div>
  74. <div v-if="!file.binary" class="skill-create__field">
  75. <label class="skill-create__label">内容</label>
  76. <a-textarea v-model:value="file.content" :rows="7" placeholder="文件内容(文本)" />
  77. </div>
  78. <p v-else class="skill-create__file-hint">二进制文件不参与在线编辑,保存时原样保留。</p>
  79. </div>
  80. <a-button size="small" @click="addFile(dir.key)">
  81. <template #icon><Icon icon="mdi:plus" :size="13" /></template>
  82. 添加文件
  83. </a-button>
  84. </a-tab-pane>
  85. </a-tabs>
  86. <p v-if="foreignFiles.length" class="skill-create__foreign">
  87. 规范目录之外的文件({{ foreignFiles.length }} 个)不参与在线编辑,保存时原样保留:{{
  88. foreignFiles.join('、')
  89. }}
  90. </p>
  91. </div>
  92. </a-spin>
  93. </BasicModal>
  94. </template>
  95. <script lang="ts" setup name="SkillEditorModal">
  96. /**
  97. * 技能编辑器(新增 / 编辑共用)—— 「插件与技能 → 技能」页的顶栏「新增技能」与卡片「编辑」。
  98. *
  99. * 按技能目录规范落地:SKILL.md 必需(技能名 + 描述 + 正文),
  100. * scripts/ references/ 可选(各目录下的文本文件)。
  101. * 保存走 capabilityApi.createSkill / updateSkill,落到当前账号的技能目录。
  102. *
  103. * 编辑已有技能时:读详情后逐个拉文本文件内容(二进制只读展示),
  104. * 保存只提交「本次新增或改动过」的文件 + 显式删除的路径,二进制与规范外文件原样保留 ——
  105. * 后端 save 也是按「未提交的文件不动」的口径落盘的。
  106. */
  107. import { computed, ref, watch } from 'vue';
  108. import { Button, Input, Spin, Tabs, Textarea } from 'ant-design-vue';
  109. import { BasicModal } from '@/core/components/Modal';
  110. import Icon from '@/core/components/Icon/src/Icon.vue';
  111. import { useMessage } from '@/core/hooks/web/useMessage';
  112. import { createSkill, readSkill, readSkillFile, updateSkill } from '../api/capabilityApi';
  113. import type { SaveSkillFile, Skill } from '../api/types';
  114. import { SKILL_NAME_RE, SKILL_STD_DIR_KEYS, SKILL_STD_DIRS, formatFileSize, splitSkillMd } from '../utils/skill';
  115. const AInput = Input;
  116. const ATextarea = Textarea;
  117. const ATabs = Tabs;
  118. const ATabPane = Tabs.TabPane;
  119. const AButton = Button;
  120. const ASpin = Spin;
  121. const props = defineProps<{
  122. open: boolean;
  123. /** 已安装的技能名,用于创建前的同名预判 */
  124. existing?: string[];
  125. /** 传入 = 编辑该技能;不传 / 为 null = 新建 */
  126. editing?: Skill | null;
  127. }>();
  128. const emit = defineEmits<{
  129. (e: 'update:open', value: boolean): void;
  130. /** 保存成功(父层据此刷新技能清单) */
  131. (e: 'saved', skill: Skill): void;
  132. }>();
  133. const { showMessage } = useMessage();
  134. interface EditorFile {
  135. uid: number;
  136. /** 所属规范目录(scripts / references) */
  137. dir: string;
  138. /** 目录内相对路径(可含子目录) */
  139. name: string;
  140. content: string;
  141. /** 编辑已有技能时的原始路径(改名后用于删除旧文件) */
  142. originalPath?: string;
  143. /** 二进制文件:只读,保存时不提交(后端保留原文件) */
  144. binary?: boolean;
  145. size?: number;
  146. }
  147. const name = ref('');
  148. const description = ref('');
  149. const content = ref('');
  150. const activeTab = ref<string>('SKILL.md');
  151. const editorFiles = ref<EditorFile[]>([]);
  152. /** 用户显式删除的已有文件(原始路径),保存时下发 deletePaths */
  153. const removedPaths = ref<string[]>([]);
  154. /** 规范之外的文件:不参与在线编辑,保存时原样保留,仅提示 */
  155. const foreignFiles = ref<string[]>([]);
  156. const saving = ref(false);
  157. const loading = ref(false);
  158. let uidSeed = 0;
  159. const isEdit = computed(() => !!props.editing);
  160. function filesOf(dir: string) {
  161. return editorFiles.value.filter((f) => f.dir === dir);
  162. }
  163. function addFile(dir: string) {
  164. editorFiles.value.push({ uid: ++uidSeed, dir, name: '', content: '' });
  165. }
  166. function removeFile(file: EditorFile) {
  167. // 只有磁盘上已存在的文件才需要下发删除;新建后没保存过的直接丢掉即可
  168. if (file.originalPath) removedPaths.value.push(file.originalPath);
  169. editorFiles.value = editorFiles.value.filter((f) => f.uid !== file.uid);
  170. }
  171. function resetForm() {
  172. name.value = '';
  173. description.value = '';
  174. content.value = '';
  175. activeTab.value = 'SKILL.md';
  176. editorFiles.value = [];
  177. removedPaths.value = [];
  178. foreignFiles.value = [];
  179. }
  180. /** 编辑模式:读详情 + 逐个拉文本文件内容(二进制只记路径,只读展示) */
  181. async function loadForEdit(skill: Skill) {
  182. loading.value = true;
  183. try {
  184. const detail = await readSkill(skill.name);
  185. const split = splitSkillMd(detail?.content || '');
  186. name.value = skill.name;
  187. description.value = split.description || detail?.description || skill.description || '';
  188. content.value = split.body;
  189. const stdDirs = new Set<string>(SKILL_STD_DIR_KEYS);
  190. const files: EditorFile[] = [];
  191. const foreign: string[] = [];
  192. for (const item of detail?.files ?? []) {
  193. const idx = item.path.indexOf('/');
  194. const top = idx > 0 ? item.path.slice(0, idx) : '';
  195. if (!stdDirs.has(top)) {
  196. foreign.push(item.path);
  197. continue;
  198. }
  199. const file = await readSkillFile(skill.name, item.path).catch(() => null);
  200. files.push({
  201. uid: ++uidSeed,
  202. dir: top,
  203. name: item.path.slice(top.length + 1),
  204. content: file?.content ?? '',
  205. originalPath: item.path,
  206. binary: file?.binary === true,
  207. size: file?.size ?? item.size,
  208. });
  209. }
  210. editorFiles.value = files;
  211. foreignFiles.value = foreign;
  212. } catch (e: any) {
  213. showMessage(e?.message || '读取技能失败', 'error');
  214. } finally {
  215. loading.value = false;
  216. }
  217. }
  218. // 每次打开都是干净表单:新建清空,编辑按磁盘上的真实内容预填
  219. watch(
  220. () => props.open,
  221. (opened) => {
  222. if (!opened) return;
  223. resetForm();
  224. if (props.editing) void loadForEdit(props.editing);
  225. },
  226. );
  227. function close() {
  228. emit('update:open', false);
  229. }
  230. async function save() {
  231. // 技能名以路径参数为准(目录名不可变),编辑时忽略输入框
  232. const skillName = (props.editing?.name || name.value).trim();
  233. if (!isEdit.value) {
  234. if (!skillName) return showMessage('请输入技能名', 'warning');
  235. if (!SKILL_NAME_RE.test(skillName)) return showMessage('技能名只能含小写字母、数字和短横线,且以字母或数字开头,最多 64 字符', 'warning');
  236. if (props.existing?.includes(skillName)) return showMessage('同名技能已存在', 'warning');
  237. }
  238. if (!content.value.trim()) return showMessage('请输入 SKILL.md 正文', 'warning');
  239. const files: SaveSkillFile[] = [];
  240. const deletePaths = new Set(removedPaths.value);
  241. const seen = new Set<string>();
  242. for (const file of editorFiles.value) {
  243. // 二进制不进 JSON:后端 save 不删未提交的文件,原文件自然保留
  244. if (file.binary) continue;
  245. const leaf = file.name.trim().replace(/^\/+/, '');
  246. if (!leaf) continue;
  247. const path = `${file.dir}/${leaf}`;
  248. if (seen.has(path)) return showMessage(`文件路径重复:${path}`, 'warning');
  249. seen.add(path);
  250. files.push({ path, content: file.content });
  251. // 改了文件名:旧路径要删掉,否则会留下两份
  252. if (file.originalPath && file.originalPath !== path) deletePaths.add(file.originalPath);
  253. }
  254. saving.value = true;
  255. try {
  256. const payload = {
  257. name: skillName,
  258. description: description.value.trim(),
  259. content: content.value,
  260. files,
  261. deletePaths: [...deletePaths],
  262. };
  263. const skill = isEdit.value ? await updateSkill(skillName, payload) : await createSkill(payload);
  264. showMessage(
  265. isEdit.value ? `技能「${skill?.name || skillName}」已更新` : `技能「${skill?.name || skillName}」已创建`,
  266. 'success',
  267. );
  268. emit('saved', skill);
  269. close();
  270. } catch (e: any) {
  271. // 同名已存在 / 名字含路径非法字符等由服务端拒绝,message 由 defHttp 统一弹
  272. showMessage(e?.message || '保存技能失败', 'error');
  273. } finally {
  274. saving.value = false;
  275. }
  276. }
  277. </script>
  278. <style lang="less" scoped>
  279. .skill-create {
  280. display: flex;
  281. flex-direction: column;
  282. gap: 10px;
  283. &__sub {
  284. margin: 0;
  285. font-size: 12.5px;
  286. line-height: 1.7;
  287. color: var(--ai-text-2, #656d76);
  288. b {
  289. font-weight: 600;
  290. color: var(--ai-text, #24292f);
  291. }
  292. }
  293. /* 表单项:label 在上、控件在下(单看 placeholder 不知道要填什么) */
  294. &__field {
  295. display: flex;
  296. flex-direction: column;
  297. gap: 4px;
  298. }
  299. &__label {
  300. font-size: 12.5px;
  301. color: var(--ai-text-2, #656d76);
  302. &.is-inline {
  303. flex: 0 0 auto;
  304. }
  305. }
  306. &__req {
  307. margin-right: 2px;
  308. font-style: normal;
  309. color: #d4380d;
  310. }
  311. &__badge {
  312. margin-left: 4px;
  313. padding: 0 5px;
  314. font-size: 11px;
  315. line-height: 16px;
  316. color: var(--ai-text-3, #9aa1ac);
  317. background: var(--ai-fill-2, #f2f3f5);
  318. border-radius: 8px;
  319. &.is-need {
  320. color: #d4380d;
  321. background: #fff2e8;
  322. }
  323. &.is-bin {
  324. color: var(--ai-text-3, #9aa1ac);
  325. }
  326. }
  327. &__dir-desc {
  328. margin: 0 0 8px;
  329. font-size: 12px;
  330. color: var(--ai-text-3, #9aa1ac);
  331. }
  332. &__file {
  333. margin-bottom: 10px;
  334. padding: 8px 10px;
  335. background: var(--ai-fill-1, #fafafa);
  336. border: 1px solid var(--ai-border, #eaecef);
  337. border-radius: 6px;
  338. }
  339. &__file-head {
  340. display: flex;
  341. gap: 6px;
  342. align-items: center;
  343. margin-bottom: 6px;
  344. color: var(--ai-text-2, #656d76);
  345. }
  346. &__file-name {
  347. flex: 1;
  348. min-width: 0;
  349. height: 26px;
  350. padding: 0 8px;
  351. font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace;
  352. font-size: 12.5px;
  353. color: var(--ai-text, #24292f);
  354. background: #fff;
  355. border: 1px solid var(--ai-border, #eaecef);
  356. border-radius: 4px;
  357. outline: none;
  358. &:focus {
  359. border-color: var(--ai-primary, #1677ff);
  360. }
  361. }
  362. &__file-hint {
  363. margin: 0;
  364. font-size: 12px;
  365. color: var(--ai-text-3, #9aa1ac);
  366. }
  367. &__foreign {
  368. margin: 0;
  369. font-size: 12px;
  370. line-height: 1.7;
  371. color: var(--ai-text-3, #9aa1ac);
  372. }
  373. }
  374. </style>