feat: add bot-level skill paths configuration (#383)

This commit is contained in:
Chrys
2026-04-17 16:05:00 +08:00
committed by GitHub
parent 7aa6ec6ca9
commit dee82177d3
11 changed files with 780 additions and 84 deletions
+28 -7
View File
@@ -1120,28 +1120,49 @@
},
"skills": {
"title": "Skills",
"discoveryTitle": "Skill Paths",
"discoveryDescription": "Manage where this bot stores managed skills and which external paths are scanned.",
"managedPathLabel": "Managed Path",
"managedPathDescription": "Memoh-managed skills are stored here.",
"managedPathHint": "\"Copy to Managed Path\" copies an external skill here. Managed copies take precedence.",
"discoveryPathsLabel": "External Skill Paths",
"discoveryPathsDescription": "Memoh scans these absolute paths for compatible external skills. One path per line.",
"discoveryPathPlaceholder": "/root/.agents/skills",
"discoveryAddPath": "Add Path",
"discoveryEmpty": "No external skill paths. Only managed and legacy skills will be scanned.",
"discoveryDefaultHint": "Default external paths: {paths}",
"discoveryReset": "Reset",
"discoverySummaryDefault": "Default",
"discoverySummaryCustom": "{count} paths",
"discoverySummaryUnsaved": "Unsaved changes",
"discoveryPathRequired": "Path is required",
"discoveryPathAbsolute": "Path must be absolute",
"discoveryPathReserved": "This path is reserved for Memoh-managed or legacy skills",
"discoveryPathDuplicate": "Duplicate path",
"discoverySaveSuccess": "Skill paths saved",
"discoverySaveFailed": "Failed to save skill paths",
"addSkill": "New Skill",
"emptyTitle": "No Skills",
"emptyDescription": "Click above to create a new skill",
"managedBadge": "Managed",
"discoveredBadge": "Discovered",
"discoveredBadge": "External",
"effectiveBadge": "Effective",
"shadowedBadge": "Shadowed",
"disabledBadge": "Disabled",
"legacyBadge": "Legacy",
"compatBadge": "Compatible",
"compatBadge": "External",
"description": "Description",
"descriptionPlaceholder": "Enter skill description",
"content": "Content",
"contentPlaceholder": "Enter skill content/prompt",
"deleteConfirm": "Are you sure you want to delete this skill?",
"overrideTitle": "Edit to create a managed override",
"adoptAction": "Adopt into Memoh-managed skills",
"adoptBlocked": "A higher-priority skill already exists",
"overrideTitle": "Edit to create a managed copy",
"adoptAction": "Copy to Managed Path",
"adoptBlocked": "A managed copy already exists",
"disableAction": "Disable this skill source",
"enableAction": "Enable this skill source",
"adoptSuccess": "Skill adopted",
"adoptFailed": "Failed to adopt skill",
"adoptSuccess": "Skill copied to managed path",
"adoptFailed": "Failed to copy skill to managed path",
"disableSuccess": "Skill disabled",
"disableFailed": "Failed to disable skill",
"enableSuccess": "Skill enabled",
+28 -7
View File
@@ -1116,28 +1116,49 @@
},
"skills": {
"title": "技能",
"discoveryTitle": "技能路径",
"discoveryDescription": "管理这个 Bot 的托管技能保存位置,以及会扫描哪些外部技能路径。",
"managedPathLabel": "托管路径",
"managedPathDescription": "Memoh 托管技能会保存在这里。",
"managedPathHint": "“复制到托管路径”会把外部技能复制到这里。托管副本会优先生效。",
"discoveryPathsLabel": "外部技能路径",
"discoveryPathsDescription": "Memoh 会扫描这些绝对路径中的兼容外部技能。每行填写一个路径。",
"discoveryPathPlaceholder": "/root/.agents/skills",
"discoveryAddPath": "添加路径",
"discoveryEmpty": "当前没有外部技能路径,仅会扫描托管技能和旧版技能。",
"discoveryDefaultHint": "默认外部路径:{paths}",
"discoveryReset": "重置",
"discoverySummaryDefault": "默认",
"discoverySummaryCustom": "{count} 个路径",
"discoverySummaryUnsaved": "有未保存修改",
"discoveryPathRequired": "路径不能为空",
"discoveryPathAbsolute": "路径必须是绝对路径",
"discoveryPathReserved": "这个路径已被 Memoh 托管技能或旧版技能占用",
"discoveryPathDuplicate": "路径重复",
"discoverySaveSuccess": "技能路径已保存",
"discoverySaveFailed": "保存技能路径失败",
"addSkill": "新建技能",
"emptyTitle": "暂无技能",
"emptyDescription": "点击上方按钮创建新技能",
"managedBadge": "托管",
"discoveredBadge": "发现",
"discoveredBadge": "外部",
"effectiveBadge": "生效中",
"shadowedBadge": "被覆盖",
"disabledBadge": "已禁用",
"legacyBadge": "旧版",
"compatBadge": "兼容",
"compatBadge": "外部",
"description": "描述",
"descriptionPlaceholder": "输入技能描述",
"content": "内容",
"contentPlaceholder": "输入技能内容/提示词",
"deleteConfirm": "确定要删除这个技能吗?",
"overrideTitle": "编辑后将创建托管覆盖版本",
"adoptAction": "纳入 Memoh 托管",
"adoptBlocked": "已有更高优先级的技能副本",
"overrideTitle": "编辑后将创建托管本",
"adoptAction": "复制到托管路径",
"adoptBlocked": "已有托管副本",
"disableAction": "禁用这个技能来源",
"enableAction": "启用这个技能来源",
"adoptSuccess": "技能已纳入托管",
"adoptFailed": "纳入托管失败",
"adoptSuccess": "技能已复制到托管路径",
"adoptFailed": "复制到托管路径失败",
"disableSuccess": "技能已禁用",
"disableFailed": "禁用技能失败",
"enableSuccess": "技能已启用",
+320 -15
View File
@@ -7,15 +7,31 @@
{{ $t('bots.skills.title') }}
</h3>
</div>
<Button
size="sm"
@click="handleCreate"
>
<Plus
class="mr-2"
/>
{{ $t('bots.skills.addSkill') }}
</Button>
<div class="flex items-center gap-1">
<Button
variant="ghost"
size="sm"
class="text-muted-foreground"
:title="$t('bots.skills.discoveryTitle')"
@click="isDiscoveryDialogOpen = true"
>
<SlidersHorizontal class="mr-2 size-4" />
{{ $t('bots.skills.discoveryTitle') }}
<span
v-if="showDiscoveryIndicator"
class="ml-2 inline-block size-2 shrink-0 rounded-full bg-primary/80"
/>
</Button>
<Button
size="sm"
@click="handleCreate"
>
<Plus
class="mr-2"
/>
{{ $t('bots.skills.addSkill') }}
</Button>
</div>
</div>
<!-- Loading State -->
@@ -230,26 +246,109 @@
</DialogFooter>
</DialogContent>
</Dialog>
<Dialog v-model:open="isDiscoveryDialogOpen">
<DialogContent class="sm:max-w-xl">
<DialogHeader>
<DialogTitle>{{ $t('bots.skills.discoveryTitle') }}</DialogTitle>
<DialogDescription class="text-xs">
{{ $t('bots.skills.discoveryDescription') }}
</DialogDescription>
</DialogHeader>
<div class="space-y-4 py-2">
<div class="space-y-2">
<Label class="text-xs font-medium">
{{ $t('bots.skills.managedPathLabel') }}
</Label>
<p class="text-xs text-muted-foreground">
{{ $t('bots.skills.managedPathDescription') }}
</p>
<div class="rounded-md border bg-muted/30 px-3 py-2 font-mono text-xs text-foreground break-all">
{{ MANAGED_SKILL_PATH }}
</div>
<p class="text-xs text-muted-foreground">
{{ $t('bots.skills.managedPathHint') }}
</p>
</div>
<div class="space-y-2">
<Label class="text-xs font-medium">
{{ $t('bots.skills.discoveryPathsLabel') }}
</Label>
<p class="text-xs text-muted-foreground">
{{ $t('bots.skills.discoveryPathsDescription') }}
</p>
<Textarea
v-model="discoveryRootsDraft"
:disabled="discoveryControlsDisabled"
:placeholder="$t('bots.skills.discoveryPathPlaceholder')"
class="min-h-32 font-mono text-xs"
/>
<p
v-if="discoveryRootError"
class="text-xs text-destructive"
>
{{ discoveryRootError }}
</p>
</div>
<p class="text-xs text-muted-foreground">
{{ $t('bots.skills.discoveryDefaultHint', { paths: DEFAULT_DISCOVERY_ROOTS.join(', ') }) }}
</p>
</div>
<DialogFooter>
<Button
variant="outline"
:disabled="discoveryControlsDisabled || !isDiscoveryRootsDirty"
@click="resetDiscoveryRoots"
>
{{ $t('bots.skills.discoveryReset') }}
</Button>
<Button
variant="outline"
:disabled="isSavingDiscoveryRoots"
@click="closeDiscoveryDialog"
>
{{ $t('common.cancel') }}
</Button>
<Button
:disabled="!canSaveDiscoveryRoots"
@click="handleSaveDiscoveryRoots"
>
<Spinner
v-if="isSavingDiscoveryRoots"
class="mr-2 size-4"
/>
{{ $t('common.save') }}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
</div>
</template>
<script setup lang="ts">
import { ArrowDownToLine, Eye, EyeOff, Plus, Zap, SquarePen, Trash2 } from 'lucide-vue-next'
import { ref, computed, onMounted } from 'vue'
import { ArrowDownToLine, Eye, EyeOff, Plus, SlidersHorizontal, Zap, SquarePen, Trash2 } from 'lucide-vue-next'
import { computed, ref, watch } from 'vue'
import { useI18n } from 'vue-i18n'
import { toast } from 'vue-sonner'
import { useQuery, useQueryCache } from '@pinia/colada'
import {
Badge, Button, Card, CardContent, CardHeader, CardTitle, CardDescription,
Dialog, DialogContent, DialogHeader, DialogTitle, DialogFooter, DialogClose,
Spinner,
Dialog, DialogContent, DialogHeader, DialogTitle, DialogDescription, DialogFooter, DialogClose,
Label, Spinner, Textarea,
} from '@memohai/ui'
import ConfirmPopover from '@/components/confirm-popover/index.vue'
import MonacoEditor from '@/components/monaco-editor/index.vue'
import {
getBotsById,
getBotsByBotIdContainerSkills,
postBotsByBotIdContainerSkills,
postBotsByBotIdContainerSkillsActions,
deleteBotsByBotIdContainerSkills,
putBotsById,
type HandlersSkillItem,
} from '@memohai/sdk'
import { resolveApiErrorMessage } from '@/utils/api-error'
@@ -268,6 +367,13 @@ const props = defineProps<{
}>()
const { t } = useI18n()
const queryCache = useQueryCache()
const MANAGED_SKILL_PATH = '/data/skills'
const DEFAULT_DISCOVERY_ROOTS = ['/data/.agents/skills', '/root/.agents/skills']
const RESERVED_DISCOVERY_ROOTS = new Set(['/data/skills', '/data/.skills'])
const WORKSPACE_METADATA_KEY = 'workspace'
const SKILL_DISCOVERY_ROOTS_METADATA_KEY = 'skill_discovery_roots'
const isLoading = ref(false)
const isSaving = ref(false)
@@ -277,6 +383,10 @@ const isActioning = ref(false)
const actionTargetPath = ref('')
const actionName = ref('')
const skills = ref<SkillItem[]>([])
const isSavingDiscoveryRoots = ref(false)
const isDiscoveryDialogOpen = ref(false)
const discoveryRootsDraft = ref(DEFAULT_DISCOVERY_ROOTS.join('\n'))
const savedDiscoveryRoots = ref<string[]>([...DEFAULT_DISCOVERY_ROOTS])
const isDialogOpen = ref(false)
const isEditing = ref(false)
@@ -294,6 +404,29 @@ const canSave = computed(() => {
return draftRaw.value.trim().length > 0
})
const { data: bot, refetch: refetchBot } = useQuery({
key: () => ['bot', props.botId],
query: async () => {
const { data } = await getBotsById({ path: { id: props.botId }, throwOnError: true })
return data
},
enabled: () => !!props.botId,
})
const discoveryRootErrors = computed(() => validateDiscoveryRoots(discoveryRootsDraft.value))
const discoveryRootError = computed(() => discoveryRootErrors.value[0] || '')
const hasDiscoveryRootErrors = computed(() => discoveryRootErrors.value.length > 0)
const normalizedDiscoveryRootDrafts = computed(() => normalizeDiscoveryRoots(parseDiscoveryRoots(discoveryRootsDraft.value)))
const isDiscoveryRootsDirty = computed(() => !areStringListsEqual(normalizedDiscoveryRootDrafts.value, savedDiscoveryRoots.value))
const savedDiscoveryRootsText = computed(() => savedDiscoveryRoots.value.join('\n'))
const isDiscoveryDraftModified = computed(() => discoveryRootsDraft.value !== savedDiscoveryRootsText.value)
const usesDefaultDiscoveryRoots = computed(() => areStringListsEqual(savedDiscoveryRoots.value, DEFAULT_DISCOVERY_ROOTS))
const showDiscoveryIndicator = computed(() => !usesDefaultDiscoveryRoots.value || isDiscoveryRootsDirty.value)
const discoveryControlsDisabled = computed(() => isSavingDiscoveryRoots.value || !bot.value)
const canSaveDiscoveryRoots = computed(() => {
return !!bot.value && isDiscoveryRootsDirty.value && !hasDiscoveryRootErrors.value && !isSavingDiscoveryRoots.value
})
async function fetchSkills() {
if (!props.botId) return
isLoading.value = true
@@ -310,6 +443,128 @@ async function fetchSkills() {
}
}
function cleanDiscoveryRoot(value: string) {
const trimmed = value.trim()
if (!trimmed.startsWith('/')) {
return trimmed
}
const parts = trimmed.split('/')
const stack: string[] = []
for (const part of parts) {
if (!part || part === '.') continue
if (part === '..') {
stack.pop()
continue
}
stack.push(part)
}
return `/${stack.join('/')}`
}
function parseDiscoveryRoots(value: string) {
return value
.split('\n')
.map(item => item.trim())
.filter(Boolean)
}
function normalizeDiscoveryRoots(values: string[]) {
const normalized: string[] = []
const seen = new Set<string>()
for (const value of values) {
const cleaned = cleanDiscoveryRoot(value)
if (!cleaned || !cleaned.startsWith('/')) continue
if (RESERVED_DISCOVERY_ROOTS.has(cleaned) || seen.has(cleaned)) continue
seen.add(cleaned)
normalized.push(cleaned)
}
return normalized
}
function validateDiscoveryRoots(value: string) {
const seen = new Set<string>()
const errors: string[] = []
for (const item of parseDiscoveryRoots(value)) {
const trimmed = item.trim()
const cleaned = cleanDiscoveryRoot(trimmed)
if (!cleaned.startsWith('/')) {
errors.push(t('bots.skills.discoveryPathAbsolute'))
continue
}
if (RESERVED_DISCOVERY_ROOTS.has(cleaned)) {
errors.push(t('bots.skills.discoveryPathReserved'))
continue
}
if (seen.has(cleaned)) {
errors.push(t('bots.skills.discoveryPathDuplicate'))
continue
}
seen.add(cleaned)
}
return [...new Set(errors)]
}
function areStringListsEqual(left: string[], right: string[]) {
return left.length === right.length && left.every((item, index) => item === right[index])
}
function isRecord(value: unknown): value is Record<string, unknown> {
return !!value && typeof value === 'object' && !Array.isArray(value)
}
function readDiscoveryRoots(metadata: Record<string, unknown> | undefined) {
const workspace = metadata?.[WORKSPACE_METADATA_KEY]
if (!isRecord(workspace)) {
return [...DEFAULT_DISCOVERY_ROOTS]
}
if (!Object.prototype.hasOwnProperty.call(workspace, SKILL_DISCOVERY_ROOTS_METADATA_KEY)) {
return [...DEFAULT_DISCOVERY_ROOTS]
}
const rawRoots = workspace[SKILL_DISCOVERY_ROOTS_METADATA_KEY]
if (!Array.isArray(rawRoots)) {
return []
}
return normalizeDiscoveryRoots(
rawRoots.filter((value): value is string => typeof value === 'string'),
)
}
function withDiscoveryRootsMetadata(metadata: Record<string, unknown> | undefined, roots: string[]) {
const nextMetadata = isRecord(metadata) ? { ...metadata } : {}
const workspaceSection = isRecord(nextMetadata[WORKSPACE_METADATA_KEY])
? { ...(nextMetadata[WORKSPACE_METADATA_KEY] as Record<string, unknown>) }
: {}
workspaceSection[SKILL_DISCOVERY_ROOTS_METADATA_KEY] = normalizeDiscoveryRoots(roots)
nextMetadata[WORKSPACE_METADATA_KEY] = workspaceSection
return nextMetadata
}
function syncDiscoveryRoots(roots: string[]) {
const nextRoots = [...roots]
discoveryRootsDraft.value = nextRoots.join('\n')
savedDiscoveryRoots.value = nextRoots
}
function resetDiscoveryRoots() {
syncDiscoveryRoots(savedDiscoveryRoots.value)
}
function closeDiscoveryDialog() {
resetDiscoveryRoots()
isDiscoveryDialogOpen.value = false
}
function handleCreate() {
isEditing.value = false
draftRaw.value = SKILL_TEMPLATE
@@ -420,6 +675,41 @@ async function handleSave() {
}
}
async function handleSaveDiscoveryRoots() {
if (!canSaveDiscoveryRoots.value) return
isSavingDiscoveryRoots.value = true
try {
const metadata = withDiscoveryRootsMetadata(
bot.value?.metadata as Record<string, unknown> | undefined,
normalizedDiscoveryRootDrafts.value,
)
await putBotsById({
path: { id: props.botId },
body: { metadata },
throwOnError: true,
})
void queryCache.invalidateQueries({ key: ['bot', props.botId] })
void queryCache.invalidateQueries({ key: ['bot'] })
void queryCache.invalidateQueries({ key: ['bots'] })
syncDiscoveryRoots(normalizedDiscoveryRootDrafts.value)
isDiscoveryDialogOpen.value = false
toast.success(t('bots.skills.discoverySaveSuccess'))
await Promise.all([
refetchBot(),
fetchSkills(),
])
} catch (error) {
toast.error(resolveApiErrorMessage(error, t('bots.skills.discoverySaveFailed')))
} finally {
isSavingDiscoveryRoots.value = false
}
}
async function handleDelete(name?: string) {
if (!name) return
isDeleting.value = true
@@ -442,7 +732,22 @@ async function handleDelete(name?: string) {
}
}
onMounted(() => {
fetchSkills()
watch(() => props.botId, () => {
if (!props.botId) return
isDiscoveryDialogOpen.value = false
syncDiscoveryRoots(DEFAULT_DISCOVERY_ROOTS)
void fetchSkills()
}, { immediate: true })
watch(bot, (value) => {
if (!value) return
if (isDiscoveryRootsDirty.value && !isSavingDiscoveryRoots.value) return
syncDiscoveryRoots(readDiscoveryRoots(value.metadata as Record<string, unknown> | undefined))
}, { immediate: true })
watch(isDiscoveryDialogOpen, (open, prevOpen) => {
if (!open && prevOpen && !isSavingDiscoveryRoots.value && (isDiscoveryDraftModified.value || hasDiscoveryRootErrors.value)) {
resetDiscoveryRoots()
}
})
</script>