| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417 |
- <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>
- 组成。保存即写入当前账号的技能库,下一轮对话就能被模型调用。
- </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/ 可选(各目录下的文本文件)。
- * 保存走 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) */
- 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>
|