feat(templates): source templates for multi-key balancing

A template stores every source field except name and api_key, so
operators spin up N key-bearing sources from one shared skeleton
instead of duplicating the whole source block N times.

- config: SourceTemplate type + RuntimeConfig.SourceTemplates stored
  in runtime.json alongside runtime sources
- store: UpsertTemplate / ListTemplates / RemoveTemplate
- core: Templates / SaveTemplate / RemoveTemplate
- gateway: GET/POST/DELETE /api/source_templates
- webui: source list gains a Templates button opening a manager with
  per-template edit/delete; the add-source dialog gains 'from
  template' (event-delegated picker) and 'as template' (card modal)
  buttons in its header; z-index fixed so the template editor layers
  above the manager
This commit is contained in:
JianFeeeee
2026-08-27 12:09:38 +08:00
parent a42ff62d06
commit 8334cffbc9
6 changed files with 418 additions and 7 deletions

View File

@ -314,12 +314,31 @@ func (c *Config) ApplyDefaults() error {
// RuntimeConfig is the legacy runtime file format (kept for migration only).
type RuntimeConfig struct {
Sources []Source `json:"sources,omitempty"`
DeletedSources []string `json:"deleted_sources,omitempty"`
DeletedAdapters []string `json:"deleted_adapters,omitempty"`
Keys []GWKey `json:"keys,omitempty"`
Auto []ModelScope `json:"auto,omitempty"`
AutoImage []ModelScope `json:"auto_image,omitempty"`
Sources []Source `json:"sources,omitempty"`
SourceTemplates []SourceTemplate `json:"source_templates,omitempty"`
DeletedSources []string `json:"deleted_sources,omitempty"`
DeletedAdapters []string `json:"deleted_adapters,omitempty"`
Keys []GWKey `json:"keys,omitempty"`
Auto []ModelScope `json:"auto,omitempty"`
AutoImage []ModelScope `json:"auto_image,omitempty"`
}
// SourceTemplate stores reusable source configuration (everything except
// name and api_key) so the WebUI can spin up multiple key-bearing sources
// from one shared template.
type SourceTemplate struct {
Name string `json:"name"`
BaseURL string `json:"base_url"`
Adapter string `json:"adapter"`
Endpoint string `json:"endpoint,omitempty"`
ImageEndpoint string `json:"image_endpoint,omitempty"`
Models []Model `json:"models"`
Headers map[string]string `json:"headers,omitempty"`
Meta map[string]interface{} `json:"meta,omitempty"`
Temperature float64 `json:"temperature,omitempty"`
MaxTokens int `json:"max_tokens,omitempty"`
MaxConcurrent int `json:"max_concurrent,omitempty"`
RPM int `json:"rpm,omitempty"`
}
// GWKey is a gateway API key persisted in the config file. Role is "admin"

View File

@ -138,6 +138,49 @@ func (s *Store) DeletedAdapters() map[string]bool {
return out
}
// UpsertTemplate adds or replaces a source template and persists.
func (s *Store) UpsertTemplate(t SourceTemplate) error {
s.mu.Lock()
defer s.mu.Unlock()
for i := range s.data.SourceTemplates {
if s.data.SourceTemplates[i].Name == t.Name {
s.data.SourceTemplates[i] = t
return s.persistLocked()
}
}
s.data.SourceTemplates = append(s.data.SourceTemplates, t)
return s.persistLocked()
}
// ListTemplates returns the saved source templates.
func (s *Store) ListTemplates() []SourceTemplate {
s.mu.Lock()
defer s.mu.Unlock()
out := make([]SourceTemplate, len(s.data.SourceTemplates))
copy(out, s.data.SourceTemplates)
return out
}
// RemoveTemplate deletes a source template by name and persists.
func (s *Store) RemoveTemplate(name string) (bool, error) {
s.mu.Lock()
defer s.mu.Unlock()
kept := s.data.SourceTemplates[:0]
removed := false
for _, t := range s.data.SourceTemplates {
if t.Name == name {
removed = true
continue
}
kept = append(kept, t)
}
s.data.SourceTemplates = kept
if removed {
return true, s.persistLocked()
}
return false, nil
}
// LoadLegacy returns the full legacy RuntimeConfig from the file (used for
// one-time migration into config.yaml). Returns nil if file is missing.
func (s *Store) LoadLegacy() *RuntimeConfig {