feat(skill): 新增推荐 Skill 列表与一键安装

- 管理端新增推荐 Skill 分组,未安装时可从卡片 + 直接拉取 ModelScope
  来源并进入安装草稿确认
- 上传入口补充安装文档链接,文档补充 ModelScope 单个 Skill 与合集安装说明
- 同步更新 roadmap 与 REFACTOR 记录
This commit is contained in:
Wenjie Zhang 2026-06-05 01:30:46 +08:00
parent 8d3927629c
commit 83656a8eb5
4 changed files with 279 additions and 79 deletions

View File

@ -58,6 +58,7 @@
- [x] 将 Qwen-Image 修改为 Skill
- [x] 现在输入区域对于不同 mention 的渲染的 ICON 和 human-message 里面的渲染的 ICON;
- [x] 节点的颜色按照 label 的分类来
- [ ] 添加推荐 Skill 的列表,点击可以直接安装,实际上是替代了填入搜索的名称,并触发拉取,这个似乎只需要在前端修改就可以了
# bug

View File

@ -47,10 +47,11 @@ Skills 系统采用「文件系统存内容,数据库存索引」的分离架
系统提供以下方式创建或安装 Skills
1. **ZIP / SKILL.md 上传(推荐)**:上传后先解析为安装草稿,确认共享范围后再写入正式 Skills 存储和数据库
2. **远程仓库安装**:填写 skills 仓库地址和 skill 名称,后端调用 `npx skills` 下载并解析为安装草稿,确认后导入系统
3. **在线编辑**:对已有且可管理的 Skill 在线创建目录、编辑文件和维护依赖
4. **Agent 内安装**:主智能体可通过 `install_skill` 工具,从沙盒路径或 Git 来源安装当前用户私有 Skill子智能体禁用该工具
1. **推荐 Skill 安装**:在 Skills 管理页的推荐分组点击 `+`,系统会拉取对应远程来源并生成安装草稿
2. **ZIP / SKILL.md 上传**:上传后先解析为安装草稿,确认共享范围后再写入正式 Skills 存储和数据库
3. **远程仓库安装**:填写 skills 仓库地址、ModelScope Skill 地址或合集地址,后端调用 `npx skills` 下载并解析为安装草稿,确认后导入系统
4. **在线编辑**:对已有且可管理的 Skill 在线创建目录、编辑文件和维护依赖
5. **Agent 内安装**:主智能体可通过 `install_skill` 工具,从沙盒路径或 Git 来源安装当前用户私有 Skill子智能体禁用该工具
不建议直接操作数据库或文件系统导入 Skill。直接写文件不会自动生成 `skills` 表记录,也无法参与权限、依赖和沙盒挂载。
@ -59,6 +60,7 @@ Skills 系统采用「文件系统存内容,数据库存索引」的分离架
Skills 本质上是提示词和工具的封装,以下是一些可以参考的 Skills 实现:
- **Anthropic 官方 Tools**https://github.com/anthropics/skills 可以参考其 skills 的组织方式和提示词设计
- **ModelScope Skill 市场**https://modelscope.cn/skills 支持单个 Skill 地址,也支持合集地址批量拉取
- **MiniMax-AI CLI**https://github.com/MiniMax-AI/cli 文本、图片、视频、语音和音乐生成 + Web 搜索(可通过 `MiniMax-AI/cli` 远程安装)
- **社区 Skills**:各平台分享的 Agent 提示词模板
- **自定义开发**:根据业务需求自行开发
@ -111,12 +113,21 @@ description: 这是一个用于处理特定任务的技能
### 导入 Skill
有三种方式可以导入 Skill
可以通过以下方式导入或安装 Skill
**方式一:通过 ZIP 包或 SKILL.md 上传(推荐)**
**方式一:从推荐列表安装**
1. 在系统设置的「Skills 管理」页面查看「推荐」分组
2. 未安装的推荐 Skill 会以普通 Skill 卡片样式展示,右侧显示 `+`
3. 点击推荐卡片或 `+` 后,系统会使用该 Skill 的远程来源拉取内容
4. 拉取成功后会弹出安装草稿,确认共享范围后完成安装
已安装的推荐 Skill 不会继续显示在「推荐」分组中。
**方式二:通过 ZIP 包或 SKILL.md 上传**
1. 将 Skill 目录打包成 ZIP 文件注意ZIP 的根目录就是 Skill 目录)
2. 在系统设置的「Skills 管理」页面,点击「导入 Skill」
2. 在系统设置的「Skills 管理」页面,点击「上传 Skill」
3. 上传 ZIP 文件或单个 `SKILL.md`
4. 系统解析上传内容并返回安装草稿
5. 确认共享范围后完成安装;也可以放弃草稿
@ -127,7 +138,30 @@ description: 这是一个用于处理特定任务的技能
- 解析 SKILL.md 的 frontmatter 并存储到数据库
- 按当前用户角色校验可选择的共享范围
**方式二:在线编辑已有 Skill**
**方式三:从远程来源安装**
1. 在 Skills 管理页面点击「远程安装」
2. 在“按仓库拉取”中填写来源,例如:
- `anthropics/skills`
- `https://github.com/anthropics/skills`
- `https://modelscope.cn/skills/@anthropics/pdf`
- `https://modelscope.cn/collections/MiniMax/MiniMax-Office-skills`
3. 点击“拉取技能”获取该来源中可发现的 Skills 列表
4. 单个 Skill 地址通常会自动选中;仓库或合集地址可在列表中勾选一个或多个 Skills
5. 点击“解析并确认”,系统返回安装草稿,确认共享范围后正式安装
也可以切换到“全局搜索发现”,输入关键字检索 skills.sh 上的开源 Skills再选择结果安装。
系统会在后端:
- 调用 `npx skills add <source> --list` 校验来源并发现可安装的 skills
- 使用隔离的临时 `HOME` 执行 `npx skills add <source> --skill <name> -g -y --copy`
- 从临时目录中提取对应 skill再按现有导入流程生成草稿确认后写入 `/app/saves/skills` 与数据库
::: tip ModelScope 合集适合批量安装
ModelScope 合集地址可以作为远程来源填写,例如 `https://modelscope.cn/collections/MiniMax/MiniMax-Office-skills`。拉取后在列表中勾选需要的 Skills再统一解析为安装草稿。
:::
**方式四:在线编辑已有 Skill**
在 Skills 管理页面,你可以:
- 新建目录或文件
@ -136,18 +170,6 @@ description: 这是一个用于处理特定任务的技能
只有具备 `can_manage` 权限的用户才能编辑文件、依赖、共享范围和启用状态。
**方式三:从远程 skills 仓库安装**
1. 在 Skills 管理页面的“远程安装”面板中填写仓库来源,例如 `anthropics/skills` 或完整 GitHub URL
2. 点击“查看可安装 Skills”获取该仓库中可发现的 skills 列表
3. 选择或输入目标 skill 名称后点击“解析”
4. 系统返回安装草稿,确认共享范围后正式安装
系统会在后端:
- 调用 `npx skills add <source> --list` 校验来源并发现可安装的 skills
- 使用隔离的临时 `HOME` 执行 `npx skills add <source> --skill <name> -g -y --copy`
- 从临时目录中提取对应 skill再按现有导入流程生成草稿确认后写入 `/app/saves/skills` 与数据库
::: tip 远程安装不会把 ~/.agents/skills 作为系统主存储
远程安装只把 `skills.sh` CLI 作为“下载器”使用。Yuxi 仍然以 `/app/saves/skills + skills 表` 作为正式来源,这样才能与现有的权限、线程可见性和沙盒挂载机制保持一致。
:::

