init:初始化项目
This commit is contained in:
@@ -0,0 +1,277 @@
|
||||
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)
|
||||
|
||||
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: inputSchemaForRoute(routeKey),
|
||||
OutputSchema: defaultOutputSchema(),
|
||||
})
|
||||
}
|
||||
|
||||
if len(items) == 0 {
|
||||
return seedSkills(), nil
|
||||
}
|
||||
return items, 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 map[string]any{
|
||||
"type": "object",
|
||||
"properties": map[string]any{
|
||||
"source_artifact_id": map[string]any{"type": "string"},
|
||||
"image_url": map[string]any{"type": "string"},
|
||||
"prompt": map[string]any{"type": "string"},
|
||||
"motion_prompt": map[string]any{"type": "string"},
|
||||
"duration_s": map[string]any{"type": "integer"},
|
||||
"camera_motion": map[string]any{"type": "string"},
|
||||
"mood": map[string]any{"type": "string"},
|
||||
"aspect_ratio": map[string]any{"type": "string"},
|
||||
},
|
||||
}
|
||||
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"},
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user