mirror of
https://github.com/memohai/Memoh.git
synced 2026-04-25 07:00:48 +09:00
feat: add bot-level skill paths configuration (#383)
This commit is contained in:
@@ -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",
|
||||
|
||||
@@ -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": "技能已启用",
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -10,6 +10,7 @@ import (
|
||||
"github.com/labstack/echo/v4"
|
||||
|
||||
skillset "github.com/memohai/memoh/internal/skills"
|
||||
"github.com/memohai/memoh/internal/workspace"
|
||||
)
|
||||
|
||||
type SkillItem struct {
|
||||
@@ -190,8 +191,12 @@ func (h *ContainerdHandler) ApplySkillAction(c echo.Context) error {
|
||||
if err != nil {
|
||||
return echo.NewHTTPError(http.StatusInternalServerError, fmt.Sprintf("container not reachable: %v", err))
|
||||
}
|
||||
roots, err := h.skillDiscoveryRoots(ctx, botID)
|
||||
if err != nil {
|
||||
return echo.NewHTTPError(http.StatusInternalServerError, err.Error())
|
||||
}
|
||||
|
||||
if err := skillset.ApplyAction(ctx, client, skillset.ActionRequest{
|
||||
if err := skillset.ApplyAction(ctx, client, roots, skillset.ActionRequest{
|
||||
Action: req.Action,
|
||||
TargetPath: req.TargetPath,
|
||||
}); err != nil {
|
||||
@@ -207,7 +212,11 @@ func (h *ContainerdHandler) LoadSkills(ctx context.Context, botID string) ([]Ski
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
items, err := skillset.LoadEffective(ctx, client)
|
||||
roots, err := h.skillDiscoveryRoots(ctx, botID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
items, err := skillset.LoadEffective(ctx, client, roots)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
@@ -219,13 +228,30 @@ func (h *ContainerdHandler) listSkillsFromContainer(ctx context.Context, botID s
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
items, err := skillset.List(ctx, client)
|
||||
roots, err := h.skillDiscoveryRoots(ctx, botID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
items, err := skillset.List(ctx, client, roots)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return skillItemsFromEntries(items), nil
|
||||
}
|
||||
|
||||
func (h *ContainerdHandler) skillDiscoveryRoots(ctx context.Context, botID string) ([]string, error) {
|
||||
if h.botService != nil {
|
||||
bot, err := h.botService.Get(ctx, botID)
|
||||
if err == nil {
|
||||
return workspace.SkillDiscoveryRootsFromMetadata(bot.Metadata), nil
|
||||
}
|
||||
}
|
||||
if h.manager == nil {
|
||||
return nil, nil
|
||||
}
|
||||
return h.manager.ResolveWorkspaceSkillDiscoveryRoots(ctx, botID)
|
||||
}
|
||||
|
||||
func skillItemsFromEntries(entries []skillset.Entry) []SkillItem {
|
||||
items := make([]SkillItem, len(entries))
|
||||
for i, entry := range entries {
|
||||
|
||||
@@ -253,7 +253,11 @@ func TestLoadSkillsUsesEffectiveSetAndPromptReflectsOverrideFallback(t *testing.
|
||||
if err != nil {
|
||||
t.Fatalf("get bridge client: %v", err)
|
||||
}
|
||||
if err := skillset.ApplyAction(context.Background(), client, skillset.ActionRequest{
|
||||
roots, err := env.handler.skillDiscoveryRoots(context.Background(), env.botID)
|
||||
if err != nil {
|
||||
t.Fatalf("resolve skill discovery roots: %v", err)
|
||||
}
|
||||
if err := skillset.ApplyAction(context.Background(), client, roots, skillset.ActionRequest{
|
||||
Action: skillset.ActionDisable,
|
||||
TargetPath: managedPath,
|
||||
}); err != nil {
|
||||
@@ -280,6 +284,27 @@ func TestLoadSkillsUsesEffectiveSetAndPromptReflectsOverrideFallback(t *testing.
|
||||
}
|
||||
}
|
||||
|
||||
func TestListSkillsAPIUsesConfiguredDiscoveryRoots(t *testing.T) {
|
||||
env := newSkillsTestEnvWithMetadata(t, map[string]any{
|
||||
"workspace": map[string]any{
|
||||
"skill_discovery_roots": []string{"/root/.openclaw/skills"},
|
||||
},
|
||||
})
|
||||
env.writeSkillFile(t, path.Join("/root/.openclaw/skills", "alpha", "SKILL.md"), managedSkillRaw("alpha", "OpenClaw Alpha"))
|
||||
env.writeSkillFile(t, path.Join("/data/.agents/skills", "beta", "SKILL.md"), managedSkillRaw("beta", "Ignored Beta"))
|
||||
|
||||
skills := env.listSkills(t)
|
||||
if len(skills) != 1 {
|
||||
t.Fatalf("expected 1 configured-discovery skill, got %d", len(skills))
|
||||
}
|
||||
if got := skills[0].SourceRoot; got != "/root/.openclaw/skills" {
|
||||
t.Fatalf("source_root = %q, want %q", got, "/root/.openclaw/skills")
|
||||
}
|
||||
if got := skills[0].Name; got != "alpha" {
|
||||
t.Fatalf("skill name = %q, want %q", got, "alpha")
|
||||
}
|
||||
}
|
||||
|
||||
type skillsTestEnv struct {
|
||||
handler *ContainerdHandler
|
||||
dataRoot string
|
||||
@@ -288,6 +313,10 @@ type skillsTestEnv struct {
|
||||
}
|
||||
|
||||
func newSkillsTestEnv(t *testing.T) *skillsTestEnv {
|
||||
return newSkillsTestEnvWithMetadata(t, nil)
|
||||
}
|
||||
|
||||
func newSkillsTestEnvWithMetadata(t *testing.T, metadata map[string]any) *skillsTestEnv {
|
||||
t.Helper()
|
||||
|
||||
dataRoot, err := newSkillsTestDataRoot()
|
||||
@@ -300,7 +329,18 @@ func newSkillsTestEnv(t *testing.T) *skillsTestEnv {
|
||||
startSkillsTestBridgeServer(t, dataRoot, botID)
|
||||
|
||||
cfg := config.WorkspaceConfig{DataRoot: dataRoot}
|
||||
db := &skillsTestDB{userID: userID, botID: botID}
|
||||
var metadataJSON []byte
|
||||
if metadata != nil {
|
||||
var err error
|
||||
metadataJSON, err = json.Marshal(metadata)
|
||||
if err != nil {
|
||||
t.Fatalf("marshal bot metadata: %v", err)
|
||||
}
|
||||
} else {
|
||||
metadataJSON = []byte(`{}`)
|
||||
}
|
||||
cfg.DataRoot = dataRoot
|
||||
db := &skillsTestDB{userID: userID, botID: botID, metadataJSON: metadataJSON}
|
||||
manager := workspace.NewManager(slog.Default(), nil, cfg, "", nil)
|
||||
handler := NewContainerdHandler(
|
||||
slog.Default(),
|
||||
@@ -395,8 +435,9 @@ func newSkillsTestDataRoot() (string, error) {
|
||||
}
|
||||
|
||||
type skillsTestDB struct {
|
||||
userID string
|
||||
botID string
|
||||
userID string
|
||||
botID string
|
||||
metadataJSON []byte
|
||||
}
|
||||
|
||||
func (*skillsTestDB) Exec(context.Context, string, ...interface{}) (pgconn.CommandTag, error) {
|
||||
@@ -412,7 +453,7 @@ func (d *skillsTestDB) QueryRow(_ context.Context, sql string, _ ...interface{})
|
||||
case strings.Contains(sql, "FROM users WHERE id = $1"):
|
||||
return makeUserRow(mustParseUUID(d.userID), "user")
|
||||
case strings.Contains(sql, "FROM bots"):
|
||||
return makeBotRow(mustParseUUID(d.botID), mustParseUUID(d.userID))
|
||||
return makeBotRow(mustParseUUID(d.botID), mustParseUUID(d.userID), d.metadataJSON)
|
||||
default:
|
||||
return &skillsTestRow{scanFunc: func(_ ...any) error { return pgx.ErrNoRows }}
|
||||
}
|
||||
@@ -451,7 +492,7 @@ func makeUserRow(userID pgtype.UUID, role string) *skillsTestRow {
|
||||
}
|
||||
}
|
||||
|
||||
func makeBotRow(botID, ownerUserID pgtype.UUID) *skillsTestRow {
|
||||
func makeBotRow(botID, ownerUserID pgtype.UUID, metadataJSON []byte) *skillsTestRow {
|
||||
return &skillsTestRow{
|
||||
scanFunc: func(dest ...any) error {
|
||||
if len(dest) < 23 {
|
||||
@@ -477,7 +518,7 @@ func makeBotRow(botID, ownerUserID pgtype.UUID) *skillsTestRow {
|
||||
*dest[17].(*int32) = 100000
|
||||
*dest[18].(*int32) = 80
|
||||
*dest[19].(*pgtype.UUID) = pgtype.UUID{}
|
||||
*dest[20].(*[]byte) = []byte(`{}`)
|
||||
*dest[20].(*[]byte) = metadataJSON
|
||||
*dest[21].(*pgtype.Timestamptz) = pgtype.Timestamptz{}
|
||||
*dest[22].(*pgtype.Timestamptz) = pgtype.Timestamptz{}
|
||||
return nil
|
||||
|
||||
+63
-19
@@ -19,10 +19,11 @@ import (
|
||||
)
|
||||
|
||||
const (
|
||||
ManagedDirPath = config.DefaultDataMount + "/skills"
|
||||
LegacyDirPath = config.DefaultDataMount + "/.skills"
|
||||
IndexDirPath = config.DefaultDataMount + "/.memoh/skills"
|
||||
IndexFilePath = IndexDirPath + "/index.json"
|
||||
ManagedDirPath = config.DefaultDataMount + "/skills"
|
||||
LegacyDirPath = config.DefaultDataMount + "/.skills"
|
||||
IndexDirPath = config.DefaultDataMount + "/.memoh/skills"
|
||||
IndexFilePath = IndexDirPath + "/index.json"
|
||||
SkillDiscoveryRootsEnvVar = "MEMOH_SKILL_DISCOVERY_ROOTS"
|
||||
|
||||
SourceKindManaged = "managed"
|
||||
SourceKindLegacy = "legacy"
|
||||
@@ -115,34 +116,39 @@ func ManagedSkillDirForName(name string) (string, error) {
|
||||
return dirPath, nil
|
||||
}
|
||||
|
||||
func ContainerEnv() []string {
|
||||
return []string{
|
||||
func ContainerEnv(rawCompatRoots []string) []string {
|
||||
compatRoots := compatDiscoveryRoots(rawCompatRoots)
|
||||
env := []string{
|
||||
"HOME=" + config.DefaultDataMount,
|
||||
"XDG_CONFIG_HOME=" + path.Join(config.DefaultDataMount, ".config"),
|
||||
"XDG_DATA_HOME=" + path.Join(config.DefaultDataMount, ".local", "share"),
|
||||
"XDG_CACHE_HOME=" + path.Join(config.DefaultDataMount, ".cache"),
|
||||
}
|
||||
env = append(env, SkillDiscoveryRootsEnvVar+"="+strings.Join(compatRoots, ":"))
|
||||
return env
|
||||
}
|
||||
|
||||
func DiscoveryRoots() []Root {
|
||||
return []Root{
|
||||
func DiscoveryRoots(rawCompatRoots []string) []Root {
|
||||
roots := []Root{
|
||||
{Path: ManagedDirPath, Kind: SourceKindManaged, Managed: true},
|
||||
{Path: LegacyDirPath, Kind: SourceKindLegacy, Managed: false},
|
||||
{Path: path.Join(config.DefaultDataMount, ".agents", "skills"), Kind: SourceKindCompat, Managed: false},
|
||||
{Path: path.Join("/root", ".agents", "skills"), Kind: SourceKindCompat, Managed: false},
|
||||
}
|
||||
for _, compatRoot := range compatDiscoveryRoots(rawCompatRoots) {
|
||||
roots = append(roots, Root{Path: compatRoot, Kind: SourceKindCompat, Managed: false})
|
||||
}
|
||||
return roots
|
||||
}
|
||||
|
||||
func List(ctx context.Context, client fileClient) ([]Entry, error) {
|
||||
func List(ctx context.Context, client fileClient, rawCompatRoots []string) ([]Entry, error) {
|
||||
idx := readIndex(ctx, client)
|
||||
items := scan(ctx, client, DiscoveryRoots())
|
||||
items := scan(ctx, client, DiscoveryRoots(rawCompatRoots))
|
||||
resolved := resolve(items, idx.Overrides)
|
||||
writeIndex(ctx, client, idx.withItems(resolved))
|
||||
return resolved, nil
|
||||
}
|
||||
|
||||
func LoadEffective(ctx context.Context, client fileClient) ([]Entry, error) {
|
||||
items, err := List(ctx, client)
|
||||
func LoadEffective(ctx context.Context, client fileClient, rawCompatRoots []string) ([]Entry, error) {
|
||||
items, err := List(ctx, client, rawCompatRoots)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
@@ -155,7 +161,7 @@ func LoadEffective(ctx context.Context, client fileClient) ([]Entry, error) {
|
||||
return out, nil
|
||||
}
|
||||
|
||||
func ApplyAction(ctx context.Context, client fileClient, req ActionRequest) error {
|
||||
func ApplyAction(ctx context.Context, client fileClient, rawCompatRoots []string, req ActionRequest) error {
|
||||
targetPath := strings.TrimSpace(req.TargetPath)
|
||||
if targetPath == "" {
|
||||
return bridge.ErrBadRequest
|
||||
@@ -164,7 +170,7 @@ func ApplyAction(ctx context.Context, client fileClient, req ActionRequest) erro
|
||||
switch strings.TrimSpace(req.Action) {
|
||||
case ActionDisable:
|
||||
idx := readIndex(ctx, client)
|
||||
items := scan(ctx, client, DiscoveryRoots())
|
||||
items := scan(ctx, client, DiscoveryRoots(rawCompatRoots))
|
||||
if !containsSourcePath(items, targetPath) {
|
||||
return bridge.ErrNotFound
|
||||
}
|
||||
@@ -176,7 +182,7 @@ func ApplyAction(ctx context.Context, client fileClient, req ActionRequest) erro
|
||||
return nil
|
||||
case ActionEnable:
|
||||
idx := readIndex(ctx, client)
|
||||
items := scan(ctx, client, DiscoveryRoots())
|
||||
items := scan(ctx, client, DiscoveryRoots(rawCompatRoots))
|
||||
if !containsSourcePath(items, targetPath) {
|
||||
return bridge.ErrNotFound
|
||||
}
|
||||
@@ -184,7 +190,7 @@ func ApplyAction(ctx context.Context, client fileClient, req ActionRequest) erro
|
||||
writeIndex(ctx, client, idx.withItems(resolve(items, idx.Overrides)))
|
||||
return nil
|
||||
case ActionAdopt:
|
||||
items := scan(ctx, client, DiscoveryRoots())
|
||||
items := scan(ctx, client, DiscoveryRoots(rawCompatRoots))
|
||||
target, ok := findBySourcePath(items, targetPath)
|
||||
if !ok {
|
||||
return bridge.ErrNotFound
|
||||
@@ -208,13 +214,51 @@ func ApplyAction(ctx context.Context, client fileClient, req ActionRequest) erro
|
||||
return err
|
||||
}
|
||||
idx := readIndex(ctx, client)
|
||||
writeIndex(ctx, client, idx.withItems(resolve(scan(ctx, client, DiscoveryRoots()), idx.Overrides)))
|
||||
writeIndex(ctx, client, idx.withItems(resolve(scan(ctx, client, DiscoveryRoots(rawCompatRoots)), idx.Overrides)))
|
||||
return nil
|
||||
default:
|
||||
return bridge.ErrBadRequest
|
||||
}
|
||||
}
|
||||
|
||||
func compatDiscoveryRoots(rawRoots []string) []string {
|
||||
if rawRoots == nil {
|
||||
rawRoots = defaultCompatDiscoveryRoots()
|
||||
}
|
||||
return normalizeCompatDiscoveryRoots(rawRoots)
|
||||
}
|
||||
|
||||
func defaultCompatDiscoveryRoots() []string {
|
||||
return []string{
|
||||
path.Join(config.DefaultDataMount, ".agents", "skills"),
|
||||
path.Join("/root", ".agents", "skills"),
|
||||
}
|
||||
}
|
||||
|
||||
func normalizeCompatDiscoveryRoots(paths []string) []string {
|
||||
out := make([]string, 0, len(paths))
|
||||
seen := make(map[string]struct{}, len(paths))
|
||||
for _, p := range paths {
|
||||
p = strings.TrimSpace(p)
|
||||
if p == "" {
|
||||
continue
|
||||
}
|
||||
p = path.Clean(p)
|
||||
if !strings.HasPrefix(p, "/") {
|
||||
continue
|
||||
}
|
||||
if p == ManagedDirPath || p == LegacyDirPath {
|
||||
continue
|
||||
}
|
||||
if _, ok := seen[p]; ok {
|
||||
continue
|
||||
}
|
||||
seen[p] = struct{}{}
|
||||
out = append(out, p)
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func ParseFile(raw string, fallbackName string) Parsed {
|
||||
trimmed := strings.TrimSpace(raw)
|
||||
result := Parsed{
|
||||
|
||||
@@ -66,7 +66,7 @@ func TestListReadsFullRawContentAndWritesIndex(t *testing.T) {
|
||||
client.listings[ManagedDirPath] = []*pb.FileEntry{{Path: "alpha", IsDir: true}}
|
||||
client.files[pathJoin(ManagedDirPath, "alpha", "SKILL.md")] = "---\nname: alpha\ndescription: Alpha\n---\n\n" + strings.Repeat("A", 7000)
|
||||
|
||||
items, err := List(context.Background(), client)
|
||||
items, err := List(context.Background(), client, nil)
|
||||
if err != nil {
|
||||
t.Fatalf("List returned error: %v", err)
|
||||
}
|
||||
@@ -87,7 +87,7 @@ func TestApplyActionAdoptAndDisable(t *testing.T) {
|
||||
client.listings["/data/.agents/skills"] = []*pb.FileEntry{{Path: "alpha", IsDir: true}}
|
||||
client.files[externalPath] = "---\nname: alpha\ndescription: Alpha\n---\n\n# Alpha"
|
||||
|
||||
if err := ApplyAction(context.Background(), client, ActionRequest{
|
||||
if err := ApplyAction(context.Background(), client, nil, ActionRequest{
|
||||
Action: ActionAdopt,
|
||||
TargetPath: externalPath,
|
||||
}); err != nil {
|
||||
@@ -97,7 +97,7 @@ func TestApplyActionAdoptAndDisable(t *testing.T) {
|
||||
t.Fatalf("expected managed copy after adopt")
|
||||
}
|
||||
|
||||
if err := ApplyAction(context.Background(), client, ActionRequest{
|
||||
if err := ApplyAction(context.Background(), client, nil, ActionRequest{
|
||||
Action: ActionDisable,
|
||||
TargetPath: externalPath,
|
||||
}); err != nil {
|
||||
@@ -115,7 +115,7 @@ func TestApplyActionAdoptRejectsInvalidManagedName(t *testing.T) {
|
||||
client.listings["/data/.agents/skills"] = []*pb.FileEntry{{Path: "escape", IsDir: true}}
|
||||
client.files[externalPath] = "---\nname: ..\ndescription: Escape\n---\n\n# Escape"
|
||||
|
||||
err := ApplyAction(context.Background(), client, ActionRequest{
|
||||
err := ApplyAction(context.Background(), client, nil, ActionRequest{
|
||||
Action: ActionAdopt,
|
||||
TargetPath: externalPath,
|
||||
})
|
||||
@@ -165,8 +165,8 @@ func TestManagedSkillDirForNameRejectsEscapingNames(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestDiscoveryRootsMatchCurrentPolicy(t *testing.T) {
|
||||
roots := DiscoveryRoots()
|
||||
func TestDiscoveryRootsMatchDefaultPolicy(t *testing.T) {
|
||||
roots := DiscoveryRoots(nil)
|
||||
want := []Root{
|
||||
{Path: ManagedDirPath, Kind: SourceKindManaged, Managed: true},
|
||||
{Path: LegacyDirPath, Kind: SourceKindLegacy, Managed: false},
|
||||
@@ -178,15 +178,46 @@ func TestDiscoveryRootsMatchCurrentPolicy(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestDiscoveryRootsUseConfiguredCompatRoots(t *testing.T) {
|
||||
roots := DiscoveryRoots([]string{
|
||||
" /custom/skills ",
|
||||
"/data/skills",
|
||||
"/custom/skills",
|
||||
"relative/skills",
|
||||
"/root/.openclaw/skills",
|
||||
})
|
||||
want := []Root{
|
||||
{Path: ManagedDirPath, Kind: SourceKindManaged, Managed: true},
|
||||
{Path: LegacyDirPath, Kind: SourceKindLegacy, Managed: false},
|
||||
{Path: "/custom/skills", Kind: SourceKindCompat, Managed: false},
|
||||
{Path: "/root/.openclaw/skills", Kind: SourceKindCompat, Managed: false},
|
||||
}
|
||||
if !slices.Equal(roots, want) {
|
||||
t.Fatalf("DiscoveryRoots(custom) = %+v, want %+v", roots, want)
|
||||
}
|
||||
}
|
||||
|
||||
func TestDiscoveryRootsAllowExplicitEmptyCompatRoots(t *testing.T) {
|
||||
roots := DiscoveryRoots([]string{})
|
||||
want := []Root{
|
||||
{Path: ManagedDirPath, Kind: SourceKindManaged, Managed: true},
|
||||
{Path: LegacyDirPath, Kind: SourceKindLegacy, Managed: false},
|
||||
}
|
||||
if !slices.Equal(roots, want) {
|
||||
t.Fatalf("DiscoveryRoots(empty) = %+v, want %+v", roots, want)
|
||||
}
|
||||
}
|
||||
|
||||
func TestListScansConfiguredDiscoveryRootsInOrder(t *testing.T) {
|
||||
client := newFakeClient()
|
||||
for _, root := range DiscoveryRoots() {
|
||||
rawCompatRoots := []string(nil)
|
||||
for _, root := range DiscoveryRoots(rawCompatRoots) {
|
||||
client.listings[root.Path] = nil
|
||||
}
|
||||
client.listings[ManagedDirPath] = []*pb.FileEntry{{Path: "alpha", IsDir: true}}
|
||||
client.files[pathJoin(ManagedDirPath, "alpha", "SKILL.md")] = "---\nname: alpha\ndescription: Alpha\n---\n\n# Alpha"
|
||||
|
||||
items, err := List(context.Background(), client)
|
||||
items, err := List(context.Background(), client, rawCompatRoots)
|
||||
if err != nil {
|
||||
t.Fatalf("List returned error: %v", err)
|
||||
}
|
||||
@@ -194,8 +225,8 @@ func TestListScansConfiguredDiscoveryRootsInOrder(t *testing.T) {
|
||||
t.Fatalf("List() items = %+v, want managed alpha only", items)
|
||||
}
|
||||
|
||||
wantCalls := make([]string, 0, len(DiscoveryRoots()))
|
||||
for _, root := range DiscoveryRoots() {
|
||||
wantCalls := make([]string, 0, len(DiscoveryRoots(rawCompatRoots)))
|
||||
for _, root := range DiscoveryRoots(rawCompatRoots) {
|
||||
wantCalls = append(wantCalls, root.Path)
|
||||
}
|
||||
if !slices.Equal(client.listCalls, wantCalls) {
|
||||
@@ -204,12 +235,13 @@ func TestListScansConfiguredDiscoveryRootsInOrder(t *testing.T) {
|
||||
}
|
||||
|
||||
func TestContainerEnvUsesDataHomeAndXDGDirs(t *testing.T) {
|
||||
env := ContainerEnv()
|
||||
env := ContainerEnv(nil)
|
||||
for _, want := range []string{
|
||||
"HOME=/data",
|
||||
"XDG_CONFIG_HOME=/data/.config",
|
||||
"XDG_DATA_HOME=/data/.local/share",
|
||||
"XDG_CACHE_HOME=/data/.cache",
|
||||
"MEMOH_SKILL_DISCOVERY_ROOTS=/data/.agents/skills:/root/.agents/skills",
|
||||
} {
|
||||
if !slices.Contains(env, want) {
|
||||
t.Fatalf("env %+v does not contain %q", env, want)
|
||||
@@ -217,6 +249,14 @@ func TestContainerEnvUsesDataHomeAndXDGDirs(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestContainerEnvUsesConfiguredSkillDiscoveryRoots(t *testing.T) {
|
||||
env := ContainerEnv([]string{"/custom/skills", "/root/.openclaw/skills"})
|
||||
want := SkillDiscoveryRootsEnvVar + "=/custom/skills:/root/.openclaw/skills"
|
||||
if !slices.Contains(env, want) {
|
||||
t.Fatalf("env %+v does not contain %q", env, want)
|
||||
}
|
||||
}
|
||||
|
||||
type fakeClient struct {
|
||||
listings map[string][]*pb.FileEntry
|
||||
files map[string]string
|
||||
|
||||
@@ -4,6 +4,7 @@ import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"path"
|
||||
"strings"
|
||||
|
||||
"github.com/jackc/pgx/v5"
|
||||
@@ -14,10 +15,11 @@ import (
|
||||
)
|
||||
|
||||
const (
|
||||
workspaceMetadataKey = "workspace"
|
||||
workspaceImageMetadataKey = "image"
|
||||
workspaceGPUMetadataKey = "gpu"
|
||||
workspaceGPUDevicesKey = "devices"
|
||||
workspaceMetadataKey = "workspace"
|
||||
workspaceImageMetadataKey = "image"
|
||||
workspaceGPUMetadataKey = "gpu"
|
||||
workspaceGPUDevicesKey = "devices"
|
||||
workspaceSkillDiscoveryRootsMetadataKey = "skill_discovery_roots"
|
||||
)
|
||||
|
||||
type WorkspaceGPUConfig struct {
|
||||
@@ -115,6 +117,30 @@ func workspaceGPUFromMetadata(metadata map[string]any) (WorkspaceGPUConfig, bool
|
||||
return WorkspaceGPUConfig{Devices: normalizeWorkspaceGPUDevices(devices)}, true
|
||||
}
|
||||
|
||||
func workspaceSkillDiscoveryRootsFromMetadata(metadata map[string]any) ([]string, bool) {
|
||||
section := workspaceSection(metadata)
|
||||
raw, ok := section[workspaceSkillDiscoveryRootsMetadataKey]
|
||||
if !ok {
|
||||
return nil, false
|
||||
}
|
||||
|
||||
var roots []string
|
||||
switch typed := raw.(type) {
|
||||
case []string:
|
||||
roots = append(roots, typed...)
|
||||
case []any:
|
||||
for _, item := range typed {
|
||||
if root, ok := item.(string); ok {
|
||||
roots = append(roots, root)
|
||||
}
|
||||
}
|
||||
default:
|
||||
return []string{}, true
|
||||
}
|
||||
|
||||
return normalizeWorkspaceSkillDiscoveryRoots(roots), true
|
||||
}
|
||||
|
||||
func withWorkspaceImagePreference(metadata map[string]any, image string) map[string]any {
|
||||
next := cloneAnyMap(metadata)
|
||||
section := workspaceSection(next)
|
||||
@@ -145,6 +171,18 @@ func withWorkspaceGPUPreference(metadata map[string]any, gpu WorkspaceGPUConfig)
|
||||
return next
|
||||
}
|
||||
|
||||
func withWorkspaceSkillDiscoveryRoots(metadata map[string]any, roots []string) map[string]any {
|
||||
next := cloneAnyMap(metadata)
|
||||
section := workspaceSection(next)
|
||||
normalized := normalizeWorkspaceSkillDiscoveryRoots(roots)
|
||||
if normalized == nil {
|
||||
normalized = []string{}
|
||||
}
|
||||
section[workspaceSkillDiscoveryRootsMetadataKey] = normalized
|
||||
next[workspaceMetadataKey] = section
|
||||
return next
|
||||
}
|
||||
|
||||
func withoutWorkspaceGPUPreference(metadata map[string]any) map[string]any {
|
||||
next := cloneAnyMap(metadata)
|
||||
section := workspaceSection(next)
|
||||
@@ -157,8 +195,20 @@ func withoutWorkspaceGPUPreference(metadata map[string]any) map[string]any {
|
||||
return next
|
||||
}
|
||||
|
||||
func withoutWorkspaceSkillDiscoveryRoots(metadata map[string]any) map[string]any {
|
||||
next := cloneAnyMap(metadata)
|
||||
section := workspaceSection(next)
|
||||
delete(section, workspaceSkillDiscoveryRootsMetadataKey)
|
||||
if len(section) == 0 {
|
||||
delete(next, workspaceMetadataKey)
|
||||
return next
|
||||
}
|
||||
next[workspaceMetadataKey] = section
|
||||
return next
|
||||
}
|
||||
|
||||
func (m *Manager) botWorkspaceImagePreference(ctx context.Context, botID string) (string, error) {
|
||||
if m.queries == nil {
|
||||
if m.db == nil || m.queries == nil {
|
||||
return "", nil
|
||||
}
|
||||
botUUID, err := db.ParseUUID(botID)
|
||||
@@ -180,7 +230,7 @@ func (m *Manager) botWorkspaceImagePreference(ctx context.Context, botID string)
|
||||
}
|
||||
|
||||
func (m *Manager) updateBotWorkspaceImagePreference(ctx context.Context, botID, image string, clearPreference bool) error {
|
||||
if m.queries == nil {
|
||||
if m.db == nil || m.queries == nil {
|
||||
return nil
|
||||
}
|
||||
botUUID, err := db.ParseUUID(botID)
|
||||
@@ -224,7 +274,7 @@ func (m *Manager) ClearWorkspaceImagePreference(ctx context.Context, botID strin
|
||||
}
|
||||
|
||||
func (m *Manager) botWorkspaceGPUPreference(ctx context.Context, botID string) (WorkspaceGPUConfig, bool, error) {
|
||||
if m.queries == nil {
|
||||
if m.db == nil || m.queries == nil {
|
||||
return WorkspaceGPUConfig{}, false, nil
|
||||
}
|
||||
botUUID, err := db.ParseUUID(botID)
|
||||
@@ -246,8 +296,31 @@ func (m *Manager) botWorkspaceGPUPreference(ctx context.Context, botID string) (
|
||||
return gpu, ok, nil
|
||||
}
|
||||
|
||||
func (m *Manager) botWorkspaceSkillDiscoveryRootsPreference(ctx context.Context, botID string) ([]string, bool, error) {
|
||||
if m.db == nil || m.queries == nil {
|
||||
return nil, false, nil
|
||||
}
|
||||
botUUID, err := db.ParseUUID(botID)
|
||||
if err != nil {
|
||||
return nil, false, err
|
||||
}
|
||||
row, err := m.queries.GetBotByID(ctx, botUUID)
|
||||
if err != nil {
|
||||
if errors.Is(err, pgx.ErrNoRows) {
|
||||
return nil, false, nil
|
||||
}
|
||||
return nil, false, err
|
||||
}
|
||||
metadata, err := decodeBotMetadata(row.Metadata)
|
||||
if err != nil {
|
||||
return nil, false, err
|
||||
}
|
||||
roots, ok := workspaceSkillDiscoveryRootsFromMetadata(metadata)
|
||||
return roots, ok, nil
|
||||
}
|
||||
|
||||
func (m *Manager) updateBotWorkspaceGPUPreference(ctx context.Context, botID string, gpu WorkspaceGPUConfig, clearPreference bool) error {
|
||||
if m.queries == nil {
|
||||
if m.db == nil || m.queries == nil {
|
||||
return nil
|
||||
}
|
||||
botUUID, err := db.ParseUUID(botID)
|
||||
@@ -299,8 +372,20 @@ func (m *Manager) ResolveWorkspaceGPU(ctx context.Context, botID string) (Worksp
|
||||
return m.resolveWorkspaceGPU(ctx, botID)
|
||||
}
|
||||
|
||||
func (m *Manager) ResolveWorkspaceSkillDiscoveryRoots(ctx context.Context, botID string) ([]string, error) {
|
||||
return m.resolveWorkspaceSkillDiscoveryRoots(ctx, botID)
|
||||
}
|
||||
|
||||
func SkillDiscoveryRootsFromMetadata(metadata map[string]any) []string {
|
||||
roots, ok := workspaceSkillDiscoveryRootsFromMetadata(metadata)
|
||||
if !ok {
|
||||
return nil
|
||||
}
|
||||
return roots
|
||||
}
|
||||
|
||||
func (m *Manager) resolveWorkspaceImage(ctx context.Context, botID string) (string, error) {
|
||||
if m.queries != nil {
|
||||
if m.db != nil && m.queries != nil {
|
||||
pgBotID, err := db.ParseUUID(botID)
|
||||
if err == nil {
|
||||
row, dbErr := m.queries.GetContainerByBotID(ctx, pgBotID)
|
||||
@@ -336,3 +421,40 @@ func (m *Manager) resolveWorkspaceGPU(ctx context.Context, botID string) (Worksp
|
||||
|
||||
return WorkspaceGPUConfig{}, nil
|
||||
}
|
||||
|
||||
func (m *Manager) resolveWorkspaceSkillDiscoveryRoots(ctx context.Context, botID string) ([]string, error) {
|
||||
roots, hasPreference, err := m.botWorkspaceSkillDiscoveryRootsPreference(ctx, botID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if !hasPreference {
|
||||
return nil, nil
|
||||
}
|
||||
return roots, nil
|
||||
}
|
||||
|
||||
func normalizeWorkspaceSkillDiscoveryRoots(roots []string) []string {
|
||||
if len(roots) == 0 {
|
||||
return nil
|
||||
}
|
||||
|
||||
managedDir := path.Join(config.DefaultDataMount, "skills")
|
||||
legacyDir := path.Join(config.DefaultDataMount, ".skills")
|
||||
seen := make(map[string]struct{}, len(roots))
|
||||
normalized := make([]string, 0, len(roots))
|
||||
for _, raw := range roots {
|
||||
root := path.Clean(strings.TrimSpace(raw))
|
||||
if root == "" || !strings.HasPrefix(root, "/") {
|
||||
continue
|
||||
}
|
||||
if root == managedDir || root == legacyDir {
|
||||
continue
|
||||
}
|
||||
if _, ok := seen[root]; ok {
|
||||
continue
|
||||
}
|
||||
seen[root] = struct{}{}
|
||||
normalized = append(normalized, root)
|
||||
}
|
||||
return normalized
|
||||
}
|
||||
|
||||
@@ -119,3 +119,74 @@ func TestWithoutWorkspaceGPUPreferenceRemovesOnlyGPUKey(t *testing.T) {
|
||||
t.Fatalf("expected unrelated workspace metadata to remain, got %#v", workspace)
|
||||
}
|
||||
}
|
||||
|
||||
func TestWorkspaceSkillDiscoveryRootsMetadataRoundTrip(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
metadata := map[string]any{
|
||||
workspaceMetadataKey: map[string]any{
|
||||
"keep": "value",
|
||||
},
|
||||
}
|
||||
|
||||
updated := withWorkspaceSkillDiscoveryRoots(metadata, []string{
|
||||
" /custom/skills ",
|
||||
"/root/.openclaw/skills",
|
||||
"/custom/skills",
|
||||
"/custom/./skills",
|
||||
"/data/skills",
|
||||
"relative/path",
|
||||
})
|
||||
|
||||
roots, ok := workspaceSkillDiscoveryRootsFromMetadata(updated)
|
||||
if !ok {
|
||||
t.Fatal("expected skill discovery roots preference to be present")
|
||||
}
|
||||
if got, want := roots, []string{"/custom/skills", "/root/.openclaw/skills"}; len(got) != len(want) || got[0] != want[0] || got[1] != want[1] {
|
||||
t.Fatalf("expected normalized skill discovery roots %v, got %v", want, got)
|
||||
}
|
||||
workspace, ok := updated[workspaceMetadataKey].(map[string]any)
|
||||
if !ok {
|
||||
t.Fatal("expected workspace metadata section")
|
||||
}
|
||||
if workspace["keep"] != "value" {
|
||||
t.Fatalf("expected existing workspace metadata to be preserved, got %#v", workspace)
|
||||
}
|
||||
}
|
||||
|
||||
func TestWorkspaceSkillDiscoveryRootsExplicitDisableRemainsPresent(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
metadata := withWorkspaceSkillDiscoveryRoots(map[string]any{}, []string{})
|
||||
|
||||
roots, ok := workspaceSkillDiscoveryRootsFromMetadata(metadata)
|
||||
if !ok {
|
||||
t.Fatal("expected skill discovery roots key to remain present")
|
||||
}
|
||||
if len(roots) != 0 {
|
||||
t.Fatalf("expected explicit disable with no roots, got %#v", roots)
|
||||
}
|
||||
}
|
||||
|
||||
func TestWithoutWorkspaceSkillDiscoveryRootsRemovesOnlyThatKey(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
metadata := map[string]any{
|
||||
workspaceMetadataKey: map[string]any{
|
||||
workspaceSkillDiscoveryRootsMetadataKey: []any{"/data/.agents/skills"},
|
||||
"keep": true,
|
||||
},
|
||||
}
|
||||
|
||||
updated := withoutWorkspaceSkillDiscoveryRoots(metadata)
|
||||
if _, ok := workspaceSkillDiscoveryRootsFromMetadata(updated); ok {
|
||||
t.Fatal("expected skill discovery roots preference to be cleared")
|
||||
}
|
||||
workspace, ok := updated[workspaceMetadataKey].(map[string]any)
|
||||
if !ok {
|
||||
t.Fatal("expected workspace metadata section to remain")
|
||||
}
|
||||
if workspace["keep"] != true {
|
||||
t.Fatalf("expected unrelated workspace metadata to remain, got %#v", workspace)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -209,7 +209,7 @@ func workspaceCDIDevicesFromLabels(labels map[string]string) []string {
|
||||
return normalizeWorkspaceGPUDevices(strings.Split(value, ","))
|
||||
}
|
||||
|
||||
func (m *Manager) buildWorkspaceContainerSpec(botID string, gpu WorkspaceGPUConfig) (ctr.ContainerSpec, error) {
|
||||
func (m *Manager) buildWorkspaceContainerSpec(ctx context.Context, botID string, gpu WorkspaceGPUConfig) (ctr.ContainerSpec, error) {
|
||||
resolvPath, err := ctr.ResolveConfSource(m.dataRoot())
|
||||
if err != nil {
|
||||
return ctr.ContainerSpec{}, err
|
||||
@@ -244,10 +244,15 @@ func (m *Manager) buildWorkspaceContainerSpec(botID string, gpu WorkspaceGPUConf
|
||||
tzMounts, tzEnv := ctr.TimezoneSpec()
|
||||
mounts = append(mounts, tzMounts...)
|
||||
|
||||
env := make([]string, 0, len(tzEnv)+1+len(skillset.ContainerEnv()))
|
||||
skillRoots, err := m.ResolveWorkspaceSkillDiscoveryRoots(ctx, botID)
|
||||
if err != nil {
|
||||
return ctr.ContainerSpec{}, err
|
||||
}
|
||||
skillEnv := skillset.ContainerEnv(skillRoots)
|
||||
env := make([]string, 0, len(tzEnv)+1+len(skillEnv))
|
||||
env = append(env, tzEnv...)
|
||||
env = append(env, "BRIDGE_SOCKET_PATH=/run/memoh/bridge.sock")
|
||||
env = append(env, skillset.ContainerEnv()...)
|
||||
env = append(env, skillEnv...)
|
||||
|
||||
return ctr.ContainerSpec{
|
||||
Cmd: []string{"/opt/memoh/bridge"},
|
||||
@@ -261,7 +266,7 @@ func (m *Manager) ensureBotWithImage(ctx context.Context, botID, image string, g
|
||||
if err := validateBotID(botID); err != nil {
|
||||
return err
|
||||
}
|
||||
spec, err := m.buildWorkspaceContainerSpec(botID, gpu)
|
||||
spec, err := m.buildWorkspaceContainerSpec(ctx, botID, gpu)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
@@ -233,7 +233,7 @@ func (m *Manager) ListBotSnapshotData(ctx context.Context, botID string) (*BotSn
|
||||
}
|
||||
|
||||
managedMeta := make(map[string]ManagedSnapshotMeta)
|
||||
if m.queries != nil {
|
||||
if m.db != nil && m.queries != nil {
|
||||
rows, err := m.queries.ListSnapshotsWithVersionByContainerID(ctx, containerID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
@@ -410,7 +410,7 @@ func (m *Manager) buildVersionSpec(ctx context.Context, botID string, cdiDevices
|
||||
}
|
||||
cdiDevices = gpu.Devices
|
||||
}
|
||||
return m.buildWorkspaceContainerSpec(botID, WorkspaceGPUConfig{Devices: cdiDevices})
|
||||
return m.buildWorkspaceContainerSpec(ctx, botID, WorkspaceGPUConfig{Devices: cdiDevices})
|
||||
}
|
||||
|
||||
func (m *Manager) safeStopTask(ctx context.Context, containerID string) error {
|
||||
|
||||
Reference in New Issue
Block a user