View File

@ -51,7 +51,7 @@
- 新增 Milvus 图谱检索链路Query 可召回图谱实体和三元组,结合 Chunk 命中实体构造 seed entity读取 Neo4j 2-hop 子图后用 igraph 执行 PPR最终以 Chunk 为产物并通过 RRF 与原 Chunk 召回融合;检索配置改为 dataclass 元数据生成,支持 `depend_on` 控制重排序和图检索参数展示。
- 收紧用户管理部门隔离:普通管理员创建用户时固定归属本部门,用户列表、访问选项、详情、更新和删除接口均限制在本部门范围内。
- 调整 Agent 资源默认选择与运行时上下文未显式配置工具、知识库、MCP、Skills 时默认启用当前用户可访问/可用的全部资源子智能体默认不启用显式保存空列表仍表示不启用对应资源Agent 创建前统一完成最终资源权限过滤、知识库 `db_id` 可见范围派生和 Skill prompt/readable 依赖闭包派生,聊天运行时与文件系统预览复用同一结果。
- 重构 Skills 权限与安装流程Skill 增加 `source_type/share_config/enabled`,内置 Skill 作为启动同步入库的全局资源,不再保留前端安装/更新状态,支持启停但不允许删除;上传和远程添加统一为解析草稿后确认生效范围,安装 slug 优先读取 `SKILL.md``slug` 字段并保留 `name` 展示名,压缩包名称不参与 slug 校验管理端支持编辑生效范围与启停Agent 运行时按当前用户可访问 Skills 派生 prompt/readable 依赖闭包并限制挂载/激活Skills prompt 改为模型请求级注入以避免污染 runtime context主智能体恢复 `install_skill` 工具,允许当前用户安装私有 Skill 并激活当前会话,子智能体配置和运行态均禁用该工具。
- 重构 Skills 权限与安装流程Skill 增加 `source_type/share_config/enabled`,内置 Skill 作为启动同步入库的全局资源,不再保留前端安装/更新状态,支持启停但不允许删除;上传和远程添加统一为解析草稿后确认生效范围,安装 slug 优先读取 `SKILL.md``slug` 字段并保留 `name` 展示名,压缩包名称不参与 slug 校验;管理端新增推荐 Skill 分组,未安装时可从卡片 `+` 直接拉取 ModelScope 来源并进入安装草稿确认,上传入口补充安装文档链接,文档补充 ModelScope 单个 Skill 与合集安装说明;管理端支持编辑生效范围与启停Agent 运行时按当前用户可访问 Skills 派生 prompt/readable 依赖闭包并限制挂载/激活Skills prompt 改为模型请求级注入以避免污染 runtime context主智能体恢复 `install_skill` 工具,允许当前用户安装私有 Skill 并激活当前会话,子智能体配置和运行态均禁用该工具。
- 精简历史兼容层:移除 sandbox provisioner `local` 后端别名、ask_user_question 单问题旧协议、JWT 历史默认密钥特殊判断、内置 Skill `SKILLS.md` 文件名回退、运行事件数字 seq 兼容和前端若干旧字段回退。
- 重构知识库共享权限:`share_config` 改为全局共享、部门共享、指定人可访问三档,部门共享必须包含当前用户部门,指定人可访问必须包含当前用户,并补充权限过滤测试。
- 移除知识库沙盒文件系统映射:不再通过 `/home/gem/kbs` 暴露知识库文件树Agent 继续使用 `query_kb``open_kb_document` 访问知识库内容。

