Files
cc-connect/core/skill_presets.go
2026-06-02 23:14:41 +08:00

142 lines
4.4 KiB
Go

package core
import (
"encoding/json"
"fmt"
"io"
"log/slog"
"net/http"
"sync"
"time"
)
const (
defaultSkillPresetsURL = "https://raw.githubusercontent.com/chenhg5/cc-connect/main/skill-presets.json"
fallbackSkillPresetsURL = "https://gitee.com/chenhg5/cc-connect/raw/main/skill-presets.json"
skillPresetsCacheTTL = 6 * time.Hour
skillPresetsHTTPTimeout = 15 * time.Second
skillPresetsFallbackHTTPTimeout = 10 * time.Second
)
// SkillPreset describes a recommended skill available from the remote presets list.
type SkillPreset struct {
Name string `json:"name"`
DisplayName string `json:"display_name"`
Description string `json:"description,omitempty"`
DescriptionZh string `json:"description_zh,omitempty"`
Version string `json:"version,omitempty"`
Author string `json:"author,omitempty"`
URL string `json:"url,omitempty"`
AgentTypes []string `json:"agent_types,omitempty"`
Tags []string `json:"tags,omitempty"`
Featured bool `json:"featured,omitempty"`
Source *SkillSource `json:"source,omitempty"`
Pricing *SkillPricing `json:"pricing,omitempty"`
}
// SkillSource describes where the skill is hosted / provided from.
type SkillSource struct {
Provider string `json:"provider"` // e.g. "github", "skills.sh", "npm"
Name string `json:"name,omitempty"` // display name, e.g. "GitHub", "Skills.sh"
URL string `json:"url,omitempty"` // provider home page
}
// SkillPricing describes the pricing model for a skill.
type SkillPricing struct {
Type string `json:"type"` // "free", "paid", "freemium"
Price float64 `json:"price,omitempty"` // 0 for free
Currency string `json:"currency,omitempty"` // "USD", "CNY", etc.
}
// SkillPresetsResponse is the top-level JSON schema for remote skill presets.
type SkillPresetsResponse struct {
Version int `json:"version"`
UpdatedAt string `json:"updated_at,omitempty"`
Skills []SkillPreset `json:"skills"`
}
type skillPresetsCache struct {
mu sync.RWMutex
data *SkillPresetsResponse
fetchedAt time.Time
url string
}
var globalSkillPresetsCache = &skillPresetsCache{}
// SetSkillPresetsURL overrides the default skill presets URL.
func SetSkillPresetsURL(url string) {
globalSkillPresetsCache.mu.Lock()
defer globalSkillPresetsCache.mu.Unlock()
globalSkillPresetsCache.url = url
globalSkillPresetsCache.data = nil
}
// FetchSkillPresets returns cached or freshly-fetched skill presets.
func FetchSkillPresets() (*SkillPresetsResponse, error) {
return globalSkillPresetsCache.fetch()
}
func (c *skillPresetsCache) fetch() (*SkillPresetsResponse, error) {
c.mu.RLock()
if c.data != nil && time.Since(c.fetchedAt) < skillPresetsCacheTTL {
defer c.mu.RUnlock()
return c.data, nil
}
c.mu.RUnlock()
c.mu.Lock()
defer c.mu.Unlock()
if c.data != nil && time.Since(c.fetchedAt) < skillPresetsCacheTTL {
return c.data, nil
}
primaryURL := c.url
if primaryURL == "" {
primaryURL = defaultSkillPresetsURL
}
result, err := fetchSkillPresetsFromURL(primaryURL, skillPresetsHTTPTimeout)
if err != nil {
slog.Warn("primary skill presets fetch failed, trying fallback", "url", primaryURL, "error", err)
result, err = fetchSkillPresetsFromURL(fallbackSkillPresetsURL, skillPresetsFallbackHTTPTimeout)
}
if err != nil {
if c.data != nil {
slog.Warn("all skill presets sources failed, using stale cache", "error", err)
return c.data, nil
}
return nil, fmt.Errorf("fetch skill presets: %w", err)
}
c.data = result
c.fetchedAt = time.Now()
return c.data, nil
}
func fetchSkillPresetsFromURL(url string, timeout time.Duration) (*SkillPresetsResponse, error) {
slog.Debug("fetching skill presets", "url", url)
client := &http.Client{Timeout: timeout}
resp, err := client.Get(url)
if err != nil {
return nil, fmt.Errorf("HTTP GET %s: %w", url, err)
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
return nil, fmt.Errorf("HTTP GET %s: status %d", url, resp.StatusCode)
}
body, err := io.ReadAll(io.LimitReader(resp.Body, 1<<20))
if err != nil {
return nil, fmt.Errorf("read body from %s: %w", url, err)
}
var result SkillPresetsResponse
if err := json.Unmarshal(body, &result); err != nil {
return nil, fmt.Errorf("parse JSON from %s: %w", url, err)
}
return &result, nil
}