popiart-server
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

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"},
},
}
}