feat(scheduler): separate image-generation AUTO chain with UI toggle

Image models previously could not be scheduled through a priority
chain: the chat AUTO chain explicitly skips image-kind slots, and
AUTO image requests fell back to unordered registry discovery.

- config: add auto_image rules (auto_image yaml / image_rules json);
  legacy auto rules keep their meaning as the chat chain
- core: buildAutoImageChain mirrors buildAutoChain with inverted kind
  filter (image-only); SaveAutoImageRules + AutoImageRules/AutoImageChain
- scheduler: ChainImage walks the chain tier-by-tier with round-robin
  and preference ordering, skipping cooling slots
- gateway: handleImage AUTO now runs down AutoImageChain when one is
  configured (falls back to legacy discovery otherwise) and records
  the actual served model; handleAutoAPI GET returns image_rules and
  PUT accepts image_rules independently of rules
- webui: priority page gains a chat/image toggle editing two
  independent lane sets; add-slot picker filters by active kind;
  persistAuto writes only the active chain's field
This commit is contained in:
JianFeeeee
2026-08-26 21:02:33 +08:00
parent 66585549f1
commit a42ff62d06
7 changed files with 299 additions and 64 deletions

View File

@ -327,6 +327,63 @@ func (s *Scheduler) ChainChatStream(ctx context.Context, chain *Chain, req *type
return chunks, src, model, err
}
// ChainImage runs an image-generation AUTO request down the chain: tiers
// ascending (tier 1 highest priority), per-tier round-robin, same-tier order
// by preference. Each slot's model is pinned to its own image id (ModelFor),
// so a fallback switches per source. Cooling-down slots are skipped. Returns
// the response, serving source and the exact model id used; on total failure
// a *ChainErr summarizing every tier.
func (s *Scheduler) ChainImage(ctx context.Context, chain *Chain, req *types.ImageGenRequest) (*types.UnifiedResponse, string, string, error) {
if chain == nil || len(chain.Tiers) == 0 {
return nil, "", "", fmt.Errorf("no image auto slot configured")
}
var ce ChainErr
for _, tn := range chain.Tiers {
var cands []*Slot
for _, sl := range tn.Slots {
if !sl.Prov.ModelAvailable(sl.Model) {
continue
}
cands = append(cands, sl)
}
if len(cands) == 0 {
ce.Skipped = append(ce.Skipped, fmt.Sprintf("tier %d: no schedulable slot (cooling)", tn.Tier))
continue
}
sort.SliceStable(cands, func(i, j int) bool {
return cands[i].Prov.Pref(cands[i].Model) > cands[j].Prov.Pref(cands[j].Model)
})
base := tn.NextStart()
var hard []TierError
for i := 0; i < len(cands); i++ {
sl := cands[(int(base)+i)%len(cands)]
if !sl.Prov.ModelAvailable(sl.Model) {
continue
}
r := *req
r.Model = sl.Model
resp, err := sl.Prov.Image(ctx, &r)
if ctx.Err() != nil {
return nil, "", "", ctx.Err()
}
if err == nil {
return resp, sl.Source, sl.Model, nil
}
if errors.Is(err, types.ErrBusy) {
continue
}
hard = append(hard, TierError{Tier: tn.Tier, Source: sl.Source, Model: sl.Model, Err: err})
}
if len(hard) > 0 {
ce.Tiers = append(ce.Tiers, hard...)
}
}
if len(ce.Tiers) == 0 && len(ce.Skipped) == 0 {
return nil, "", "", fmt.Errorf("no image auto slot configured")
}
return nil, "", "", &ce
}
// ---- direct scheduling ----
// Chat runs a chat request across cands, falling back on failure. Each