feat(web): surface effective skill state in console

This commit is contained in:
ChrAlpha
2026-04-13 13:48:02 +00:00
parent 3701f44b39
commit 2345d9a9e6
4 changed files with 247 additions and 8 deletions
+19
View File
@@ -1075,11 +1075,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
@@ -1071,11 +1071,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": "技能已保存",
@@ -52,7 +52,7 @@
>
<Card
v-for="skill in skills"
:key="skill.name"
:key="skillKey(skill)"
class="flex flex-col"
>
<CardHeader class="pb-3">
@@ -68,14 +68,69 @@
variant="ghost"
size="sm"
class="size-8 p-0"
:title="$t('common.edit')"
:title="skill.managed === false ? $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"
/>
<Eye
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"
/>
<EyeOff
v-else
class="size-3.5"
/>
</Button>
<Button
v-if="skill.managed === false"
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)"
@@ -103,6 +158,43 @@
{{ skill.description || '-' }}
</CardDescription>
</CardHeader>
<CardContent class="pt-0 mt-auto space-y-2">
<div class="flex flex-wrap items-center gap-1.5">
<Badge
variant="secondary"
class="text-[10px]"
>
{{ skill.managed ? $t('bots.skills.managedBadge') : $t('bots.skills.discoveredBadge') }}
</Badge>
<Badge
variant="outline"
class="text-[10px]"
>
{{ stateLabel(skill.state) }}
</Badge>
<Badge
v-if="skill.source_kind && skill.source_kind !== 'managed'"
variant="outline"
class="text-[10px]"
>
{{ sourceKindLabel(skill.source_kind) }}
</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="skill.source_path"
>
{{ skill.source_path }}
</p>
</CardContent>
</Card>
</div>
@@ -147,12 +239,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 +253,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 +278,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 +327,74 @@ 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 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
@@ -104,8 +104,8 @@ import { storeToRefs } from 'pinia'
import { useQuery } from '@pinia/colada'
import { Sparkles, ExternalLink } from 'lucide-vue-next'
import { ScrollArea } from '@memohai/ui'
import { getBotsByBotIdSessionsBySessionIdStatus } from '@memohai/sdk'
import type { HandlersSessionInfoResponse } from '@memohai/sdk'
import { getBotsByBotIdContainerSkills, getBotsByBotIdSessionsBySessionIdStatus } from '@memohai/sdk'
import type { HandlersSessionInfoResponse, HandlersSkillItem } from '@memohai/sdk'
import { useChatStore } from '@/store/chat-list'
import { openInFileManagerKey } from '../composables/useFileManagerProvider'
@@ -118,6 +118,11 @@ const chatStore = useChatStore()
const { currentBotId, sessionId } = storeToRefs(chatStore)
const openInFileManager = inject(openInFileManagerKey, undefined)
type SkillItem = HandlersSkillItem & {
source_path?: string
state?: string
}
const { data: info } = useQuery({
key: () => ['session-status', currentBotId.value ?? '', sessionId.value ?? '', props.overrideModelId ?? ''],
query: async () => {
@@ -137,6 +142,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(() => {
@@ -155,6 +175,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`
@@ -163,6 +191,6 @@ 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)
}
</script>