mirror of
https://gitcode.com/JianFeeeee/ModelRouter.git
synced 2026-11-07 09:47:23 +00:00
Compare commits
8 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| a5384d9fb6 | |||
| ab20f1be48 | |||
| 8334cffbc9 | |||
| a42ff62d06 | |||
| 66585549f1 | |||
| 747dff5b76 | |||
| 5d045f97a8 | |||
| e48baa1bb3 |
1
.gitignore
vendored
1
.gitignore
vendored
@ -20,3 +20,4 @@ cmd/gui/*.log
|
||||
|
||||
# local ops scripts (machine-specific, not for sharing)
|
||||
scripts/
|
||||
cmd/gui/dist/
|
||||
|
||||
6
cmd/gui/package-lock.json
generated
6
cmd/gui/package-lock.json
generated
@ -1,12 +1,12 @@
|
||||
{
|
||||
"name": "modelrouter-gui",
|
||||
"version": "1.0.0",
|
||||
"version": "1.2.0",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "modelrouter-gui",
|
||||
"version": "1.0.0",
|
||||
"version": "1.2.0",
|
||||
"license": "Proprietary",
|
||||
"devDependencies": {
|
||||
"electron": "^33.0.0",
|
||||
@ -4045,4 +4045,4 @@
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "modelrouter-gui",
|
||||
"version": "1.1.0",
|
||||
"version": "1.2.0",
|
||||
"description": "ModelRouter Desktop — embedded ModelRouter core with tray",
|
||||
"author": "ModelRouter",
|
||||
"main": "main.js",
|
||||
|
||||
@ -24,7 +24,8 @@ type Config struct {
|
||||
TLSKeyFile string `yaml:"tls_key_file,omitempty"` // PEM private key
|
||||
PublicBaseURL string `yaml:"public_base_url,omitempty"` // external base for generated config snippets; default inferred from request
|
||||
Sources []Source `yaml:"sources"`
|
||||
Auto []ModelScope `yaml:"auto,omitempty"` // AUTO 调度链规则(WebUI 优先级页编辑)
|
||||
Auto []ModelScope `yaml:"auto,omitempty"` // AUTO 调度链规则(WebUI 优先级页编辑,chat)
|
||||
AutoImage []ModelScope `yaml:"auto_image,omitempty"` // AUTO 生图调度链规则(WebUI 优先级页·生图)
|
||||
Keys []GWKey `yaml:"keys,omitempty"` // 网关密钥(WebUI 密钥页管理)
|
||||
}
|
||||
|
||||
@ -313,11 +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"`
|
||||
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"
|
||||
|
||||
@ -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 {
|
||||
|
||||
@ -32,7 +32,8 @@ type Core struct {
|
||||
store *config.Store
|
||||
scheduler *scheduler.Scheduler
|
||||
registry *provider.Registry
|
||||
autoChain atomic.Pointer[scheduler.Chain]
|
||||
autoChain atomic.Pointer[scheduler.Chain] // chat AUTO chain
|
||||
autoImageChain atomic.Pointer[scheduler.Chain] // image-generation AUTO chain
|
||||
}
|
||||
|
||||
// New builds the core from a config file plus runtime overlay.
|
||||
@ -181,6 +182,9 @@ func (c *Core) Registry() *provider.Registry { return c.registry }
|
||||
// AutoChain returns the current AUTO scheduling chain.
|
||||
func (c *Core) AutoChain() *scheduler.Chain { return c.autoChain.Load() }
|
||||
|
||||
// AutoImageChain returns the persisted image-generation AUTO chain snapshot.
|
||||
func (c *Core) AutoImageChain() *scheduler.Chain { return c.autoImageChain.Load() }
|
||||
|
||||
func (c *Core) DefaultModel() string { return c.cfg.DefaultModel }
|
||||
|
||||
func (c *Core) GatewayKeys() []string { return c.cfg.GatewayKeys }
|
||||
@ -294,6 +298,15 @@ func (c *Core) AutoRules() []config.ModelScope {
|
||||
return out
|
||||
}
|
||||
|
||||
// AutoImageRules returns the configured image-generation AUTO chain rules.
|
||||
func (c *Core) AutoImageRules() []config.ModelScope {
|
||||
c.mu.Lock()
|
||||
defer c.mu.Unlock()
|
||||
out := make([]config.ModelScope, len(c.cfg.AutoImage))
|
||||
copy(out, c.cfg.AutoImage)
|
||||
return out
|
||||
}
|
||||
|
||||
// cleanScopes drops empty model entries and normalizes placeholder source
|
||||
// names; it returns nil when no entries survive so an empty scope means
|
||||
// "unrestricted" (nil) instead of a restrictive-but-empty list — a non-nil
|
||||
@ -325,16 +338,35 @@ func (c *Core) SaveAutoRules(entries []config.ModelScope) error {
|
||||
return err
|
||||
}
|
||||
c.buildAutoChain()
|
||||
if ch := c.autoChain.Load(); ch != nil {
|
||||
for _, tn := range ch.Tiers {
|
||||
for _, sl := range tn.Slots {
|
||||
if p := c.registry.ProviderForSlot(sl.Model, sl.Source); p != nil {
|
||||
p.ResetModelCooldown(sl.Model)
|
||||
}
|
||||
c.resetChainCooldowns(c.autoChain.Load())
|
||||
return nil
|
||||
}
|
||||
|
||||
// SaveAutoImageRules persists the image-generation AUTO chain. Image models
|
||||
// are kept as-is (unlike buildAutoChain, which skips them).
|
||||
func (c *Core) SaveAutoImageRules(entries []config.ModelScope) error {
|
||||
c.mu.Lock()
|
||||
defer c.mu.Unlock()
|
||||
c.cfg.AutoImage = cleanScopes(entries)
|
||||
if err := c.saveConfig(); err != nil {
|
||||
return err
|
||||
}
|
||||
c.buildAutoImageChain()
|
||||
c.resetChainCooldowns(c.autoImageChain.Load())
|
||||
return nil
|
||||
}
|
||||
|
||||
func (c *Core) resetChainCooldowns(ch *scheduler.Chain) {
|
||||
if ch == nil {
|
||||
return
|
||||
}
|
||||
for _, tn := range ch.Tiers {
|
||||
for _, sl := range tn.Slots {
|
||||
if p := c.registry.ProviderForSlot(sl.Model, sl.Source); p != nil {
|
||||
p.ResetModelCooldown(sl.Model)
|
||||
}
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// ResetHealth clears the scheduling backoff state of every provider.
|
||||
@ -420,6 +452,7 @@ func (c *Core) rebuildRegistry() error {
|
||||
c.registry.Replace(providers)
|
||||
}
|
||||
c.buildAutoChain()
|
||||
c.buildAutoImageChain()
|
||||
return nil
|
||||
}
|
||||
|
||||
@ -466,6 +499,47 @@ func (c *Core) buildAutoChain() {
|
||||
c.autoChain.Store(scheduler.BuildChain(sr, prov))
|
||||
}
|
||||
|
||||
// buildAutoImageChain rebuilds the image-generation AUTO chain snapshot.
|
||||
// Unlike buildAutoChain, only image-kind models participate: chat models in
|
||||
// the rules are skipped so a stale chat slot can't receive image traffic.
|
||||
func (c *Core) buildAutoImageChain() {
|
||||
prov := func(model, source string) scheduler.Provider {
|
||||
p := c.registry.ProviderForSlot(model, source)
|
||||
if p == nil {
|
||||
return nil
|
||||
}
|
||||
if m := p.ModelByID(model); m != nil && m.Kind != "image" {
|
||||
return nil
|
||||
}
|
||||
return p
|
||||
}
|
||||
rules := c.cfg.AutoImage
|
||||
sr := make([]scheduler.Rule, 0, len(rules))
|
||||
for _, e := range rules {
|
||||
model, source := e.Model, e.Source
|
||||
if p := c.registry.ProviderForSlot(e.Model, e.Source); p != nil {
|
||||
if exact := p.ModelIDFold(e.Model); exact != "" {
|
||||
model = exact
|
||||
}
|
||||
if m := p.ModelByID(model); m != nil && m.Kind != "image" {
|
||||
continue // chat-kind models never join the image AUTO chain
|
||||
}
|
||||
if source == "" {
|
||||
source = p.Name()
|
||||
}
|
||||
}
|
||||
sr = append(sr, scheduler.Rule{
|
||||
Model: model,
|
||||
Source: source,
|
||||
Tier: e.Tier,
|
||||
Quota: e.TokenQuota,
|
||||
Period: e.Period,
|
||||
Hours: e.Hours,
|
||||
})
|
||||
}
|
||||
c.autoImageChain.Store(scheduler.BuildChain(sr, prov))
|
||||
}
|
||||
|
||||
// AutoSlotState is the UI-facing health snapshot of one AUTO chain slot.
|
||||
type AutoSlotState struct {
|
||||
Model string `json:"model"`
|
||||
@ -601,6 +675,33 @@ func (c *Core) Sources() []config.Source {
|
||||
return c.mergedSources()
|
||||
}
|
||||
|
||||
// Templates returns the saved source templates.
|
||||
func (c *Core) Templates() []config.SourceTemplate {
|
||||
c.mu.Lock()
|
||||
defer c.mu.Unlock()
|
||||
return c.store.ListTemplates()
|
||||
}
|
||||
|
||||
// SaveTemplate upserts a source template.
|
||||
func (c *Core) SaveTemplate(t config.SourceTemplate) error {
|
||||
c.mu.Lock()
|
||||
defer c.mu.Unlock()
|
||||
if t.Name == "" {
|
||||
return fmt.Errorf("template requires a name")
|
||||
}
|
||||
return c.store.UpsertTemplate(t)
|
||||
}
|
||||
|
||||
// RemoveTemplate deletes a source template by name.
|
||||
func (c *Core) RemoveTemplate(name string) error {
|
||||
c.mu.Lock()
|
||||
defer c.mu.Unlock()
|
||||
if _, err := c.store.RemoveTemplate(name); err != nil {
|
||||
return err
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func normalizeSource(s *config.Source) error {
|
||||
if s.Name == "" || s.BaseURL == "" {
|
||||
return fmt.Errorf("source requires name and base_url")
|
||||
|
||||
@ -134,6 +134,45 @@ func csvHeaders(w http.ResponseWriter, filename string) {
|
||||
w.Header().Set("Content-Disposition", "attachment; filename="+filename)
|
||||
}
|
||||
|
||||
// handleSourceTemplatesAPI manages reusable source templates (a Source minus
|
||||
// name and api_key) stored in the runtime file so the WebUI can spin up
|
||||
// multiple key-bearing sources from one shared template.
|
||||
func (g *Gateway) handleSourceTemplatesAPI(w http.ResponseWriter, r *http.Request) {
|
||||
if reqRole(r.Context()) != "admin" {
|
||||
writeError(w, http.StatusForbidden, "forbidden", "admin role required")
|
||||
return
|
||||
}
|
||||
path := strings.TrimPrefix(r.URL.Path, "/api/source_templates")
|
||||
path = strings.Trim(path, "/")
|
||||
switch r.Method {
|
||||
case http.MethodGet:
|
||||
writeJSON(w, http.StatusOK, map[string]interface{}{"templates": g.core.Templates()})
|
||||
case http.MethodPost:
|
||||
var t config.SourceTemplate
|
||||
if err := json.NewDecoder(r.Body).Decode(&t); err != nil {
|
||||
writeError(w, http.StatusBadRequest, "invalid_request", "invalid json: "+err.Error())
|
||||
return
|
||||
}
|
||||
if err := g.core.SaveTemplate(t); err != nil {
|
||||
writeError(w, http.StatusBadRequest, "template_error", err.Error())
|
||||
return
|
||||
}
|
||||
writeJSON(w, http.StatusOK, map[string]interface{}{"ok": true, "templates": g.core.Templates()})
|
||||
case http.MethodDelete:
|
||||
if path == "" {
|
||||
writeError(w, http.StatusBadRequest, "invalid_request", "template name required")
|
||||
return
|
||||
}
|
||||
if err := g.core.RemoveTemplate(path); err != nil {
|
||||
writeError(w, http.StatusBadRequest, "template_error", err.Error())
|
||||
return
|
||||
}
|
||||
writeJSON(w, http.StatusOK, map[string]interface{}{"ok": true})
|
||||
default:
|
||||
writeError(w, http.StatusMethodNotAllowed, "method_not_allowed", "")
|
||||
}
|
||||
}
|
||||
|
||||
// exportKey resolves which masked key id an export covers: admins may pass
|
||||
// any key filter, user keys are always scoped to themselves (records and
|
||||
// aggregates are keyed by the masked keyID form).
|
||||
|
||||
@ -897,11 +897,45 @@ func (g *Gateway) handleImage(w http.ResponseWriter, r *http.Request) {
|
||||
if model == "" {
|
||||
model = g.core.DefaultModel()
|
||||
}
|
||||
if !isAuto(model) {
|
||||
if allow := g.allowedModels(r.Context()); allow != nil && !g.hasScopeModel(allow, model) {
|
||||
writeError(w, http.StatusForbidden, "model_not_allowed", fmt.Sprintf("model %q is not allowed for this key", model))
|
||||
if isAuto(model) {
|
||||
if chain := g.core.AutoImageChain(); chain != nil && len(chain.Tiers) > 0 {
|
||||
if msg := g.checkModelScope(r.Context(), "AUTO"); msg != "" {
|
||||
writeError(w, http.StatusForbidden, "model_not_allowed", msg)
|
||||
return
|
||||
}
|
||||
done := g.stats.Begin()
|
||||
defer done()
|
||||
rec := &Req{Key: keyID(reqKey(r.Context())), Type: "image", Model: model, OK: false}
|
||||
t0 := time.Now()
|
||||
resp, usedSrc, usedModel, err := g.core.Scheduler().ChainImage(r.Context(), chain, &req)
|
||||
rec.LatMs = time.Since(t0).Milliseconds()
|
||||
if err != nil {
|
||||
rec.Status = upstreamErrStatus(err)
|
||||
rec.Err = err.Error()
|
||||
g.writeRec(rec)
|
||||
writeError(w, rec.Status, "upstream_error", clientUpstreamErr(err))
|
||||
return
|
||||
}
|
||||
rec.Source = usedSrc
|
||||
if usedModel != "" {
|
||||
rec.Model = usedModel // actual image model served, not "AUTO"
|
||||
}
|
||||
rec.OK = true
|
||||
rec.Status = http.StatusOK
|
||||
rec.Compl = int64(len(resp.ImageData))
|
||||
g.writeRec(rec)
|
||||
writeJSON(w, http.StatusOK, types.ImageGenResponse{
|
||||
Created: time.Now().Unix(),
|
||||
Data: resp.ImageData,
|
||||
})
|
||||
return
|
||||
}
|
||||
// no image chain configured: fall through to legacy discovery (all
|
||||
// sources exposing an image model, tried in registry order)
|
||||
}
|
||||
if allow := g.allowedModels(r.Context()); allow != nil && !g.hasScopeModel(allow, model) {
|
||||
writeError(w, http.StatusForbidden, "model_not_allowed", fmt.Sprintf("model %q is not allowed for this key", model))
|
||||
return
|
||||
}
|
||||
cands, _ := g.resolveByModel(model)
|
||||
cands = imageOnly(cands)
|
||||
@ -932,6 +966,9 @@ func (g *Gateway) handleImage(w http.ResponseWriter, r *http.Request) {
|
||||
if usedSrc != "" {
|
||||
rec.Source = usedSrc
|
||||
}
|
||||
if resp.Model != "" {
|
||||
rec.Model = resp.Model // record the actual model served, not the raw request id
|
||||
}
|
||||
rec.OK = true
|
||||
rec.Status = http.StatusOK
|
||||
rec.Compl = int64(len(resp.ImageData))
|
||||
|
||||
@ -143,8 +143,9 @@ func (g *Gateway) allowedModels(ctx context.Context) []config.ModelScope {
|
||||
func (g *Gateway) handleAutoAPI(w http.ResponseWriter, r *http.Request) {
|
||||
if r.Method == http.MethodGet {
|
||||
writeJSON(w, http.StatusOK, map[string]interface{}{
|
||||
"rules": g.core.AutoRules(),
|
||||
"states": g.core.AutoSlotStates(),
|
||||
"rules": g.core.AutoRules(),
|
||||
"image_rules": g.core.AutoImageRules(),
|
||||
"states": g.core.AutoSlotStates(),
|
||||
})
|
||||
return
|
||||
}
|
||||
@ -155,17 +156,30 @@ func (g *Gateway) handleAutoAPI(w http.ResponseWriter, r *http.Request) {
|
||||
switch r.Method {
|
||||
case http.MethodPut, http.MethodPost:
|
||||
var body struct {
|
||||
Rules []config.ModelScope `json:"rules"`
|
||||
Rules []config.ModelScope `json:"rules"`
|
||||
ImageRules []config.ModelScope `json:"image_rules"`
|
||||
}
|
||||
if err := json.NewDecoder(r.Body).Decode(&body); err != nil {
|
||||
writeError(w, http.StatusBadRequest, "invalid_request", "invalid json: "+err.Error())
|
||||
return
|
||||
}
|
||||
if err := g.core.SaveAutoRules(body.Rules); err != nil {
|
||||
writeError(w, http.StatusBadRequest, "auto_error", err.Error())
|
||||
return
|
||||
if body.Rules != nil {
|
||||
if err := g.core.SaveAutoRules(body.Rules); err != nil {
|
||||
writeError(w, http.StatusBadRequest, "auto_error", err.Error())
|
||||
return
|
||||
}
|
||||
}
|
||||
writeJSON(w, http.StatusOK, map[string]interface{}{"ok": true, "rules": g.core.AutoRules()})
|
||||
if body.ImageRules != nil {
|
||||
if err := g.core.SaveAutoImageRules(body.ImageRules); err != nil {
|
||||
writeError(w, http.StatusBadRequest, "auto_error", err.Error())
|
||||
return
|
||||
}
|
||||
}
|
||||
writeJSON(w, http.StatusOK, map[string]interface{}{
|
||||
"ok": true,
|
||||
"rules": g.core.AutoRules(),
|
||||
"image_rules": g.core.AutoImageRules(),
|
||||
})
|
||||
default:
|
||||
writeError(w, http.StatusMethodNotAllowed, "method_not_allowed", "")
|
||||
}
|
||||
|
||||
@ -218,6 +218,8 @@ func (g *Gateway) routes(w http.ResponseWriter, r *http.Request) {
|
||||
g.handleAdaptersAPI(w, r)
|
||||
case r.URL.Path == "/api/sources" || strings.HasPrefix(r.URL.Path, "/api/sources/"):
|
||||
g.handleSourcesAPI(w, r)
|
||||
case r.URL.Path == "/api/source_templates" || strings.HasPrefix(r.URL.Path, "/api/source_templates/"):
|
||||
g.handleSourceTemplatesAPI(w, r)
|
||||
case r.URL.Path == "/api/chat":
|
||||
g.handleChat(w, r)
|
||||
case r.URL.Path == "/api/status":
|
||||
|
||||
@ -192,6 +192,8 @@
|
||||
.row{display:flex;gap:12px}.row>div{flex:1}
|
||||
.model-row{display:flex;gap:6px;align-items:center;width:100%}
|
||||
.model-row .m-id{flex:1;min-width:0;width:0}
|
||||
.tpl-pick-row{padding:12px 14px;border-radius:13px;cursor:pointer;background:var(--card2);border:1px solid var(--line);margin-bottom:8px;transition:background .15s,border-color .15s}
|
||||
.tpl-pick-row:hover{background:var(--accent-soft,#1e2a52);border-color:var(--primary)}
|
||||
.model-row .m-kind{flex:0 0 96px;width:96px}
|
||||
.model-row .del{flex:0 0 auto;padding:4px 8px}
|
||||
.muted{color:var(--muted)}
|
||||
@ -365,6 +367,8 @@
|
||||
#modal-wrap .card{width:100%;max-width:480px;animation:cardIn .28s var(--ease-spring) both}
|
||||
|
||||
/* ---- scratch-style priority canvas (sakura restyle, same classes) ---- */
|
||||
.kind-toggle{display:inline-flex;gap:4px;margin-right:8px}
|
||||
.kind-toggle button{padding:4px 12px;font-size:11.5px}
|
||||
.sort-canvas{position:relative;min-height:420px;padding:22px 14px 30px;margin:4px 0 10px;background-color:var(--card2);
|
||||
background-image:linear-gradient(var(--line-strong) 1px,transparent 1px),linear-gradient(90deg,var(--line-strong) 1px,transparent 1px);
|
||||
background-size:28px 28px;border:1px solid var(--line);border-radius:16px;overflow-x:auto;backdrop-filter:blur(10px)}
|
||||
@ -792,6 +796,14 @@
|
||||
cStream: "调用 /v1/chat/completions 流式 SSE",
|
||||
srcEmpty: "还没有配置任何源",
|
||||
srcAdd: "+ 新增源",
|
||||
tplManage: "模板管理",
|
||||
tplFrom: "从模板创建",
|
||||
tplSave: "存为模板",
|
||||
tplTitle: "模板管理",
|
||||
tplEmpty: "还没有模板",
|
||||
tplNew: "新建模板",
|
||||
tplNamePrompt: "输入模板名称",
|
||||
tplConfirmDel: "确认删除模板?",
|
||||
srcEdit: "编辑",
|
||||
srcDel: "删除",
|
||||
adEmpty: "尚未加载适配器",
|
||||
@ -992,6 +1004,14 @@
|
||||
cStream: "calls /v1/chat/completions streaming SSE",
|
||||
srcEmpty: "No sources configured yet",
|
||||
srcAdd: "+ Add source",
|
||||
tplManage: "Templates",
|
||||
tplFrom: "From template",
|
||||
tplSave: "As template",
|
||||
tplTitle: "Template Manager",
|
||||
tplEmpty: "No templates yet",
|
||||
tplNew: "New template",
|
||||
tplNamePrompt: "Template name",
|
||||
tplConfirmDel: "Delete this template?",
|
||||
srcEdit: "Edit",
|
||||
srcDel: "Delete",
|
||||
adEmpty: "No adapters loaded",
|
||||
@ -2268,6 +2288,7 @@
|
||||
firstChunk = Date.now();
|
||||
try {
|
||||
const resp = await fetch("/api/chat", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({
|
||||
model,
|
||||
@ -2404,6 +2425,7 @@
|
||||
.join("");
|
||||
$("#tab-sources").innerHTML = `
|
||||
<div class="card"><h2><span>${t("srcTitle")}</span><span class="grow"></span>
|
||||
<button class="ghost small" onclick="openTemplateModal()">${t("tplManage")}</button>
|
||||
<button class="small" onclick="editSource('')">${t("srcAdd")}</button></h2>
|
||||
<div class="tbl-wrap"><table><tr><th>${t("tName")}</th><th>${t("tURL")}</th><th>${t("tAdapter")}</th><th>${t("tModels")}</th><th></th></tr>
|
||||
${rows || `<tr><td colspan="5" class="empty">${t("srcEmpty")}</td></tr>`}</table></div>
|
||||
@ -2431,7 +2453,9 @@
|
||||
)
|
||||
.join("") +
|
||||
"</select>";
|
||||
wrap.innerHTML = `<div class="card"><h2>${esc(name ? t("modalEdit") + ": " + name : t("modalNew"))}</h2>
|
||||
wrap.innerHTML = `<div class="card"><h2><span>${esc(name ? t("modalEdit") + ": " + name : t("modalNew"))}</span><span class="grow"></span>
|
||||
${!name ? `<button class="ghost small" onclick="srcFromTemplate()">${t("tplFrom")}</button>` : ""}
|
||||
<button class="ghost small" onclick="srcSaveAsTemplate()">${t("tplSave")}</button></h2>
|
||||
<div class="row">
|
||||
<div><label>${t("mName")}</label><input id="s-name" value="${escAttr(s.name)}" ${name ? "disabled" : ""}></div>
|
||||
<div><label>${t("mAlias")}</label>${adSel}</div>
|
||||
@ -2516,6 +2540,8 @@
|
||||
btn.disabled = true;
|
||||
try {
|
||||
await api("/api/sources", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify(payload),
|
||||
});
|
||||
toast(t("toastSaved"));
|
||||
@ -2528,10 +2554,273 @@
|
||||
btn.disabled = false;
|
||||
}
|
||||
}
|
||||
// collect the source form fields except name & api_key — the fields a
|
||||
// template reuses. Returns the cleaned object or null on invalid JSON.
|
||||
function readSourceFormExceptKeys() {
|
||||
const models = [...document.querySelectorAll("#s-models .model-row")]
|
||||
.map((row) => ({
|
||||
id: row.querySelector(".m-id").value.trim(),
|
||||
priority: parseInt(row.dataset.priority) || 0,
|
||||
kind: row.querySelector(".m-kind").value,
|
||||
}))
|
||||
.filter((m) => m.id);
|
||||
let meta = {};
|
||||
try {
|
||||
meta = JSON.parse($("#s-meta").value || "{}");
|
||||
} catch (e) {
|
||||
toast(t("toastBadJson"));
|
||||
return null;
|
||||
}
|
||||
return {
|
||||
base_url: $("#s-url").value.trim(),
|
||||
adapter: $("#s-adapter").value.trim(),
|
||||
endpoint: $("#s-ep").value.trim(),
|
||||
image_endpoint: $("#s-img").value.trim(),
|
||||
max_concurrent: parseInt($("#s-conc").value) || 8,
|
||||
rpm: parseInt($("#s-rpm").value) || 0,
|
||||
temperature: parseFloat($("#s-temp").value) || 0,
|
||||
models,
|
||||
meta,
|
||||
};
|
||||
}
|
||||
// "存为模板": save every non-key/non-name form field as a template.
|
||||
async function srcSaveAsTemplate() {
|
||||
const body = readSourceFormExceptKeys();
|
||||
if (!body) return;
|
||||
const dflt = ($("#s-name").value || "").trim() || "tpl";
|
||||
const wrap = document.createElement("div");
|
||||
wrap.id = "tpl-save-modal";
|
||||
wrap.style.cssText =
|
||||
"position:fixed;inset:0;background:rgba(15,22,44,.45);display:flex;align-items:flex-start;justify-content:center;overflow:auto;padding:48px 20px;z-index:70";
|
||||
wrap.innerHTML = `<div class="card" style="width:420px"><h2>${t("tplSave")}</h2>
|
||||
<label>${t("tplNamePrompt")}</label>
|
||||
<input id="tpl-save-name" value="${escAttr(dflt)}">
|
||||
<p><button onclick="confirmSaveTemplate()">${t("mSave")}</button>
|
||||
<button class="ghost" onclick="this.closest('#tpl-save-modal').remove()">${t("mCancel")}</button></p>
|
||||
</div>`;
|
||||
document.body.appendChild(wrap);
|
||||
$("#tpl-save-name").focus();
|
||||
window._templateSaveBody = body;
|
||||
}
|
||||
async function confirmSaveTemplate() {
|
||||
const nm = ($("#tpl-save-name").value || "").trim();
|
||||
if (!nm) { toast(t("tplNamePrompt")); return; }
|
||||
const body = window._templateSaveBody;
|
||||
if (!body) return;
|
||||
try {
|
||||
const r = await api("/api/source_templates", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ name: nm, ...body }),
|
||||
});
|
||||
toast(t("toastSaved"));
|
||||
$("#tpl-save-modal").remove();
|
||||
} catch (e) {
|
||||
toast(e.message);
|
||||
}
|
||||
}
|
||||
// "从模板创建": pick a template and fill the form (except name/key).
|
||||
async function srcFromTemplate() {
|
||||
let tpls = [];
|
||||
try {
|
||||
tpls = (await api("/api/source_templates")).templates || [];
|
||||
} catch (e) {
|
||||
toast(e.message);
|
||||
return;
|
||||
}
|
||||
if (!tpls.length) {
|
||||
toast(t("tplEmpty"));
|
||||
return;
|
||||
}
|
||||
const wrap = document.createElement("div");
|
||||
wrap.id = "template-pick";
|
||||
wrap.style.cssText =
|
||||
"position:fixed;inset:0;background:rgba(15,22,44,.45);display:flex;align-items:flex-start;justify-content:center;overflow:auto;padding:48px 20px;z-index:60";
|
||||
const rows = tpls
|
||||
.map(
|
||||
(tpl) =>
|
||||
`<div class="tpl-pick-row" data-tpl="${escAttr(tpl.name)}">
|
||||
<div><b>${esc(tpl.name)}</b><span class="muted"> · ${esc(tpl.base_url || "")}</span></div>
|
||||
<div class="muted">${esc((tpl.adapter || "") + " · " + (tpl.models || []).map((m) => m.id).join(", "))}</div>
|
||||
</div>`,
|
||||
)
|
||||
.join("");
|
||||
wrap.innerHTML = `<div class="card" style="width:420px"><h2>${t("tplFrom")}</h2>
|
||||
${rows}
|
||||
<p><button class="ghost" onclick="this.closest('#template-pick').remove()">${t("mCancel")}</button></p>
|
||||
</div>`;
|
||||
document.body.appendChild(wrap);
|
||||
// delegate click to avoid inline-handler quoting issues
|
||||
wrap.addEventListener("click", (e) => {
|
||||
const row = e.target.closest(".tpl-pick-row");
|
||||
if (!row) return;
|
||||
pickTemplate(row.dataset.tpl);
|
||||
});
|
||||
}
|
||||
// applies a template's fields onto the open source form (name + key stay).
|
||||
function pickTemplate(name) {
|
||||
api("/api/source_templates")
|
||||
.then((r) => {
|
||||
const tpl = (r.templates || []).find((t) => t.name === name);
|
||||
if (!tpl) return;
|
||||
$("#s-url").value = tpl.base_url || "";
|
||||
$("#s-adapter").value = tpl.adapter || "";
|
||||
$("#s-ep").value = tpl.endpoint || "";
|
||||
$("#s-img").value = tpl.image_endpoint || "";
|
||||
$("#s-conc").value = tpl.max_concurrent || 8;
|
||||
$("#s-rpm").value = tpl.rpm || 0;
|
||||
$("#s-temp").value = tpl.temperature ?? 0.7;
|
||||
$("#s-meta").value = JSON.stringify(tpl.meta || {}, null, 2);
|
||||
const box = $("#s-models");
|
||||
box.innerHTML = "";
|
||||
(tpl.models && tpl.models.length
|
||||
? tpl.models
|
||||
: [{ id: "", priority: 0, kind: "chat" }]
|
||||
).forEach((m, i) =>
|
||||
box.insertAdjacentHTML("beforeend", modelRow(m, i)),
|
||||
);
|
||||
const w = $("#template-pick");
|
||||
if (w) w.remove();
|
||||
toast(t("toastSaved"));
|
||||
})
|
||||
.catch((e) => toast(e.message));
|
||||
}
|
||||
// template manager: list all templates with edit + delete.
|
||||
async function openTemplateModal() {
|
||||
let tpls = [];
|
||||
try {
|
||||
tpls = (await api("/api/source_templates")).templates || [];
|
||||
} catch (e) {
|
||||
toast(e.message);
|
||||
return;
|
||||
}
|
||||
const wrap = document.createElement("div");
|
||||
wrap.id = "tpl-manage";
|
||||
wrap.style.cssText =
|
||||
"position:fixed;inset:0;background:rgba(15,22,44,.45);display:flex;align-items:flex-start;justify-content:center;overflow:auto;padding:48px 20px;z-index:60";
|
||||
wrap.innerHTML = `<div class="card" style="width:560px;max-width:96%"><h2>${t("tplTitle")}<span class="grow"></span>
|
||||
<button class="small" onclick="editTemplate('')">${t("tplNew")}</button></h2>
|
||||
${tpls.length
|
||||
? `<div class="tbl-wrap"><table><tr><th>${t("tName")}</th><th>${t("tURL")}</th><th>${t("tAdapter")}</th><th>${t("tModels")}</th><th></th></tr>` +
|
||||
tpls
|
||||
.map(
|
||||
(t) =>
|
||||
`<tr><td><b>${esc(t.name)}</b></td><td>${esc(t.base_url)}</td><td>${esc(t.adapter)}</td>
|
||||
<td><div class="src-models">${(t.models || []).map((m) => `<span class="tag tag-blue">${esc(m.id)}</span>`).join("")}</div></td>
|
||||
<td><button class="ghost small" onclick="editTemplate(${JSON.stringify(t.name).replace(/["<>]/g, (c) => ({ '"': """, "<": "<", ">": ">" }[c]))})">${t("srcEdit")}</button>
|
||||
<button class="danger small" onclick="delTemplate('${escAttr(t.name)}')">${t("srcDel")}</button></td></tr>`,
|
||||
)
|
||||
.join("") +
|
||||
"</table></div>"
|
||||
: `<div class="empty">${t("tplEmpty")}</div>`}
|
||||
<p><button class="ghost" onclick="this.closest('#tpl-manage').remove()">${t("mCancel")}</button></p>
|
||||
</div>`;
|
||||
document.body.appendChild(wrap);
|
||||
}
|
||||
async function delTemplate(name) {
|
||||
if (!confirm(tFmt("tplConfirmDel", name))) return;
|
||||
try {
|
||||
await api("/api/source_templates/" + encodeURIComponent(name), {
|
||||
method: "DELETE",
|
||||
});
|
||||
toast(t("toastDelOk"));
|
||||
openTemplateModal();
|
||||
} catch (e) {
|
||||
toast(e.message);
|
||||
}
|
||||
}
|
||||
// edit or create a template (no name / api_key fields).
|
||||
async function editTemplate(name) {
|
||||
const wrap = document.createElement("div");
|
||||
wrap.id = "modal-wrap";
|
||||
Promise.all([api("/api/source_templates"), api("/api/status")])
|
||||
.then(([tplsR, st]) => {
|
||||
const tlist = tplsR.templates || [];
|
||||
const s = tlist.find((x) => x.name === name) || {
|
||||
name: name,
|
||||
models: [{ id: "", priority: 0, kind: "chat" }],
|
||||
};
|
||||
const cur = s.adapter || "openai";
|
||||
const apps = ["", ...(st.adapters || []).map((a) => a.name)];
|
||||
if (cur && !apps.includes(cur)) apps.push(cur);
|
||||
const adSel =
|
||||
`<select id="s-adapter"><option value="" ${!cur ? "selected" : ""}>${esc(t("mAliasAuto"))}</option>` +
|
||||
apps
|
||||
.filter((n) => n)
|
||||
.map(
|
||||
(n) =>
|
||||
`<option value="${escAttr(n)}" ${n === cur ? "selected" : ""}>${esc(n)}</option>`,
|
||||
)
|
||||
.join("") +
|
||||
"</select>";
|
||||
wrap.innerHTML = `<div class="card"><h2>${esc(name ? t("tplTitle") + ": " + name : t("tplNew"))}</h2>
|
||||
<div class="row">
|
||||
<div><label>${t("mName")}</label><input id="s-name" value="${escAttr(s.name)}" ${name ? "disabled" : ""}></div>
|
||||
<div><label>${t("mAlias")}</label>${adSel}</div>
|
||||
</div>
|
||||
<label>${t("mURL")}</label><input id="s-url" value="${escAttr(s.base_url || "")}">
|
||||
<div class="row">
|
||||
<div><label>${t("mEp")}</label><input id="s-ep" value="${escAttr(s.endpoint || "")}"></div>
|
||||
<div><label>${t("mImgEp")}</label><input id="s-img" value="${escAttr(s.image_endpoint || "")}"></div>
|
||||
</div>
|
||||
<div class="row">
|
||||
<div><label>${t("mConc")}</label><input id="s-conc" type="number" value="${s.max_concurrent || 8}"></div>
|
||||
<div><label>${t("mTemp")}</label><input id="s-temp" type="number" step="0.1" value="${s.temperature ?? 0.7}"></div>
|
||||
</div>
|
||||
<div class="row">
|
||||
<div><label>${t("mRPM")}</label><input id="s-rpm" type="number" min="0" placeholder="0 = 不限" value="${s.rpm || 0}"></div>
|
||||
<div></div>
|
||||
</div>
|
||||
<label>${t("mModels")}</label>
|
||||
<div id="s-models"></div>
|
||||
<button class="ghost small" onclick="addModelRow()">${t("mAddModel")}</button>
|
||||
<label>${t("mMeta")}</label>
|
||||
<textarea id="s-meta" style="min-height:80px" placeholder='{"app_id":"x","app_secret":"y"}'>${esc(JSON.stringify(s.meta || {}, null, 2))}</textarea>
|
||||
<p><button onclick="saveTemplate(this)">${t("mSave")}</button>
|
||||
<button class="ghost" onclick="this.closest('#modal-wrap').remove()">${t("mCancel")}</button></p>
|
||||
</div>`;
|
||||
wrap.style.cssText =
|
||||
"position:fixed;inset:0;background:rgba(15,22,44,.45);display:flex;align-items:flex-start;justify-content:center;overflow:auto;padding:48px 20px;z-index:70";
|
||||
document.body.appendChild(wrap);
|
||||
const box = $("#s-models");
|
||||
(s.models && s.models.length
|
||||
? s.models
|
||||
: [{ id: "", priority: 0, kind: "chat" }]
|
||||
).forEach((m, i) =>
|
||||
box.insertAdjacentHTML("beforeend", modelRow(m, i)),
|
||||
);
|
||||
})
|
||||
.catch(() => {});
|
||||
}
|
||||
async function saveTemplate(btn) {
|
||||
const nm = $("#s-name").value.trim();
|
||||
if (!nm) {
|
||||
toast(t("tplNamePrompt"));
|
||||
return;
|
||||
}
|
||||
const body = readSourceFormExceptKeys();
|
||||
if (!body) return;
|
||||
btn.disabled = true;
|
||||
try {
|
||||
await api("/api/source_templates", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ name: nm, ...body }),
|
||||
});
|
||||
toast(t("toastSaved"));
|
||||
const w = $("#modal-wrap");
|
||||
if (w) w.remove();
|
||||
openTemplateModal();
|
||||
} catch (e) {
|
||||
toast(e.message);
|
||||
btn.disabled = false;
|
||||
}
|
||||
}
|
||||
async function delSource(name) {
|
||||
if (!confirm(tFmt("confirmDelSrc", name))) return;
|
||||
await api("/api/sources/" + encodeURIComponent(name), {
|
||||
});
|
||||
await api("/api/sources/" + encodeURIComponent(name), {
|
||||
method: "DELETE",
|
||||
});
|
||||
toast(t("toastDelOk"));
|
||||
renderSources();
|
||||
}
|
||||
@ -2560,14 +2849,19 @@
|
||||
function srcShort(name) {
|
||||
return (name || "?").slice(0, 2).toUpperCase();
|
||||
}
|
||||
const sortState = { lanes: [], origin: null, drag: null };
|
||||
const sortState = { lanes: [], origin: null, drag: null, kind: "chat" };
|
||||
// per-kind editor state: chat lanes and image lanes are independent;
|
||||
// the toggle switches which chain the canvas edits
|
||||
const sortLanes = { chat: [], image: [] };
|
||||
let sortStateMap = new Map();
|
||||
async function renderSort() {
|
||||
const j = await api("/api/sources");
|
||||
let autoR = [];
|
||||
let autoImg = [];
|
||||
try {
|
||||
const a = await api("/api/auto");
|
||||
autoR = a.rules || [];
|
||||
autoImg = a.image_rules || [];
|
||||
sortStateMap = new Map(
|
||||
(a.states || []).map((st) => [
|
||||
st.model + "|" + (st.source || "*"),
|
||||
@ -2580,58 +2874,69 @@
|
||||
const sourceRows = new Map();
|
||||
j.sources.forEach((s) =>
|
||||
(s.models || []).forEach((m) => {
|
||||
if (m.kind === "image") return;
|
||||
const it = { src: s.name, id: m.id, prio: m.priority || 0 };
|
||||
const it = { src: s.name, id: m.id, prio: m.priority || 0, kind: m.kind };
|
||||
byPair.set(m.id + "|" + s.name, it);
|
||||
if (!byModel.has(m.id)) byModel.set(m.id, it);
|
||||
if (!sourceRows.has(it.prio)) sourceRows.set(it.prio, []);
|
||||
sourceRows.get(it.prio).push({ src: s.name, id: m.id });
|
||||
}),
|
||||
);
|
||||
if (autoR.length) {
|
||||
const tiers = [];
|
||||
autoR.forEach((r, i) => {
|
||||
const src = normSrc(r.source);
|
||||
const pair = src ? byPair.get(r.model + "|" + src) : null;
|
||||
if (src && !pair) return;
|
||||
const ref = pair ||
|
||||
byModel.get(r.model) || { src: "*", id: r.model };
|
||||
const ti = Math.max(1, parseInt(r.tier) || i + 1) - 1;
|
||||
if (!tiers[ti]) tiers[ti] = [];
|
||||
tiers[ti].push({
|
||||
src: ref.src || "*",
|
||||
id: r.model,
|
||||
uid: nexUid(),
|
||||
meta: {
|
||||
quota: r.token_quota || 0,
|
||||
period: r.period || "",
|
||||
hours: r.hours || 0,
|
||||
},
|
||||
// build a lane structure for one kind from its saved rules; when
|
||||
// rules are empty, fall back to discovery from sourceRows filtered by
|
||||
// kind so an unconfigured chain still shows all available slots
|
||||
const buildLanes = (rules, kind) => {
|
||||
if (rules.length) {
|
||||
const tiers = [];
|
||||
rules.forEach((r, i) => {
|
||||
const src = normSrc(r.source);
|
||||
const pair = src ? byPair.get(r.model + "|" + src) : null;
|
||||
if (src && !pair) return;
|
||||
const ref = pair ||
|
||||
byModel.get(r.model) || { src: "*", id: r.model };
|
||||
const ti = Math.max(1, parseInt(r.tier) || i + 1) - 1;
|
||||
if (!tiers[ti]) tiers[ti] = [];
|
||||
tiers[ti].push({
|
||||
src: ref.src || "*",
|
||||
id: r.model,
|
||||
uid: nexUid(),
|
||||
meta: {
|
||||
quota: r.token_quota || 0,
|
||||
period: r.period || "",
|
||||
hours: r.hours || 0,
|
||||
},
|
||||
});
|
||||
});
|
||||
});
|
||||
sortState.lanes = tiers
|
||||
.filter(Boolean)
|
||||
.map((models, i) => ({ prio: (tiers.length - i) * 10, models }));
|
||||
} else {
|
||||
sortState.lanes = [...sourceRows.entries()]
|
||||
return tiers
|
||||
.filter(Boolean)
|
||||
.map((models, i) => ({ prio: (tiers.length - i) * 10, models }));
|
||||
}
|
||||
// discovery: group slots of this kind by their source priority
|
||||
return [...sourceRows.entries()]
|
||||
.sort((a, b) => b[0] - a[0])
|
||||
.map(([prio, models]) => {
|
||||
models.sort((x, y) =>
|
||||
x.src < y.src ? -1 : x.src > y.src ? 1 : 0,
|
||||
);
|
||||
models.forEach((m) => {
|
||||
m.uid = nexUid();
|
||||
m.meta = null;
|
||||
const same = models.filter((m) => {
|
||||
const it = byPair.get(m.id + "|" + m.src);
|
||||
const k = it ? it.kind : "chat";
|
||||
return k === kind;
|
||||
});
|
||||
return { prio, models };
|
||||
});
|
||||
}
|
||||
if (!same.length) return null;
|
||||
same.sort((x, y) => x.src < y.src ? -1 : x.src > y.src ? 1 : 0);
|
||||
same.forEach((m) => { m.uid = nexUid(); m.meta = null; });
|
||||
return { prio, models: same };
|
||||
})
|
||||
.filter(Boolean);
|
||||
};
|
||||
sortLanes.chat = buildLanes(autoR, "chat");
|
||||
sortLanes.image = buildLanes(autoImg, "image");
|
||||
sortState.lanes = sortLanes[sortState.kind] || [];
|
||||
// model picker for the "add slot" control: one option per (source, model)
|
||||
// pair so identical model ids on different sources stay distinguishable
|
||||
await loadModelPairs();
|
||||
sortState.origin = JSON.stringify(sortState.lanes);
|
||||
const kindToggle =
|
||||
`<div class="kind-toggle"><button class="${sortState.kind === "chat" ? "" : "ghost"} small" onclick="switchSortKind('chat')">${t("kChat") || "聊天"}</button><button class="${sortState.kind === "image" ? "" : "ghost"} small" onclick="switchSortKind('image')">${t("kImage") || "生图"}</button></div>`;
|
||||
$("#tab-sort").innerHTML = `
|
||||
<div class="card"><h2><span>${t("sortTitle")}</span><span class="grow"></span>
|
||||
<div class="card"><h2><span>${t("sortTitle")}</span><span class="grow"></span>${kindToggle}
|
||||
<button class="ghost small" onclick="scrAddModal()">+ ${t("sortAdd")}</button>
|
||||
<button class="ghost small" onclick="sortReset()">${t("sortReset")}</button>
|
||||
<button class="small" onclick="saveSort()">${t("sortSave")}</button></h2>
|
||||
@ -2651,6 +2956,25 @@
|
||||
</div>`;
|
||||
paintSort();
|
||||
}
|
||||
function switchSortKind(k) {
|
||||
if (sortState.kind === k) return;
|
||||
sortLanes[sortState.kind] = sortState.lanes;
|
||||
sortState.kind = k;
|
||||
sortState.lanes = sortLanes[k] || [];
|
||||
sortState.origin = JSON.stringify(sortState.lanes);
|
||||
sortState.drag = null;
|
||||
const h = $("#tab-sort").querySelector("h2");
|
||||
if (h) {
|
||||
const tg = h.querySelector(".kind-toggle");
|
||||
if (tg) {
|
||||
tg.querySelectorAll("button").forEach((b) => {
|
||||
const isK = (b.getAttribute("onclick") || "").indexOf("'" + k + "'") >= 0;
|
||||
b.className = isK ? "small" : "ghost small";
|
||||
});
|
||||
}
|
||||
}
|
||||
paintSort();
|
||||
}
|
||||
function healthTag(it) {
|
||||
const st = sortStateMap.get(it.id + "|" + (it.src || "*"));
|
||||
if (!st) return "";
|
||||
@ -3113,8 +3437,15 @@
|
||||
});
|
||||
}),
|
||||
);
|
||||
// each kind persists to its own chain; the other kind's saved rules
|
||||
// are left untouched (omitting the field means "no change")
|
||||
const body = { rules: undefined, image_rules: undefined };
|
||||
if (sortState.kind === "image") body.image_rules = rules;
|
||||
else body.rules = rules;
|
||||
await api("/api/auto", {
|
||||
body: JSON.stringify({ rules }),
|
||||
method: "PUT",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify(body),
|
||||
});
|
||||
}
|
||||
function scrAddModal() {
|
||||
@ -3123,15 +3454,25 @@
|
||||
wrap.id = "modal-wrap";
|
||||
wrap.style.cssText =
|
||||
"position:fixed;inset:0;background:rgba(15,22,44,.45);display:flex;align-items:flex-start;justify-content:center;overflow:auto;padding:48px 20px;z-index:50";
|
||||
wrap.innerHTML = `<div class="card" style="width:400px;max-width:100%"><h2>${t("sortAdd")}</h2>
|
||||
// filter the model picker to the kind currently edited (image models
|
||||
// in image mode, chat models in chat mode)
|
||||
const kind = sortState.kind;
|
||||
const pickModels = (allModels || []).filter((m) => {
|
||||
const k = typeof m === "string" ? "chat" : m.kind || "chat";
|
||||
return kind === "image" ? k === "image" : k !== "image";
|
||||
});
|
||||
const optsHtml = pickModels.length
|
||||
? pickModels
|
||||
.map((m) =>
|
||||
typeof m === "string"
|
||||
? `<option value="${escAttr(m)}">${esc(m)}</option>`
|
||||
: `<option value="${escAttr(m.key)}">${esc(m.label)}</option>`,
|
||||
)
|
||||
.join("")
|
||||
: `<option value="" disabled selected>${esc(t("kEmpty") || "No models")}</option>`;
|
||||
wrap.innerHTML = `<div class="card" style="width:400px;max-width:100%"><h2>${t("sortAdd")} ${kind === "image" ? "· 生图" : "· 聊天"}</h2>
|
||||
<label>${t("kModelB")} <span class="muted">${t("kSrcHint")}</span></label>
|
||||
<select id="a-model">${allModels
|
||||
.map((m) =>
|
||||
typeof m === "string"
|
||||
? `<option value="${escAttr(m)}">${esc(m)}</option>`
|
||||
: `<option value="${escAttr(m.key)}">${esc(m.label)}</option>`,
|
||||
)
|
||||
.join("")}</select>
|
||||
<select id="a-model">${optsHtml}</select>
|
||||
<label>${t("kQuotaB")} <span class="muted">${t("kQuotaHintB")}</span></label>
|
||||
<input id="a-quota" type="number" min="0" step="1" placeholder="${escAttr(t("kQuotaHintB"))}">
|
||||
<label>${t("kPeriodB")}</label>
|
||||
@ -3372,6 +3713,8 @@
|
||||
if (!name || !code) return toast(t("toastNeedAll"));
|
||||
try {
|
||||
await api("/api/adapters", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ name, code }),
|
||||
});
|
||||
toast(t("toastUploaded"));
|
||||
@ -3401,11 +3744,11 @@
|
||||
const pairs = [];
|
||||
(srcs || []).forEach((s) =>
|
||||
(s.models || []).forEach((m) => {
|
||||
if (m.kind === "image") return;
|
||||
pairs.push({
|
||||
src: s.name,
|
||||
id: m.id,
|
||||
label: m.id + " · " + s.name,
|
||||
label: m.id + " · " + s.name + (m.kind === "image" ? " (image)" : ""),
|
||||
kind: m.kind,
|
||||
key: m.id + "|" + s.name,
|
||||
});
|
||||
}),
|
||||
@ -3584,6 +3927,8 @@
|
||||
}
|
||||
async function putScope(key, scopes) {
|
||||
await api("/api/keys/" + encodeURIComponent(key), {
|
||||
method: "PUT",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ models: scopes }),
|
||||
});
|
||||
}
|
||||
@ -3905,6 +4250,8 @@
|
||||
let j;
|
||||
try {
|
||||
j = await api("/api/keys", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({
|
||||
name,
|
||||
role: $("#kc-role").value,
|
||||
|
||||
@ -988,6 +988,7 @@ func (p *Provider) Image(ctx context.Context, req *types.ImageGenRequest) (*type
|
||||
unified, terr := p.vm.Transform(p.adapter+"_image", "transform_response", raw)
|
||||
if terr == nil && unified != raw {
|
||||
if err := json.Unmarshal([]byte(unified), &out); err == nil {
|
||||
out.Model = model // actual model served (AUTO resolved to bestImageModel)
|
||||
p.RecordSuccess(model)
|
||||
return &out, nil
|
||||
}
|
||||
@ -1000,6 +1001,7 @@ func (p *Provider) Image(ctx context.Context, req *types.ImageGenRequest) (*type
|
||||
return nil, fmt.Errorf("unmarshal image response: %w", err)
|
||||
}
|
||||
out.ImageData = img.Data
|
||||
out.Model = model // actual model served (AUTO resolved to bestImageModel)
|
||||
p.RecordSuccess(model)
|
||||
return &out, nil
|
||||
}
|
||||
|
||||
@ -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
|
||||
|
||||
@ -71,6 +71,7 @@ type ToolCall struct {
|
||||
// ---- Unified internal representation (what adapters produce) ----
|
||||
|
||||
type UnifiedResponse struct {
|
||||
Model string `json:"model,omitempty"`
|
||||
Content string `json:"content"`
|
||||
ReasoningContent string `json:"reasoning_content,omitempty"`
|
||||
FinishReason string `json:"finish_reason,omitempty"`
|
||||
|
||||
Reference in New Issue
Block a user