mirror of
https://github.com/memohai/Memoh.git
synced 2026-04-25 07:00:48 +09:00
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:
@@ -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",
|
||||
|
||||
@@ -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": "技能已保存",
|
||||
|
||||
@@ -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)
|
||||
|
||||
Reference in New Issue
Block a user