refactor(skills): import skill with pure markdown string

This commit is contained in:
Acbox
2026-02-28 23:07:44 +08:00
parent 21029f44c7
commit 443ede30b4
7 changed files with 80 additions and 84 deletions
+9 -18
View File
@@ -17,6 +17,7 @@ type SkillItem struct {
Description string `json:"description"`
Content string `json:"content"`
Metadata map[string]any `json:"metadata,omitempty"`
Raw string `json:"raw"`
}
type SkillsResponse struct {
@@ -24,7 +25,7 @@ type SkillsResponse struct {
}
type SkillsUpsertRequest struct {
Skills []SkillItem `json:"skills"`
Skills []string `json:"skills"`
}
type SkillsDeleteRequest struct {
@@ -74,6 +75,7 @@ func (h *ContainerdHandler) ListSkills(c echo.Context) error {
Description: parsed.Description,
Content: parsed.Content,
Metadata: parsed.Metadata,
Raw: raw,
})
}
@@ -107,21 +109,17 @@ func (h *ContainerdHandler) UpsertSkills(c echo.Context) error {
if err != nil {
return echo.NewHTTPError(http.StatusInternalServerError, err.Error())
}
for _, skill := range req.Skills {
name := strings.TrimSpace(skill.Name)
if !isValidSkillName(name) {
return echo.NewHTTPError(http.StatusBadRequest, "invalid skill name")
for _, raw := range req.Skills {
parsed := parseSkillFile(raw, "")
if !isValidSkillName(parsed.Name) {
return echo.NewHTTPError(http.StatusBadRequest, "skill must have a valid name in YAML frontmatter")
}
content := strings.TrimSpace(skill.Content)
if content == "" {
content = buildSkillContent(name, strings.TrimSpace(skill.Description))
}
dirPath := filepath.Join(skillsDir, name)
dirPath := filepath.Join(skillsDir, parsed.Name)
if err := os.MkdirAll(dirPath, 0o755); err != nil {
return echo.NewHTTPError(http.StatusInternalServerError, err.Error())
}
filePath := filepath.Join(dirPath, "SKILL.md")
if err := os.WriteFile(filePath, []byte(content), 0o644); err != nil {
if err := os.WriteFile(filePath, []byte(raw), 0o644); err != nil {
return echo.NewHTTPError(http.StatusInternalServerError, err.Error())
}
}
@@ -374,13 +372,6 @@ func normalizeParsedSkill(skill parsedSkill) parsedSkill {
return skill
}
func buildSkillContent(name, description string) string {
if description == "" {
description = name
}
return "---\nname: " + name + "\ndescription: " + description + "\n---\n\n# " + name + "\n\n" + description
}
func isValidSkillName(name string) bool {
if name == "" {
return false
+2 -1
View File
@@ -613,6 +613,7 @@ export type HandlersSkillItem = {
[key: string]: unknown;
};
name?: string;
raw?: string;
};
export type HandlersSkillsDeleteRequest = {
@@ -624,7 +625,7 @@ export type HandlersSkillsResponse = {
};
export type HandlersSkillsUpsertRequest = {
skills?: Array<HandlersSkillItem>;
skills?: Array<string>;
};
export type HandlersSnapshotInfo = {
@@ -1,8 +1,10 @@
<script setup lang="ts">
import { ref, watch } from 'vue'
import { ref, watch, onMounted } from 'vue'
import { useI18n } from 'vue-i18n'
import { useRoute, useRouter } from 'vue-router'
import { toast } from 'vue-sonner'
import { FontAwesomeIcon } from '@fortawesome/vue-fontawesome'
import { useSyncedQueryParam } from '@/composables/useSyncedQueryParam'
import {
Button,
Input,
@@ -32,11 +34,30 @@ const props = defineProps<{
const { t } = useI18n()
const currentPath = ref('/data')
const route = useRoute()
const router = useRouter()
const currentPath = useSyncedQueryParam('path', '/data')
const entries = ref<HandlersFsFileInfo[]>([])
const listLoading = ref(false)
const openFile = ref<HandlersFsFileInfo | null>(null)
function syncFileToUrl(filePath: string | null) {
const current = route.query.file as string | undefined
const next = filePath || undefined
if (current !== next) {
void router.push({ query: { ...route.query, file: next } })
}
}
function restoreFileFromUrl() {
const filePath = route.query.file as string | undefined
if (filePath && !openFile.value) {
const name = filePath.split('/').pop() ?? ''
openFile.value = { path: filePath, name, isDir: false }
}
}
const mkdirDialogOpen = ref(false)
const mkdirName = ref('')
const mkdirLoading = ref(false)
@@ -71,15 +92,18 @@ async function loadDirectory(path: string) {
function handleNavigate(path: string) {
openFile.value = null
syncFileToUrl(null)
void loadDirectory(path)
}
function handleOpenFile(entry: HandlersFsFileInfo) {
openFile.value = entry
syncFileToUrl(entry.path ?? null)
}
function handleCloseViewer() {
openFile.value = null
syncFileToUrl(null)
}
function handleFileSaved() {
@@ -164,6 +188,7 @@ async function handleRename() {
renameDialogOpen.value = false
if (openFile.value?.path === target.path) {
openFile.value = null
syncFileToUrl(null)
}
toast.success(t('bots.files.renameSuccess'))
void loadDirectory(currentPath.value)
@@ -194,6 +219,7 @@ async function handleDelete() {
deleteDialogOpen.value = false
if (openFile.value?.path === target.path) {
openFile.value = null
syncFileToUrl(null)
}
toast.success(t('bots.files.deleteSuccess'))
void loadDirectory(currentPath.value)
@@ -215,9 +241,13 @@ function handleDownload(entry: HandlersFsFileInfo) {
watch(() => props.botId, () => {
openFile.value = null
currentPath.value = '/data'
void loadDirectory('/data')
syncFileToUrl(null)
void loadDirectory(currentPath.value)
}, { immediate: true })
onMounted(() => {
restoreFileFromUrl()
})
</script>
<template>
@@ -92,7 +92,7 @@
:title="$t('common.delete')"
>
<FontAwesomeIcon
:icon="['fas', 'trash']"
:icon="['far', 'trash-can']"
class="size-3.5"
/>
</Button>
@@ -107,46 +107,21 @@
{{ skill.description || '-' }}
</CardDescription>
</CardHeader>
<CardContent class="pb-4 grow">
<div class="rounded-md bg-muted p-2 text-xs font-mono text-muted-foreground line-clamp-4 break-all">
{{ skill.content }}
</div>
</CardContent>
</Card>
</div>
<!-- Edit Dialog -->
<Dialog v-model:open="isDialogOpen">
<DialogContent class="sm:max-w-xl">
<DialogContent class="sm:max-w-2xl">
<DialogHeader>
<DialogTitle>{{ isEditing ? $t('common.edit') : $t('bots.skills.addSkill') }}</DialogTitle>
</DialogHeader>
<div class="space-y-4 py-4">
<div class="space-y-2">
<Label>{{ $t('common.name') }}</Label>
<Input
v-model="draftSkill.name"
:placeholder="$t('common.namePlaceholder')"
:disabled="isEditing || isSaving"
/>
</div>
<div class="space-y-2">
<Label>{{ $t('bots.skills.description') }}</Label>
<Input
v-model="draftSkill.description"
:placeholder="$t('bots.skills.descriptionPlaceholder')"
:disabled="isSaving"
/>
</div>
<div class="space-y-2">
<Label>{{ $t('bots.skills.content') }}</Label>
<Textarea
v-model="draftSkill.content"
:placeholder="$t('bots.skills.contentPlaceholder')"
:disabled="isSaving"
class="min-h-[150px] font-mono text-sm"
/>
</div>
<div class="py-4 h-[400px]">
<MonacoEditor
v-model="draftRaw"
language="markdown"
:readonly="isSaving"
/>
</div>
<DialogFooter>
<DialogClose as-child>
@@ -178,11 +153,12 @@ import { ref, computed, onMounted } from 'vue'
import { useI18n } from 'vue-i18n'
import { toast } from 'vue-sonner'
import {
Button, Card, CardHeader, CardTitle, CardDescription, CardContent,
Button, Card, CardHeader, CardTitle, CardDescription,
Dialog, DialogContent, DialogHeader, DialogTitle, DialogFooter, DialogClose,
Input, Textarea, Label, Spinner,
Spinner,
} from '@memoh/ui'
import ConfirmPopover from '@/components/confirm-popover/index.vue'
import MonacoEditor from '@/components/monaco-editor/index.vue'
import {
getBotsByBotIdContainerSkills,
postBotsByBotIdContainerSkills,
@@ -205,14 +181,18 @@ const skills = ref<HandlersSkillItem[]>([])
const isDialogOpen = ref(false)
const isEditing = ref(false)
const draftSkill = ref<HandlersSkillItem>({
name: '',
description: '',
content: '',
})
const draftRaw = ref('')
const SKILL_TEMPLATE = `---
name: my-skill
description: Brief description
---
# My Skill
`
const canSave = computed(() => {
return (draftSkill.value.name || '').trim() && (draftSkill.value.content || '').trim()
return draftRaw.value.trim().length > 0
})
async function fetchSkills() {
@@ -233,22 +213,13 @@ async function fetchSkills() {
function handleCreate() {
isEditing.value = false
draftSkill.value = {
name: '',
description: '',
content: '',
}
draftRaw.value = SKILL_TEMPLATE
isDialogOpen.value = true
}
function handleEdit(skill: HandlersSkillItem) {
isEditing.value = true
draftSkill.value = {
name: skill.name || '',
description: skill.description || '',
content: skill.content || '',
metadata: skill.metadata,
}
draftRaw.value = skill.raw || ''
isDialogOpen.value = true
}
@@ -259,12 +230,7 @@ async function handleSave() {
await postBotsByBotIdContainerSkills({
path: { bot_id: props.botId },
body: {
skills: [{
name: draftSkill.value.name?.trim(),
description: draftSkill.value.description?.trim(),
content: draftSkill.value.content?.trim(),
metadata: draftSkill.value.metadata,
}],
skills: [draftRaw.value],
},
throwOnError: true,
})
+4 -1
View File
@@ -8042,6 +8042,9 @@ const docTemplate = `{
},
"name": {
"type": "string"
},
"raw": {
"type": "string"
}
}
},
@@ -8073,7 +8076,7 @@ const docTemplate = `{
"skills": {
"type": "array",
"items": {
"$ref": "#/definitions/handlers.SkillItem"
"type": "string"
}
}
}
+4 -1
View File
@@ -8033,6 +8033,9 @@
},
"name": {
"type": "string"
},
"raw": {
"type": "string"
}
}
},
@@ -8064,7 +8067,7 @@
"skills": {
"type": "array",
"items": {
"$ref": "#/definitions/handlers.SkillItem"
"type": "string"
}
}
}
+3 -1
View File
@@ -1005,6 +1005,8 @@ definitions:
type: object
name:
type: string
raw:
type: string
type: object
handlers.SkillsDeleteRequest:
properties:
@@ -1024,7 +1026,7 @@ definitions:
properties:
skills:
items:
$ref: '#/definitions/handlers.SkillItem'
type: string
type: array
type: object
handlers.SnapshotInfo: