|
|
@@ -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>
|