You can not select more than 25 topics
Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.
326 lines
9.1 KiB
326 lines
9.1 KiB
package server
|
|
|
|
import (
|
|
"encoding/json"
|
|
"fmt"
|
|
"os"
|
|
"path/filepath"
|
|
"sort"
|
|
"strconv"
|
|
"strings"
|
|
)
|
|
|
|
type skillhubIndex struct {
|
|
Skills []skillhubIndexEntry `json:"skills"`
|
|
}
|
|
|
|
type skillhubIndexEntry struct {
|
|
Name string `json:"name"`
|
|
Path string `json:"path"`
|
|
Category string `json:"category"`
|
|
Capability string `json:"capability"`
|
|
}
|
|
|
|
func loadSkills(skillhubDir string) ([]skill, error) {
|
|
skillhubDir = strings.TrimSpace(skillhubDir)
|
|
if skillhubDir == "" {
|
|
return seedSkills(), nil
|
|
}
|
|
|
|
indexPath := filepath.Join(skillhubDir, "index.json")
|
|
data, err := os.ReadFile(indexPath)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("read skillhub index: %w", err)
|
|
}
|
|
|
|
var idx skillhubIndex
|
|
if err := json.Unmarshal(data, &idx); err != nil {
|
|
return nil, fmt.Errorf("decode skillhub index: %w", err)
|
|
}
|
|
|
|
items := make([]skill, 0, len(idx.Skills))
|
|
for _, entry := range idx.Skills {
|
|
if !strings.HasPrefix(entry.Name, "popiskill-") {
|
|
continue
|
|
}
|
|
|
|
skillPath := strings.TrimSpace(entry.Path)
|
|
if skillPath == "" {
|
|
skillPath = filepath.Join("skills", entry.Name)
|
|
}
|
|
docPath := filepath.Join(skillhubDir, skillPath, "SKILL.md")
|
|
|
|
doc, err := parseSkillDoc(docPath)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("parse %s: %w", docPath, err)
|
|
}
|
|
|
|
category := defaultString(strings.TrimSpace(entry.Category), skillCategoryFromID(entry.Name))
|
|
capability := defaultString(strings.TrimSpace(entry.Capability), skillCapabilityFromID(entry.Name))
|
|
routeKey := normalizeRouteKey(category + "." + capability)
|
|
version := semverFromSkillID(entry.Name)
|
|
tags := tagsFromSkillID(entry.Name)
|
|
name := defaultString(strings.TrimSpace(doc.Title), displayNameFromSkillID(entry.Name))
|
|
description := strings.TrimSpace(doc.Description)
|
|
inputSchema := inputSchemaForRoute(routeKey)
|
|
if schema, ok, err := readSkillSchemaFile(filepath.Join(skillhubDir, skillPath, "input_schema.json")); err != nil {
|
|
return nil, fmt.Errorf("read input schema for %s: %w", entry.Name, err)
|
|
} else if ok {
|
|
inputSchema = schema
|
|
}
|
|
outputSchema := defaultOutputSchema()
|
|
if schema, ok, err := readSkillSchemaFile(filepath.Join(skillhubDir, skillPath, "output_schema.json")); err != nil {
|
|
return nil, fmt.Errorf("read output schema for %s: %w", entry.Name, err)
|
|
} else if ok {
|
|
outputSchema = schema
|
|
}
|
|
|
|
items = append(items, skill{
|
|
ID: defaultString(strings.TrimSpace(doc.Name), entry.Name),
|
|
Name: name,
|
|
Description: description,
|
|
Tags: tags,
|
|
Version: version,
|
|
ModelType: category,
|
|
RouteKey: routeKey,
|
|
EstimatedDurationS: estimateDuration(routeKey),
|
|
InputSchema: inputSchema,
|
|
OutputSchema: outputSchema,
|
|
})
|
|
}
|
|
|
|
if len(items) == 0 {
|
|
return seedSkills(), nil
|
|
}
|
|
return items, nil
|
|
}
|
|
|
|
func readSkillSchemaFile(path string) (map[string]any, bool, error) {
|
|
data, err := os.ReadFile(path)
|
|
if err != nil {
|
|
if os.IsNotExist(err) {
|
|
return nil, false, nil
|
|
}
|
|
return nil, false, err
|
|
}
|
|
var schema map[string]any
|
|
if err := json.Unmarshal(data, &schema); err != nil {
|
|
return nil, false, err
|
|
}
|
|
if len(schema) == 0 {
|
|
return nil, false, nil
|
|
}
|
|
return schema, true, nil
|
|
}
|
|
|
|
type skillDoc struct {
|
|
Name string
|
|
Description string
|
|
Title string
|
|
}
|
|
|
|
func parseSkillDoc(path string) (skillDoc, error) {
|
|
data, err := os.ReadFile(path)
|
|
if err != nil {
|
|
return skillDoc{}, err
|
|
}
|
|
text := string(data)
|
|
lines := strings.Split(text, "\n")
|
|
doc := skillDoc{}
|
|
|
|
start := 0
|
|
if len(lines) > 0 && strings.TrimSpace(lines[0]) == "---" {
|
|
end := -1
|
|
for i := 1; i < len(lines); i++ {
|
|
if strings.TrimSpace(lines[i]) == "---" {
|
|
end = i
|
|
break
|
|
}
|
|
key, value, ok := strings.Cut(lines[i], ":")
|
|
if !ok {
|
|
continue
|
|
}
|
|
switch strings.TrimSpace(key) {
|
|
case "name":
|
|
doc.Name = trimYAMLScalar(value)
|
|
case "description":
|
|
doc.Description = trimYAMLScalar(value)
|
|
}
|
|
}
|
|
if end >= 0 {
|
|
start = end + 1
|
|
}
|
|
}
|
|
|
|
for i := start; i < len(lines); i++ {
|
|
line := strings.TrimSpace(lines[i])
|
|
if strings.HasPrefix(line, "# ") {
|
|
doc.Title = strings.TrimSpace(strings.TrimPrefix(line, "# "))
|
|
break
|
|
}
|
|
}
|
|
|
|
if doc.Name == "" {
|
|
return skillDoc{}, fmt.Errorf("missing frontmatter name")
|
|
}
|
|
if doc.Description == "" {
|
|
return skillDoc{}, fmt.Errorf("missing frontmatter description")
|
|
}
|
|
return doc, nil
|
|
}
|
|
|
|
func trimYAMLScalar(value string) string {
|
|
value = strings.TrimSpace(value)
|
|
value = strings.Trim(value, `"'`)
|
|
return value
|
|
}
|
|
|
|
func skillCategoryFromID(skillID string) string {
|
|
parts := strings.Split(skillID, "-")
|
|
if len(parts) >= 3 {
|
|
return parts[1]
|
|
}
|
|
return "image"
|
|
}
|
|
|
|
func skillCapabilityFromID(skillID string) string {
|
|
parts := strings.Split(skillID, "-")
|
|
if len(parts) >= 4 {
|
|
return parts[2]
|
|
}
|
|
return "text2image"
|
|
}
|
|
|
|
func semverFromSkillID(skillID string) string {
|
|
parts := strings.Split(skillID, "-")
|
|
last := parts[len(parts)-1]
|
|
if isVersionToken(last) {
|
|
if n, err := strconv.Atoi(strings.TrimPrefix(last, "v")); err == nil && n > 0 {
|
|
return fmt.Sprintf("%d.0.0", n)
|
|
}
|
|
}
|
|
return "1.0.0"
|
|
}
|
|
|
|
func tagsFromSkillID(skillID string) []string {
|
|
parts := strings.Split(skillID, "-")
|
|
seen := map[string]bool{}
|
|
out := make([]string, 0, len(parts))
|
|
for _, part := range parts {
|
|
part = strings.TrimSpace(part)
|
|
if part == "" || part == "popiskill" || isVersionToken(part) {
|
|
continue
|
|
}
|
|
if !seen[part] {
|
|
seen[part] = true
|
|
out = append(out, part)
|
|
}
|
|
}
|
|
sort.Strings(out)
|
|
return out
|
|
}
|
|
|
|
func displayNameFromSkillID(skillID string) string {
|
|
parts := strings.Split(skillID, "-")
|
|
items := make([]string, 0, len(parts))
|
|
for _, part := range parts {
|
|
if part == "popiskill" || isVersionToken(part) || part == "" {
|
|
continue
|
|
}
|
|
items = append(items, strings.Title(part))
|
|
}
|
|
return strings.Join(items, " ")
|
|
}
|
|
|
|
func isVersionToken(value string) bool {
|
|
if !strings.HasPrefix(value, "v") || len(value) < 2 {
|
|
return false
|
|
}
|
|
_, err := strconv.Atoi(strings.TrimPrefix(value, "v"))
|
|
return err == nil
|
|
}
|
|
|
|
func estimateDuration(routeKey string) int {
|
|
switch normalizeRouteKey(routeKey) {
|
|
case "image.img2img":
|
|
return 45
|
|
case "video.image2video":
|
|
return 90
|
|
default:
|
|
return 30
|
|
}
|
|
}
|
|
|
|
func defaultOutputSchema() map[string]any {
|
|
return map[string]any{
|
|
"type": "object",
|
|
"properties": map[string]any{
|
|
"artifact_ids": map[string]any{"type": "array", "items": map[string]any{"type": "string"}},
|
|
"usage": map[string]any{"type": "object"},
|
|
},
|
|
}
|
|
}
|
|
|
|
func inputSchemaForRoute(routeKey string) map[string]any {
|
|
switch normalizeRouteKey(routeKey) {
|
|
case "image.img2img":
|
|
return map[string]any{
|
|
"type": "object",
|
|
"properties": map[string]any{
|
|
"prompt": map[string]any{"type": "string"},
|
|
"scene_prompt": map[string]any{"type": "string"},
|
|
"image_url": map[string]any{"type": "string"},
|
|
"reference_image_url": map[string]any{"type": "string"},
|
|
"source_artifact_id": map[string]any{"type": "string"},
|
|
"shot_type": map[string]any{"type": "string"},
|
|
"camera": map[string]any{"type": "string"},
|
|
"mood": map[string]any{"type": "string"},
|
|
"aspect_ratio": map[string]any{"type": "string"},
|
|
},
|
|
}
|
|
case "video.image2video":
|
|
return imageToVideoInputSchema()
|
|
default:
|
|
return map[string]any{
|
|
"type": "object",
|
|
"properties": map[string]any{
|
|
"prompt": map[string]any{"type": "string"},
|
|
"style": map[string]any{"type": "string"},
|
|
"aspect_ratio": map[string]any{"type": "string"},
|
|
"seed": map[string]any{"type": "integer"},
|
|
},
|
|
"required": []string{"prompt"},
|
|
}
|
|
}
|
|
}
|
|
|
|
func imageToVideoInputSchema() map[string]any {
|
|
return map[string]any{
|
|
"type": "object",
|
|
"properties": map[string]any{
|
|
"source_artifact_id": map[string]any{"type": "string"},
|
|
"image_url": map[string]any{"type": "string"},
|
|
"reference_image_url": map[string]any{"type": "string"},
|
|
"last_frame_image_url": map[string]any{"type": "string"},
|
|
"end_frame_image_url": map[string]any{"type": "string"},
|
|
"last_frame_artifact_id": map[string]any{"type": "string"},
|
|
"end_frame_artifact_id": map[string]any{"type": "string"},
|
|
"images": map[string]any{"type": "array", "items": map[string]any{"type": "string"}},
|
|
"metadata": map[string]any{"type": "object"},
|
|
"prompt": map[string]any{"type": "string"},
|
|
"motion_prompt": map[string]any{"type": "string"},
|
|
"negative_prompt": map[string]any{"type": "string"},
|
|
"duration_s": map[string]any{"type": "number"},
|
|
"seconds": map[string]any{"type": "number"},
|
|
"fps": map[string]any{"type": "number"},
|
|
"camera_motion": map[string]any{"type": "string"},
|
|
"motion_intensity": map[string]any{"type": "string"},
|
|
"style": map[string]any{"type": "string"},
|
|
"size": map[string]any{"type": "string"},
|
|
"mood": map[string]any{"type": "string"},
|
|
"aspect_ratio": map[string]any{"type": "string"},
|
|
"seed": map[string]any{"type": "number"},
|
|
"notes": map[string]any{"type": "string"},
|
|
},
|
|
}
|
|
}
|
|
|