mirror of
https://github.com/memohai/Memoh.git
synced 2026-04-25 07:00:48 +09:00
refactor(skills): import skill with pure markdown string
This commit is contained in:
@@ -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
|
||||
|
||||
@@ -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
@@ -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
@@ -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
@@ -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:
|
||||
|
||||
Reference in New Issue
Block a user