feat(skills): add effective skill resolution and actions (#377)

* feat(skills): add effective skill resolution and actions

* refactor(workspace): normalize skill-related env and prompt

* chore(api): regenerate skills OpenAPI and SDK artifacts

* feat(web): surface effective skill state in console

* test(skills): cover API and runtime effective state

* fix(web): show adopt action for discovered skills

* fix(web): align skill header and show stateful visibility icon

* refactor(web): compact skill metadata on narrow layouts

* fix(web): constrain long skill text in cards

* refactor(skills): narrow default discovery roots

* fix(skills): harden managed skill path validation

* feat: add path in the results of `use_skill`

---------

Co-authored-by: Acbox <acbox0328@gmail.com>
This commit is contained in:
Chrys
2026-04-16 13:50:39 +08:00
committed by GitHub
parent 8e1ed3683f
commit 33e18e7e64
27 changed files with 2223 additions and 209 deletions
+19
View File
@@ -1123,11 +1123,30 @@
"addSkill": "New Skill",
"emptyTitle": "No Skills",
"emptyDescription": "Click above to create a new skill",
"managedBadge": "Managed",
"discoveredBadge": "Discovered",
"effectiveBadge": "Effective",
"shadowedBadge": "Shadowed",
"disabledBadge": "Disabled",
"legacyBadge": "Legacy",
"compatBadge": "Compatible",
"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",
"disableAction": "Disable this skill source",
"enableAction": "Enable this skill source",
"adoptSuccess": "Skill adopted",
"adoptFailed": "Failed to adopt skill",
"disableSuccess": "Skill disabled",
"disableFailed": "Failed to disable skill",
"enableSuccess": "Skill enabled",
"enableFailed": "Failed to enable skill",
"shadowedBy": "Shadowed by:",
"deleteSuccess": "Skill deleted",
"deleteFailed": "Failed to delete skill",
"saveSuccess": "Skill saved",
+19
View File
@@ -1119,11 +1119,30 @@
"addSkill": "新建技能",
"emptyTitle": "暂无技能",
"emptyDescription": "点击上方按钮创建新技能",
"managedBadge": "托管",
"discoveredBadge": "发现",
"effectiveBadge": "生效中",
"shadowedBadge": "被覆盖",
"disabledBadge": "已禁用",
"legacyBadge": "旧版",
"compatBadge": "兼容",
"description": "描述",
"descriptionPlaceholder": "输入技能描述",
"content": "内容",
"contentPlaceholder": "输入技能内容/提示词",
"deleteConfirm": "确定要删除这个技能吗?",
"overrideTitle": "编辑后将创建托管覆盖版本",
"adoptAction": "纳入 Memoh 托管",
"adoptBlocked": "已有更高优先级的技能副本",
"disableAction": "禁用这个技能来源",
"enableAction": "启用这个技能来源",
"adoptSuccess": "技能已纳入托管",
"adoptFailed": "纳入托管失败",
"disableSuccess": "技能已禁用",
"disableFailed": "禁用技能失败",
"enableSuccess": "技能已启用",
"enableFailed": "启用技能失败",
"shadowedBy": "被以下来源覆盖:",
"deleteSuccess": "技能已删除",
"deleteFailed": "删除技能失败",
"saveSuccess": "技能已保存",
+187 -10
View File
@@ -52,13 +52,13 @@
>
<Card
v-for="skill in skills"
:key="skill.name"
class="flex flex-col"
:key="skillKey(skill)"
class="flex min-w-0 flex-col overflow-hidden"
>
<CardHeader class="pb-3">
<div class="flex items-start justify-between gap-2">
<CardHeader class="min-w-0 pb-3">
<div class="flex min-w-0 items-center justify-between gap-2">
<CardTitle
class="text-sm truncate"
class="min-w-0 flex-1 truncate text-sm"
:title="skill.name"
>
{{ skill.name }}
@@ -68,14 +68,69 @@
variant="ghost"
size="sm"
class="size-8 p-0"
:title="$t('common.edit')"
:title="!skill.managed ? $t('bots.skills.overrideTitle') : $t('common.edit')"
@click="handleEdit(skill)"
>
<SquarePen
class="size-3.5"
/>
</Button>
<Button
v-if="skill.state === 'disabled'"
variant="ghost"
size="sm"
class="size-8 p-0"
:disabled="isActioning"
:title="$t('bots.skills.enableAction')"
@click="handleSkillAction('enable', skill)"
>
<Spinner
v-if="isSkillActionPending(skill, 'enable')"
class="size-3.5"
/>
<EyeOff
v-else
class="size-3.5"
/>
</Button>
<Button
v-else
variant="ghost"
size="sm"
class="size-8 p-0"
:disabled="isActioning"
:title="$t('bots.skills.disableAction')"
@click="handleSkillAction('disable', skill)"
>
<Spinner
v-if="isSkillActionPending(skill, 'disable')"
class="size-3.5"
/>
<Eye
v-else
class="size-3.5"
/>
</Button>
<Button
v-if="!skill.managed"
variant="ghost"
size="sm"
class="size-8 p-0"
:disabled="isActioning || skill.state === 'shadowed'"
:title="skill.state === 'shadowed' ? $t('bots.skills.adoptBlocked') : $t('bots.skills.adoptAction')"
@click="handleSkillAction('adopt', skill)"
>
<Spinner
v-if="isSkillActionPending(skill, 'adopt')"
class="size-3.5"
/>
<ArrowDownToLine
v-else
class="size-3.5"
/>
</Button>
<ConfirmPopover
v-if="skill.managed"
:message="$t('bots.skills.deleteConfirm')"
:loading="isDeleting && deletingName === skill.name"
@confirm="handleDelete(skill.name)"
@@ -97,12 +152,44 @@
</div>
</div>
<CardDescription
class="line-clamp-2"
class="min-w-0 overflow-hidden line-clamp-2 break-words [overflow-wrap:anywhere]"
:title="skill.description"
>
{{ skill.description || '-' }}
</CardDescription>
</CardHeader>
<CardContent class="mt-auto min-w-0 space-y-1.5 pt-0">
<div class="flex flex-wrap items-center gap-1.5">
<Badge
variant="secondary"
size="sm"
class="rounded-full"
>
{{ skill.managed ? $t('bots.skills.managedBadge') : $t('bots.skills.discoveredBadge') }}
</Badge>
<Badge
variant="outline"
size="sm"
class="rounded-full"
>
{{ stateLabel(skill.state) }}
</Badge>
</div>
<p
v-if="skill.shadowed_by"
class="text-[11px] text-muted-foreground truncate"
:title="skill.shadowed_by"
>
{{ $t('bots.skills.shadowedBy') }} {{ skill.shadowed_by }}
</p>
<p
v-if="skill.source_path"
class="text-[11px] text-muted-foreground truncate"
:title="sourceSummary(skill)"
>
{{ sourceSummary(skill) }}
</p>
</CardContent>
</Card>
</div>
@@ -147,12 +234,12 @@
</template>
<script setup lang="ts">
import { Plus, Zap, SquarePen, Trash2 } from 'lucide-vue-next'
import { ArrowDownToLine, Eye, EyeOff, Plus, Zap, SquarePen, Trash2 } from 'lucide-vue-next'
import { ref, computed, onMounted } from 'vue'
import { useI18n } from 'vue-i18n'
import { toast } from 'vue-sonner'
import {
Button, Card, CardHeader, CardTitle, CardDescription,
Badge, Button, Card, CardContent, CardHeader, CardTitle, CardDescription,
Dialog, DialogContent, DialogHeader, DialogTitle, DialogFooter, DialogClose,
Spinner,
} from '@memohai/ui'
@@ -161,11 +248,21 @@ import MonacoEditor from '@/components/monaco-editor/index.vue'
import {
getBotsByBotIdContainerSkills,
postBotsByBotIdContainerSkills,
postBotsByBotIdContainerSkillsActions,
deleteBotsByBotIdContainerSkills,
type HandlersSkillItem,
} from '@memohai/sdk'
import { resolveApiErrorMessage } from '@/utils/api-error'
type SkillItem = HandlersSkillItem & {
source_path?: string
source_root?: string
source_kind?: string
managed?: boolean
state?: string
shadowed_by?: string
}
const props = defineProps<{
botId: string
}>()
@@ -176,7 +273,10 @@ const isLoading = ref(false)
const isSaving = ref(false)
const isDeleting = ref(false)
const deletingName = ref('')
const skills = ref<HandlersSkillItem[]>([])
const isActioning = ref(false)
const actionTargetPath = ref('')
const actionName = ref('')
const skills = ref<SkillItem[]>([])
const isDialogOpen = ref(false)
const isEditing = ref(false)
@@ -222,6 +322,83 @@ function handleEdit(skill: HandlersSkillItem) {
isDialogOpen.value = true
}
function skillKey(skill: SkillItem) {
return skill.source_path || `${skill.name || 'unknown'}:${skill.source_kind || 'unknown'}`
}
function isSkillActionPending(skill: SkillItem, action: string) {
return isActioning.value && actionTargetPath.value === skill.source_path && actionName.value === action
}
function sourceKindLabel(kind?: string) {
switch (kind) {
case 'legacy':
return t('bots.skills.legacyBadge')
case 'compat':
return t('bots.skills.compatBadge')
default:
return t('bots.skills.managedBadge')
}
}
function sourceSummary(skill: SkillItem) {
const sourcePath = skill.source_path || ''
if (!sourcePath) return ''
if (!skill.source_kind || skill.source_kind === 'managed') {
return sourcePath
}
return `${sourceKindLabel(skill.source_kind)} · ${sourcePath}`
}
function stateLabel(state?: string) {
switch (state) {
case 'disabled':
return t('bots.skills.disabledBadge')
case 'shadowed':
return t('bots.skills.shadowedBadge')
default:
return t('bots.skills.effectiveBadge')
}
}
async function handleSkillAction(action: 'adopt' | 'disable' | 'enable', skill: SkillItem) {
if (!skill.source_path) return
isActioning.value = true
actionTargetPath.value = skill.source_path
actionName.value = action
try {
await postBotsByBotIdContainerSkillsActions({
path: { bot_id: props.botId },
body: {
action,
target_path: skill.source_path,
},
throwOnError: true,
})
toast.success(
action === 'adopt'
? t('bots.skills.adoptSuccess')
: action === 'disable'
? t('bots.skills.disableSuccess')
: t('bots.skills.enableSuccess'),
)
await fetchSkills()
} catch (error) {
toast.error(resolveApiErrorMessage(
error,
action === 'adopt'
? t('bots.skills.adoptFailed')
: action === 'disable'
? t('bots.skills.disableFailed')
: t('bots.skills.enableFailed'),
))
} finally {
isActioning.value = false
actionTargetPath.value = ''
actionName.value = ''
}
}
async function handleSave() {
if (!canSave.value) return
isSaving.value = true
@@ -126,8 +126,8 @@ import { useQuery, useQueryCache } from '@pinia/colada'
import { toast } from 'vue-sonner'
import { Sparkles, ExternalLink, Loader2, Minimize2 } from 'lucide-vue-next'
import { ScrollArea } from '@memohai/ui'
import { getBotsByBotIdSessionsBySessionIdStatus, postBotsByBotIdSessionsBySessionIdCompact } from '@memohai/sdk'
import type { HandlersSessionInfoResponse } from '@memohai/sdk'
import { getBotsByBotIdContainerSkills, getBotsByBotIdSessionsBySessionIdStatus, postBotsByBotIdSessionsBySessionIdCompact } from '@memohai/sdk'
import type { HandlersSessionInfoResponse, HandlersSkillItem } from '@memohai/sdk'
import { resolveApiErrorMessage } from '@/utils/api-error'
import { useChatStore } from '@/store/chat-list'
import { openInFileManagerKey } from '../composables/useFileManagerProvider'
@@ -143,6 +143,11 @@ const { currentBotId, sessionId } = storeToRefs(chatStore)
const openInFileManager = inject(openInFileManagerKey, undefined)
const queryCache = useQueryCache()
type SkillItem = HandlersSkillItem & {
source_path?: string
state?: string
}
const { data: info } = useQuery({
key: () => ['session-status', currentBotId.value ?? '', sessionId.value ?? '', props.overrideModelId ?? ''],
query: async () => {
@@ -162,6 +167,21 @@ const { data: info } = useQuery({
refetchOnWindowFocus: false,
})
const { data: skillCatalog } = useQuery({
key: () => ['bot-skill-catalog', currentBotId.value ?? ''],
query: async () => {
const { data } = await getBotsByBotIdContainerSkills({
path: {
bot_id: currentBotId.value!,
},
throwOnError: true,
})
return (data.skills || []) as SkillItem[]
},
enabled: () => !!currentBotId.value && props.visible,
refetchOnWindowFocus: false,
})
const usedTokens = computed(() => info.value?.context_usage?.used_tokens ?? 0)
const contextWindow = computed(() => info.value?.context_usage?.context_window ?? null)
const contextPercent = computed(() => {
@@ -180,6 +200,14 @@ const cacheHitRate = computed(() => {
})
const skills = computed(() => info.value?.skills ?? [])
const effectiveSkillPathByName = computed<Record<string, string>>(() => {
const out: Record<string, string> = {}
for (const item of skillCatalog.value || []) {
if (item.state !== 'effective' || !item.name || !item.source_path) continue
out[item.name] = item.source_path
}
return out
})
function formatTokenCount(n: number): string {
if (n >= 1_000_000) return `${(n / 1_000_000).toFixed(1)}M`
@@ -188,7 +216,7 @@ function formatTokenCount(n: number): string {
}
function openSkillFile(skillName: string) {
openInFileManager?.(`/data/skills/${skillName}/SKILL.md`, false)
openInFileManager?.(effectiveSkillPathByName.value[skillName] || `/data/skills/${skillName}/SKILL.md`, false)
}
const isCompacting = ref(false)