feat(skill): 优化远程安装功能,支持多技能选择与进度展示
This commit is contained in:
parent
037872924b
commit
cc241f79ed
@ -15,7 +15,9 @@ ENV TZ=Asia/Shanghai \
|
|||||||
UV_COMPILE_BYTECODE=1 \
|
UV_COMPILE_BYTECODE=1 \
|
||||||
DEBIAN_FRONTEND=noninteractive
|
DEBIAN_FRONTEND=noninteractive
|
||||||
|
|
||||||
RUN npm install -g npm@latest && npm cache clean --force
|
# 设置 npm 镜像源,为 MCP 和 Skills 安装依赖
|
||||||
|
RUN npm config set registry https://registry.npmmirror.com --global \
|
||||||
|
&& npm cache clean --force
|
||||||
|
|
||||||
# 设置代理和时区,更换镜像源,安装系统依赖 - 合并为一个RUN减少层数
|
# 设置代理和时区,更换镜像源,安装系统依赖 - 合并为一个RUN减少层数
|
||||||
RUN set -ex \
|
RUN set -ex \
|
||||||
@ -37,7 +39,6 @@ RUN set -ex \
|
|||||||
&& apt-get clean \
|
&& apt-get clean \
|
||||||
&& rm -rf /var/lib/apt/lists/*
|
&& rm -rf /var/lib/apt/lists/*
|
||||||
|
|
||||||
|
|
||||||
# 复制项目配置文件
|
# 复制项目配置文件
|
||||||
COPY ../backend/pyproject.toml /app/pyproject.toml
|
COPY ../backend/pyproject.toml /app/pyproject.toml
|
||||||
COPY ../backend/.python-version /app/.python-version
|
COPY ../backend/.python-version /app/.python-version
|
||||||
|
|||||||
@ -33,7 +33,7 @@
|
|||||||
- 调整输入框 `@` 提及中的文件搜索交互:无查询内容时不再直接展示文件列表,改为提示“输入相关内容以搜索文件”,避免未过滤结果干扰选择。
|
- 调整输入框 `@` 提及中的文件搜索交互:无查询内容时不再直接展示文件列表,改为提示“输入相关内容以搜索文件”,避免未过滤结果干扰选择。
|
||||||
- 收紧文件系统安全边界:viewer/chat 下载与删除路径统一基于解析后的真实路径做允许目录校验,阻止通过软链接逃逸工作区/线程目录;同时将密码哈希默认实现升级为 Argon2,并移除 skill frontmatter 解析中的正则回溯风险。
|
- 收紧文件系统安全边界:viewer/chat 下载与删除路径统一基于解析后的真实路径做允许目录校验,阻止通过软链接逃逸工作区/线程目录;同时将密码哈希默认实现升级为 Argon2,并移除 skill frontmatter 解析中的正则回溯风险。
|
||||||
- 调整 Skills 导入能力:`/api/system/skills/import` 现在除 ZIP 外也支持直接上传单个 `SKILL.md`,前端上传入口与后端导入服务同步兼容,便于快速导入单文件技能
|
- 调整 Skills 导入能力:`/api/system/skills/import` 现在除 ZIP 外也支持直接上传单个 `SKILL.md`,前端上传入口与后端导入服务同步兼容,便于快速导入单文件技能
|
||||||
- 新增 Skills 远程安装能力:Skills 管理页支持填写 `owner/repo` 或 GitHub URL,后端通过隔离的临时 `HOME` 调用 `npx skills add` 下载指定 skill,再复用现有导入链路写入 `saves/skills` 和数据库,避免将 `~/.agents/skills` 直接作为系统主存储
|
- 新增 Skills 远程安装能力:Skills 管理页支持填写 `owner/repo` 或 GitHub URL,后端通过隔离的临时 `HOME` 调用 `npx skills add` 下载指定 skill,再复用现有导入链路写入 `saves/skills` 和数据库,避免将 `~/.agents/skills` 直接作为系统主存储;前端远程安装弹窗补充多选串行安装与批量进度展示,复用现有单 skill 安装接口逐个提交请求
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
|||||||
@ -358,6 +358,9 @@
|
|||||||
title="远程安装 Skill"
|
title="远程安装 Skill"
|
||||||
:footer="null"
|
:footer="null"
|
||||||
width="560px"
|
width="560px"
|
||||||
|
:closable="!installingRemoteSkill"
|
||||||
|
:mask-closable="!installingRemoteSkill"
|
||||||
|
:keyboard="!installingRemoteSkill"
|
||||||
>
|
>
|
||||||
<div class="remote-install-panel modal-mode">
|
<div class="remote-install-panel modal-mode">
|
||||||
<div class="panel-header-text">
|
<div class="panel-header-text">
|
||||||
@ -373,17 +376,21 @@
|
|||||||
<a-input
|
<a-input
|
||||||
v-model:value="remoteInstallForm.source"
|
v-model:value="remoteInstallForm.source"
|
||||||
placeholder="anthropics/skills 或 GitHub URL"
|
placeholder="anthropics/skills 或 GitHub URL"
|
||||||
|
:disabled="installingRemoteSkill"
|
||||||
/>
|
/>
|
||||||
</a-form-item>
|
</a-form-item>
|
||||||
<a-form-item label="Skill 名称">
|
<a-form-item label="Skill 名称">
|
||||||
<a-auto-complete
|
<a-select
|
||||||
v-model:value="remoteInstallForm.skill"
|
v-model:value="remoteInstallForm.skills"
|
||||||
|
mode="tags"
|
||||||
:options="filteredRemoteSkillOptions"
|
:options="filteredRemoteSkillOptions"
|
||||||
placeholder="frontend-design"
|
placeholder="frontend-design"
|
||||||
allow-clear
|
allow-clear
|
||||||
>
|
show-search
|
||||||
<a-input />
|
:disabled="installingRemoteSkill"
|
||||||
</a-auto-complete>
|
:filter-option="filterRemoteSkillOption"
|
||||||
|
:max-tag-count="6"
|
||||||
|
/>
|
||||||
</a-form-item>
|
</a-form-item>
|
||||||
<div class="remote-install-actions">
|
<div class="remote-install-actions">
|
||||||
<a-button
|
<a-button
|
||||||
@ -401,6 +408,9 @@
|
|||||||
>
|
>
|
||||||
安装
|
安装
|
||||||
</a-button>
|
</a-button>
|
||||||
|
<span v-if="remoteInstallStatusText" class="remote-install-status">
|
||||||
|
{{ remoteInstallStatusText }}
|
||||||
|
</span>
|
||||||
</div>
|
</div>
|
||||||
<div v-if="remoteSkillOptions.length" class="remote-skill-summary">
|
<div v-if="remoteSkillOptions.length" class="remote-skill-summary">
|
||||||
共发现 {{ remoteSkillOptions.length }} 个 skills,可按输入内容筛选候选项。
|
共发现 {{ remoteSkillOptions.length }} 个 skills,可按输入内容筛选候选项。
|
||||||
@ -472,9 +482,21 @@ const remoteInstallModalVisible = ref(false)
|
|||||||
const createForm = reactive({ path: '', isDir: false, content: '' })
|
const createForm = reactive({ path: '', isDir: false, content: '' })
|
||||||
const remoteInstallForm = reactive({
|
const remoteInstallForm = reactive({
|
||||||
source: 'https://github.com/anthropics/skills',
|
source: 'https://github.com/anthropics/skills',
|
||||||
skill: ''
|
skills: []
|
||||||
})
|
})
|
||||||
const remoteSkillOptions = ref([])
|
const remoteSkillOptions = ref([])
|
||||||
|
const remoteInstallProgress = reactive({
|
||||||
|
visible: false,
|
||||||
|
total: 0,
|
||||||
|
completed: 0,
|
||||||
|
success: 0,
|
||||||
|
failed: 0,
|
||||||
|
currentSkill: ''
|
||||||
|
})
|
||||||
|
const remoteInstallResults = reactive({
|
||||||
|
success: [],
|
||||||
|
failed: []
|
||||||
|
})
|
||||||
const dependencyOptions = reactive({ tools: [], mcps: [], skills: [] })
|
const dependencyOptions = reactive({ tools: [], mcps: [], skills: [] })
|
||||||
const dependencyForm = reactive({
|
const dependencyForm = reactive({
|
||||||
tool_dependencies: [],
|
tool_dependencies: [],
|
||||||
@ -585,15 +607,49 @@ const skillDependencyOptions = computed(() =>
|
|||||||
.filter((s) => s !== currentSkill.value?.slug)
|
.filter((s) => s !== currentSkill.value?.slug)
|
||||||
.map((i) => ({ label: i, value: i }))
|
.map((i) => ({ label: i, value: i }))
|
||||||
)
|
)
|
||||||
const filteredRemoteSkillOptions = computed(() => {
|
const filteredRemoteSkillOptions = computed(() =>
|
||||||
const keyword = remoteInstallForm.skill.trim().toLowerCase()
|
remoteSkillOptions.value.map((item) => ({
|
||||||
return remoteSkillOptions.value
|
value: item.name,
|
||||||
.filter((item) => !keyword || item.name.toLowerCase().includes(keyword))
|
label: item.description ? `${item.name} - ${item.description}` : item.name
|
||||||
.map((item) => ({
|
}))
|
||||||
value: item.name,
|
)
|
||||||
label: item.description ? `${item.name} - ${item.description}` : item.name
|
const remoteInstallStatusText = computed(() => {
|
||||||
}))
|
if (!remoteInstallProgress.visible || !remoteInstallProgress.total) return ''
|
||||||
|
const progressText = `[${remoteInstallProgress.completed}/${remoteInstallProgress.total}]`
|
||||||
|
const currentSkill = remoteInstallProgress.currentSkill || ''
|
||||||
|
const failedText =
|
||||||
|
remoteInstallProgress.failed > 0 ? `, ${remoteInstallProgress.failed} failed` : ''
|
||||||
|
return `${progressText} ${currentSkill}${failedText}`.trim()
|
||||||
})
|
})
|
||||||
|
const filterRemoteSkillOption = (input, option) => {
|
||||||
|
const keyword = input.trim().toLowerCase()
|
||||||
|
if (!keyword) return true
|
||||||
|
const value = String(option?.value || '').toLowerCase()
|
||||||
|
const label = String(option?.label || '').toLowerCase()
|
||||||
|
return value.includes(keyword) || label.includes(keyword)
|
||||||
|
}
|
||||||
|
|
||||||
|
const normalizeRemoteSkillNames = (skills) => {
|
||||||
|
const seen = new Set()
|
||||||
|
return (skills || []).reduce((acc, skill) => {
|
||||||
|
const normalized = String(skill || '').trim()
|
||||||
|
if (!normalized || seen.has(normalized)) return acc
|
||||||
|
seen.add(normalized)
|
||||||
|
acc.push(normalized)
|
||||||
|
return acc
|
||||||
|
}, [])
|
||||||
|
}
|
||||||
|
|
||||||
|
const resetRemoteInstallState = () => {
|
||||||
|
remoteInstallProgress.visible = false
|
||||||
|
remoteInstallProgress.total = 0
|
||||||
|
remoteInstallProgress.completed = 0
|
||||||
|
remoteInstallProgress.success = 0
|
||||||
|
remoteInstallProgress.failed = 0
|
||||||
|
remoteInstallProgress.currentSkill = ''
|
||||||
|
remoteInstallResults.success = []
|
||||||
|
remoteInstallResults.failed = []
|
||||||
|
}
|
||||||
|
|
||||||
const normalizeTree = (nodes) =>
|
const normalizeTree = (nodes) =>
|
||||||
(nodes || []).map((node) => ({
|
(nodes || []).map((node) => ({
|
||||||
@ -941,6 +997,7 @@ const handleListRemoteSkills = async () => {
|
|||||||
try {
|
try {
|
||||||
const result = await skillApi.listRemoteSkills(source)
|
const result = await skillApi.listRemoteSkills(source)
|
||||||
remoteSkillOptions.value = result?.data || []
|
remoteSkillOptions.value = result?.data || []
|
||||||
|
remoteInstallForm.skills = normalizeRemoteSkillNames(remoteInstallForm.skills)
|
||||||
if (!remoteSkillOptions.value.length) {
|
if (!remoteSkillOptions.value.length) {
|
||||||
message.warning('未发现可安装的 Skills')
|
message.warning('未发现可安装的 Skills')
|
||||||
return
|
return
|
||||||
@ -955,36 +1012,78 @@ const handleListRemoteSkills = async () => {
|
|||||||
|
|
||||||
const handleInstallRemoteSkill = async () => {
|
const handleInstallRemoteSkill = async () => {
|
||||||
const source = remoteInstallForm.source.trim()
|
const source = remoteInstallForm.source.trim()
|
||||||
const skill = remoteInstallForm.skill.trim()
|
const skillsToInstall = normalizeRemoteSkillNames(remoteInstallForm.skills)
|
||||||
if (!source || !skill) {
|
if (!source || !skillsToInstall.length) {
|
||||||
message.warning('请填写来源仓库和 Skill 名称')
|
message.warning('请填写来源仓库和 Skill 名称')
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
remoteInstallForm.skills = skillsToInstall
|
||||||
|
resetRemoteInstallState()
|
||||||
installingRemoteSkill.value = true
|
installingRemoteSkill.value = true
|
||||||
|
remoteInstallProgress.visible = true
|
||||||
|
remoteInstallProgress.total = skillsToInstall.length
|
||||||
|
let lastInstalledSlug = ''
|
||||||
try {
|
try {
|
||||||
const result = await skillApi.installRemoteSkill({ source, skill })
|
for (const skill of skillsToInstall) {
|
||||||
const installed = result?.data
|
remoteInstallProgress.currentSkill = skill
|
||||||
remoteInstallForm.skill = installed?.slug || skill
|
try {
|
||||||
|
const result = await skillApi.installRemoteSkill({ source, skill })
|
||||||
|
const installed = result?.data
|
||||||
|
const installedSlug = installed?.slug || skill
|
||||||
|
remoteInstallResults.success.push(installedSlug)
|
||||||
|
remoteInstallProgress.success += 1
|
||||||
|
lastInstalledSlug = installedSlug
|
||||||
|
} catch (error) {
|
||||||
|
remoteInstallResults.failed.push({
|
||||||
|
skill,
|
||||||
|
error: error?.response?.data?.detail || error.message || '远程 Skill 安装失败'
|
||||||
|
})
|
||||||
|
remoteInstallProgress.failed += 1
|
||||||
|
} finally {
|
||||||
|
remoteInstallProgress.completed += 1
|
||||||
|
}
|
||||||
|
}
|
||||||
|
remoteInstallProgress.currentSkill = ''
|
||||||
await fetchSkills()
|
await fetchSkills()
|
||||||
if (installed?.slug) {
|
if (lastInstalledSlug) {
|
||||||
const record =
|
const record =
|
||||||
skills.value.find((item) => item.slug === installed.slug) ||
|
skills.value.find((item) => item.slug === lastInstalledSlug) ||
|
||||||
builtinSkills.value.find((item) => item.slug === installed.slug)
|
builtinSkills.value.find((item) => item.slug === lastInstalledSlug)
|
||||||
if (record) await selectSkill(record)
|
if (record) await selectSkill(record)
|
||||||
}
|
}
|
||||||
remoteInstallModalVisible.value = false
|
if (remoteInstallResults.failed.length === 0) {
|
||||||
message.success('远程 Skill 安装成功')
|
remoteInstallModalVisible.value = false
|
||||||
|
message.success(`远程 Skills 安装成功,共 ${remoteInstallResults.success.length} 个`)
|
||||||
|
resetRemoteInstallState()
|
||||||
|
remoteInstallForm.skills = []
|
||||||
|
return
|
||||||
|
}
|
||||||
|
message.warning(
|
||||||
|
`远程 Skills 安装完成,成功 ${remoteInstallResults.success.length} 个,失败 ${remoteInstallResults.failed.length} 个`
|
||||||
|
)
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
message.error(error?.response?.data?.detail || error.message || '远程 Skill 安装失败')
|
message.error(error?.response?.data?.detail || error.message || '远程 Skill 安装失败')
|
||||||
} finally {
|
} finally {
|
||||||
|
remoteInstallProgress.currentSkill = ''
|
||||||
installingRemoteSkill.value = false
|
installingRemoteSkill.value = false
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const openRemoteInstallModal = () => {
|
const openRemoteInstallModal = () => {
|
||||||
|
if (!remoteInstallModalVisible.value) {
|
||||||
|
remoteInstallForm.skills = []
|
||||||
|
resetRemoteInstallState()
|
||||||
|
}
|
||||||
remoteInstallModalVisible.value = true
|
remoteInstallModalVisible.value = true
|
||||||
}
|
}
|
||||||
|
|
||||||
|
watch(remoteInstallModalVisible, (visible) => {
|
||||||
|
if (!visible && !installingRemoteSkill.value) {
|
||||||
|
remoteInstallForm.skills = []
|
||||||
|
resetRemoteInstallState()
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
const saveDependencies = async () => {
|
const saveDependencies = async () => {
|
||||||
if (!currentSkill.value || !isInstalledSkill.value) return
|
if (!currentSkill.value || !isInstalledSkill.value) return
|
||||||
savingDependencies.value = true
|
savingDependencies.value = true
|
||||||
@ -1080,9 +1179,20 @@ defineExpose({
|
|||||||
|
|
||||||
.remote-install-actions {
|
.remote-install-actions {
|
||||||
display: flex;
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
gap: 8px;
|
gap: 8px;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.remote-install-status {
|
||||||
|
min-width: 0;
|
||||||
|
flex: 1;
|
||||||
|
font-size: 12px;
|
||||||
|
color: var(--gray-600);
|
||||||
|
white-space: nowrap;
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
}
|
||||||
|
|
||||||
.remote-skill-hints {
|
.remote-skill-hints {
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-wrap: wrap;
|
flex-wrap: wrap;
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user