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