View File

@ -30,6 +30,15 @@
<span>上传 Skill</span>
</a-button>
</a-upload>
<a-button
:href="SKILLS_DOC_URL"
target="_blank"
rel="noopener noreferrer"
class="lucide-icon-btn"
>
<ExternalLink :size="14" />
<span>安装文档</span>
</a-button>
<a-tooltip title="刷新 Skills" placement="bottom">
<a-button class="lucide-icon-btn" :disabled="loading" @click="fetchSkills">
<RefreshCw :size="14" />
@ -55,7 +64,7 @@
</PageShoulder>
<div
v-if="filteredInstalledSkills.length === 0"
v-if="visibleSkillGroups.length === 0"
class="extension-card-grid-empty-state skill-empty-state"
>
<div class="skill-empty-card">
@ -76,21 +85,25 @@
</div>
<template v-else>
<template v-for="group in skillGroups" :key="group.key">
<template v-if="group.skills.length">
<template v-for="group in visibleSkillGroups" :key="group.key">
<div class="extension-section-header">{{ group.title }}</div>
<ExtensionCardGrid :min-width="360">
<div
v-for="skill in group.skills"
:key="skill.slug"
:key="`${group.key}:${skill.slug}`"
class="card-wrapper"
:class="{
selected: selectedCardSlugs.includes(skill.slug),
'batch-mode': isBatchDeleteMode
selected: !skill.isRecommendation && selectedCardSlugs.includes(skill.slug),
'batch-mode': isBatchDeleteMode && !skill.isRecommendation
}"
>
<a-checkbox
v-if="isBatchDeleteMode && canManageSkill(skill) && skill.sourceType !== 'builtin'"
v-if="
!skill.isRecommendation &&
isBatchDeleteMode &&
canManageSkill(skill) &&
skill.sourceType !== 'builtin'
"
:checked="selectedCardSlugs.includes(skill.slug)"
@change="handleToggleCardSelect(skill.slug)"
class="card-select-checkbox"
@ -101,10 +114,27 @@
:description="skill.description || '暂无描述'"
:default-icon="BookMarkedIcon"
@click="handleCardClick(skill)"
:class="{ 'card-clickable-select': isBatchDeleteMode }"
:class="{ 'card-clickable-select': isBatchDeleteMode && !skill.isRecommendation }"
>
<template #action>
<button
v-if="skill.isRecommendation"
type="button"
class="skill-enabled-action"
:class="{ loading: isRecommendedSkillInstalling(skill.source) }"
:disabled="isRecommendedSkillInstallDisabled(skill.source)"
aria-label="安装推荐 Skill"
@click.stop="handleRecommendedSkillInstall(skill)"
>
<LoaderCircle
v-if="isRecommendedSkillInstalling(skill.source)"
:size="15"
class="action-icon action-icon-spin"
/>
<Plus v-else :size="15" class="action-icon" />
</button>
<button
v-else
type="button"
class="skill-enabled-action"
:class="{ enabled: skill.enabled !== false }"
@ -124,7 +154,6 @@
</ExtensionCardGrid>
</template>
</template>
</template>
<a-modal
v-model:open="skillPreviewVisible"
@ -581,7 +610,9 @@ import {
Trash2,
Check,
Plus,
Minus
Minus,
ExternalLink,
LoaderCircle
} from 'lucide-vue-next'
import { skillApi } from '@/apis/skill_api'
import ExtensionCardGrid from './ExtensionCardGrid.vue'
@ -592,6 +623,44 @@ import MarkdownPreview from '@/components/common/MarkdownPreview.vue'
import { formatExtensionCardTitle } from '@/utils/extensionDisplayName'
const BookMarkedIcon = BookMarked
const SKILLS_DOC_URL = 'https://xerrors.github.io/Yuxi/agents/skills-management.html'
const RECOMMENDED_SKILLS = [
{
slug: 'skill-creator',
name: 'skill-creator',
description: '创建、维护和改进 Agent Skill。',
source: 'https://modelscope.cn/skills/@anthropics/skill-creator',
aliases: ['skill-creator', 'Skill Creator']
},
{
slug: 'frontend-design',
name: 'frontend-design',
description: '面向前端界面与交互设计的实现建议。',
source: 'https://modelscope.cn/skills/@anthropics/frontend-design',
aliases: ['frontend-design', 'Frontend Design']
},
{
slug: 'docx',
name: 'docx',
description: '读取、编辑和生成 Word DOCX 文档。',
source: 'https://modelscope.cn/skills/@anthropics/docx',
aliases: ['docx', 'DOCX']
},
{
slug: 'xlsx',
name: 'xlsx',
description: '读取、编辑和生成 Excel XLSX 表格。',
source: 'https://modelscope.cn/skills/@anthropics/xlsx',
aliases: ['xlsx', 'XLSX']
},
{
slug: 'pdf',
name: 'pdf',
description: '读取、分析和处理 PDF 文件。',
source: 'https://modelscope.cn/skills/@anthropics/pdf',
aliases: ['pdf', 'PDF']
}
]
const router = useRouter()
@ -613,6 +682,7 @@ const skillPreviewLoading = ref(false)
const skillPreviewError = ref('')
const deletingPreviewSkill = ref(false)
let previewRequestSeq = 0
const installingRecommendedSources = ref([])
const remoteInstallModalVisible = ref(false)
const activeTab = ref('repo') // 'repo' 'search'
@ -645,7 +715,8 @@ const shareConfigFormRef = ref(null)
const matchesSearch = (skill) => {
if (!searchQuery.value) return true
const q = searchQuery.value.toLowerCase()
return skill.name.toLowerCase().includes(q) || skill.slug.toLowerCase().includes(q)
const text = [skill.name, skill.slug, skill.description].filter(Boolean).join(' ').toLowerCase()
return text.includes(q)
}
const installedSkillCards = computed(() =>
@ -655,8 +726,34 @@ const installedSkillCards = computed(() =>
}))
)
const installedSkillKeys = computed(() => {
const keys = new Set()
installedSkillCards.value.forEach((skill) => {
const identifiers = [skill.slug, skill.name]
identifiers.forEach((value) => {
if (value) keys.add(String(value).toLowerCase())
})
})
return keys
})
const recommendedSkillCards = computed(() =>
RECOMMENDED_SKILLS.filter(
(skill) => !skill.aliases.some((alias) => installedSkillKeys.value.has(alias.toLowerCase()))
).map((skill) => ({
...skill,
sourceType: 'recommended',
isRecommendation: true
}))
)
const filteredInstalledSkills = computed(() => installedSkillCards.value.filter(matchesSearch))
const skillGroups = computed(() => [
{
key: 'recommended',
title: '推荐',
skills: isBatchDeleteMode.value ? [] : recommendedSkillCards.value.filter(matchesSearch)
},
{
key: 'builtin',
title: '内置',
@ -668,6 +765,7 @@ const skillGroups = computed(() => [
skills: filteredInstalledSkills.value.filter((skill) => skill.sourceType !== 'builtin')
}
])
const visibleSkillGroups = computed(() => skillGroups.value.filter((group) => group.skills.length))
const filteredDeletableSkills = computed(() =>
filteredInstalledSkills.value.filter(
(skill) => canManageSkill(skill) && skill.sourceType !== 'builtin'
@ -761,6 +859,11 @@ const sourceTypeLabel = (sourceType) => {
const canManageSkill = (skill) => skill?.can_manage !== false
const isSkillToggling = (slug) => togglingSkillSlugs.value.includes(slug)
const isRecommendedSkillInstalling = (source) => installingRecommendedSources.value.includes(source)
const isRecommendedSkillInstallDisabled = (source) =>
installingRecommendedSources.value.length > 0 ||
draftConfirmVisible.value ||
isRecommendedSkillInstalling(source)
const navigateToDetail = (skill) => {
router.push({ path: `/extensions/skill/${encodeURIComponent(skill.slug)}` })
@ -797,6 +900,10 @@ const goToPreviewSkillManagement = () => {
}
const handleCardClick = (skill) => {
if (skill?.isRecommendation) {
if (!isBatchDeleteMode.value) handleRecommendedSkillInstall(skill)
return
}
if (isBatchDeleteMode.value) {
handleToggleCardSelect(skill.slug)
} else {
@ -1080,6 +1187,17 @@ const handleCancelInstall = () => {
remoteInstallModalVisible.value = false
}
const rememberRemoteSource = (source) => {
let history = [...repoHistory.value]
history = history.filter((item) => item !== source)
history.unshift(source)
if (history.length > 10) {
history = history.slice(0, 10)
}
repoHistory.value = history
localStorage.setItem('yuxi_remote_repo_history', JSON.stringify(history))
}
const handleListRemoteSkills = async () => {
const source = remoteInstallForm.source.trim()
if (!source) {
@ -1102,15 +1220,7 @@ const handleListRemoteSkills = async () => {
message.success(`已发现 ${remoteSkillOptions.value.length} 个 Skills`)
}
//
let history = [...repoHistory.value]
history = history.filter((item) => item !== source)
history.unshift(source)
if (history.length > 10) {
history = history.slice(0, 10)
}
repoHistory.value = history
localStorage.setItem('yuxi_remote_repo_history', JSON.stringify(history))
rememberRemoteSource(source)
} catch (error) {
message.error(error?.response?.data?.detail || error.message || '获取远程 Skills 失败')
} finally {
@ -1118,6 +1228,54 @@ const handleListRemoteSkills = async () => {
}
}
const handleRecommendedSkillInstall = async (skill) => {
if (!skill?.source || isRecommendedSkillInstallDisabled(skill.source)) {
return
}
installingRecommendedSources.value.push(skill.source)
activeTab.value = 'repo'
remoteInstallForm.source = skill.source
remoteSkillOptions.value = []
selectedRepoSkills.value = []
repoFilterKeyword.value = ''
try {
const listResult = await skillApi.listRemoteSkills(skill.source)
const options = listResult?.data || []
remoteSkillOptions.value = options
const aliasSet = new Set(
(skill.aliases || [skill.slug, skill.name]).map((item) => item.toLowerCase())
)
const matchedSkill =
options.length === 1
? options[0]
: options.find((item) => aliasSet.has(String(item.name || '').toLowerCase()))
if (!matchedSkill?.name) {
remoteInstallModalVisible.value = true
message.warning('已拉取推荐来源,请选择要安装的 Skill')
return
}
selectedRepoSkills.value = [matchedSkill.name]
rememberRemoteSource(skill.source)
const prepareResult = await skillApi.prepareRemoteSkills({
source: skill.source,
skills: [matchedSkill.name]
})
if (await openDraftConfirmation(prepareResult?.data)) {
message.success('解析完成,请确认 Skill 生效范围')
}
} catch (error) {
message.error(error?.response?.data?.detail || error.message || '解析推荐 Skill 失败')
} finally {
installingRecommendedSources.value = installingRecommendedSources.value.filter(
(source) => source !== skill.source
)
}
}
const loadHistory = () => {
try {
const raw = localStorage.getItem('yuxi_remote_repo_history')
@ -1354,6 +1512,11 @@ defineExpose({
opacity: 0.45;
}
&.loading:disabled {
cursor: wait;
opacity: 1;
}
&.enabled {
color: var(--color-success-700);
@ -1382,6 +1545,20 @@ defineExpose({
flex-shrink: 0;
}
.action-icon-spin {
animation: skill-action-spin 1s linear infinite;
}
@keyframes skill-action-spin {
from {
transform: rotate(0deg);
}
to {
transform: rotate(360deg);
}
}
.skill-preview-panel {
display: flex;
flex-direction: column;