mirror of
https://gitcode.com/JianFeeeee/ModelRouter.git
synced 2026-09-20 08:57:57 +00:00
feat(stats): gateway request stats/audit API + polish scratch sort animations (link chain, reset, cleanup)
This commit is contained in:
@ -113,4 +113,14 @@ func (g *Gateway) handleSourcesAPI(w http.ResponseWriter, r *http.Request) {
|
||||
default:
|
||||
writeError(w, http.StatusMethodNotAllowed, "method_not_allowed", "")
|
||||
}
|
||||
}
|
||||
}
|
||||
// handleStatsAPI returns per-key / per-model / per-source usage aggregates and
|
||||
// the recent request audit trail.
|
||||
func (g *Gateway) handleStatsAPI(w http.ResponseWriter, r *http.Request) {
|
||||
if r.Method != http.MethodGet {
|
||||
writeError(w, http.StatusMethodNotAllowed, "method_not_allowed", "use GET")
|
||||
return
|
||||
}
|
||||
limit := 500
|
||||
writeJSON(w, http.StatusOK, g.stats.Snapshot(limit))
|
||||
}
|
||||
|
||||
@ -147,6 +147,8 @@ func (g *Gateway) handleChat(w http.ResponseWriter, r *http.Request) {
|
||||
effective = firstModel(cands[0])
|
||||
}
|
||||
ctx := r.Context()
|
||||
done := g.stats.Begin()
|
||||
defer done()
|
||||
|
||||
inner := &types.ChatRequest{
|
||||
Model: effective,
|
||||
@ -159,11 +161,19 @@ func (g *Gateway) handleChat(w http.ResponseWriter, r *http.Request) {
|
||||
DisableThinking: req.DisableThinking,
|
||||
ExtraBody: req.ExtraBody,
|
||||
}
|
||||
rec := &Req{
|
||||
Key: keyID(reqKey(ctx)),
|
||||
Type: "chat",
|
||||
Model: effective,
|
||||
Source: firstSource(cands),
|
||||
OK: false,
|
||||
}
|
||||
if req.Stream {
|
||||
g.streamChat(w, ctx, cands, inner, effective)
|
||||
rec.Type = "stream"
|
||||
g.streamChat(w, ctx, cands, inner, effective, rec)
|
||||
return
|
||||
}
|
||||
g.singleChat(w, ctx, cands, inner, effective)
|
||||
g.singleChat(w, ctx, cands, inner, effective, rec)
|
||||
}
|
||||
|
||||
func normalizeModel(m string) string {
|
||||
@ -220,12 +230,32 @@ func toolCallsWire(tcs []types.ToolCall) json.RawMessage {
|
||||
return b
|
||||
}
|
||||
|
||||
func (g *Gateway) singleChat(w http.ResponseWriter, ctx context.Context, cands []*provider.Provider, req *types.ChatRequest, effective string) {
|
||||
func firstSource(cands []*provider.Provider) string {
|
||||
if len(cands) > 0 {
|
||||
return cands[0].Name()
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
func (g *Gateway) singleChat(w http.ResponseWriter, ctx context.Context, cands []*provider.Provider, req *types.ChatRequest, effective string, rec *Req) {
|
||||
rec.LatMs = 0
|
||||
t0 := time.Now()
|
||||
resp, err := g.core.Scheduler().Chat(ctx, scheduler.FromRegistry(cands), req)
|
||||
rec.LatMs = time.Since(t0).Milliseconds()
|
||||
if err != nil {
|
||||
rec.OK = false
|
||||
rec.Status = http.StatusBadGateway
|
||||
rec.Err = err.Error()
|
||||
g.writeRec(rec)
|
||||
writeError(w, http.StatusBadGateway, "upstream_error", err.Error())
|
||||
return
|
||||
}
|
||||
rec.OK = true
|
||||
rec.Status = http.StatusOK
|
||||
rec.Prompt = int64(resp.TokenUsage.Prompt)
|
||||
rec.Compl = int64(resp.TokenUsage.Completion)
|
||||
rec.Source = firstSource(cands)
|
||||
g.writeRec(rec)
|
||||
msg := RespMessage{Role: "assistant", Content: resp.Content}
|
||||
if resp.ReasoningContent != "" {
|
||||
msg.ReasoningContent = resp.ReasoningContent
|
||||
@ -246,9 +276,31 @@ func (g *Gateway) singleChat(w http.ResponseWriter, ctx context.Context, cands [
|
||||
writeJSON(w, http.StatusOK, out)
|
||||
}
|
||||
|
||||
func (g *Gateway) streamChat(w http.ResponseWriter, ctx context.Context, cands []*provider.Provider, req *types.ChatRequest, effective string) {
|
||||
// writeRec records a finished request (audit + aggregates).
|
||||
func (g *Gateway) writeRec(rec *Req) {
|
||||
if rec == nil {
|
||||
return
|
||||
}
|
||||
if rec.Time == 0 {
|
||||
rec.Time = time.Now().UnixMilli()
|
||||
}
|
||||
g.stats.Record(*rec)
|
||||
}
|
||||
|
||||
func (g *Gateway) streamChat(w http.ResponseWriter, ctx context.Context, cands []*provider.Provider, req *types.ChatRequest, effective string, rec *Req) {
|
||||
rec.LatMs = 0
|
||||
t0 := time.Now()
|
||||
rec.OK = true
|
||||
rec.Status = http.StatusOK
|
||||
defer func() {
|
||||
rec.LatMs = time.Since(t0).Milliseconds()
|
||||
g.writeRec(rec)
|
||||
}()
|
||||
chunks, err := g.core.Scheduler().ChatStream(ctx, scheduler.FromRegistry(cands), req)
|
||||
if err != nil {
|
||||
rec.OK = false
|
||||
rec.Status = http.StatusBadGateway
|
||||
rec.Err = err.Error()
|
||||
writeError(w, http.StatusBadGateway, "upstream_error", err.Error())
|
||||
return
|
||||
}
|
||||
@ -297,6 +349,7 @@ func (g *Gateway) streamChat(w http.ResponseWriter, ctx context.Context, cands [
|
||||
choice.FinishReason = &stop
|
||||
}
|
||||
chunk.Choices = []ChunkChoice{choice}
|
||||
rec.Compl += int64(len(ck.Content)+len(ck.ReasoningContent)) / 3
|
||||
if !send(chunk) {
|
||||
return
|
||||
}
|
||||
@ -336,11 +389,23 @@ func (g *Gateway) handleImage(w http.ResponseWriter, r *http.Request) {
|
||||
writeError(w, http.StatusServiceUnavailable, "no_provider", "no image source configured")
|
||||
return
|
||||
}
|
||||
done := g.stats.Begin()
|
||||
defer done()
|
||||
rec := &Req{Key: keyID(reqKey(r.Context())), Type: "image", Model: model, Source: firstSource(cands), OK: false}
|
||||
t0 := time.Now()
|
||||
resp, err := g.core.Scheduler().Image(r.Context(), scheduler.FromRegistry(cands), &req)
|
||||
rec.LatMs = time.Since(t0).Milliseconds()
|
||||
if err != nil {
|
||||
rec.Status = http.StatusBadGateway
|
||||
rec.Err = err.Error()
|
||||
g.writeRec(rec)
|
||||
writeError(w, http.StatusBadGateway, "upstream_error", err.Error())
|
||||
return
|
||||
}
|
||||
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,
|
||||
|
||||
@ -5,6 +5,7 @@
|
||||
package gateway
|
||||
|
||||
import (
|
||||
"context"
|
||||
"embed"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
@ -25,6 +26,7 @@ type Gateway struct {
|
||||
core *core.Core
|
||||
apiKeys map[string]bool
|
||||
ui http.Handler
|
||||
stats *Stats
|
||||
}
|
||||
|
||||
func New(c *core.Core, gatewayKeys []string) (*Gateway, error) {
|
||||
@ -42,6 +44,7 @@ func New(c *core.Core, gatewayKeys []string) (*Gateway, error) {
|
||||
core: c,
|
||||
apiKeys: keys,
|
||||
ui: http.FileServer(http.FS(sub)),
|
||||
stats: NewStats(3000),
|
||||
}, nil
|
||||
}
|
||||
|
||||
@ -72,6 +75,8 @@ func (g *Gateway) routes(w http.ResponseWriter, r *http.Request) {
|
||||
g.handleChat(w, r)
|
||||
case r.URL.Path == "/api/status":
|
||||
g.handleStatusAPI(w, r)
|
||||
case r.URL.Path == "/api/stats" || strings.HasPrefix(r.URL.Path, "/api/stats/"):
|
||||
g.handleStatsAPI(w, r)
|
||||
case r.URL.Path == "/login":
|
||||
g.handleLogin(w, r)
|
||||
case r.URL.Path == "/api/login":
|
||||
@ -128,10 +133,31 @@ func (g *Gateway) auth(next http.Handler) http.Handler {
|
||||
http.Redirect(w, r, "/login?continue="+url.QueryEscape(r.URL.Path), http.StatusFound)
|
||||
return
|
||||
}
|
||||
next.ServeHTTP(w, r)
|
||||
next.ServeHTTP(w, r.WithContext(withKey(r.Context(), key)))
|
||||
})
|
||||
}
|
||||
|
||||
// keyCtxKey is the context key carrying the authenticated gateway key.
|
||||
type keyCtxKey struct{}
|
||||
|
||||
func withKey(ctx context.Context, key string) context.Context {
|
||||
return context.WithValue(ctx, keyCtxKey{}, key)
|
||||
}
|
||||
|
||||
// reqKey returns the authenticated gateway key id (masked suffix for display).
|
||||
func reqKey(ctx context.Context) string {
|
||||
k, _ := ctx.Value(keyCtxKey{}).(string)
|
||||
return k
|
||||
}
|
||||
|
||||
// keyID returns a short stable id for a gateway key (last 6 chars).
|
||||
func keyID(k string) string {
|
||||
if len(k) <= 6 {
|
||||
return k
|
||||
}
|
||||
return "***" + k[len(k)-6:]
|
||||
}
|
||||
|
||||
// isAPIPath reports whether the request targets a JSON API endpoint that
|
||||
// should answer 401 instead of redirecting to the login page.
|
||||
func isAPIPath(p string) bool {
|
||||
|
||||
167
internal/gateway/stats.go
Normal file
167
internal/gateway/stats.go
Normal file
@ -0,0 +1,167 @@
|
||||
package gateway
|
||||
|
||||
import (
|
||||
"sync"
|
||||
)
|
||||
|
||||
// Req is one recorded gateway request (audit trail + per-key/per-model stats).
|
||||
type Req struct {
|
||||
Time int64 `json:"time"` // unix milliseconds
|
||||
Key string `json:"key"` // gateway key id
|
||||
Type string `json:"type"` // chat | stream | image
|
||||
Model string `json:"model"` // effective model used upstream
|
||||
// Source provider/source name
|
||||
Source string `json:"source"`
|
||||
// Prompt prompt tokens
|
||||
Prompt int64 `json:"prompt_tokens"`
|
||||
// Compl completion tokens
|
||||
Compl int64 `json:"completion_tokens"`
|
||||
// LatMs total handling time ms
|
||||
LatMs int64 `json:"latency_ms"`
|
||||
OK bool `json:"ok"`
|
||||
// Status http status code
|
||||
Status int `json:"status"`
|
||||
// Err short error message
|
||||
Err string `json:"error,omitempty"`
|
||||
}
|
||||
|
||||
// Stat aggregates counters for one dimension row.
|
||||
type Stat struct {
|
||||
Reqs int64 `json:"reqs"`
|
||||
OK int64 `json:"ok"`
|
||||
Err int64 `json:"err"`
|
||||
Tokens int64 `json:"tokens"`
|
||||
Prompt int64 `json:"prompt_tokens"`
|
||||
Compl int64 `json:"completion_tokens"`
|
||||
LatSum int64 `json:"latency_sum_ms"`
|
||||
LatMax int64 `json:"latency_max_ms"`
|
||||
}
|
||||
|
||||
type agrRow struct {
|
||||
Name string `json:"name"`
|
||||
Stat
|
||||
}
|
||||
|
||||
// Stats collects per-key / per-model / per-source aggregates plus a bounded
|
||||
// ring of raw request records, all guarded by one mutex.
|
||||
type Stats struct {
|
||||
mu sync.Mutex
|
||||
active int64
|
||||
byKey map[string]*Stat
|
||||
byModel map[string]*Stat
|
||||
bySrc map[string]*Stat
|
||||
recs []Req
|
||||
maxRecs int
|
||||
}
|
||||
|
||||
func NewStats(maxRecords int) *Stats {
|
||||
if maxRecords <= 0 {
|
||||
maxRecords = 3000
|
||||
}
|
||||
return &Stats{
|
||||
byKey: map[string]*Stat{},
|
||||
byModel: map[string]*Stat{},
|
||||
bySrc: map[string]*Stat{},
|
||||
maxRecs: maxRecords,
|
||||
}
|
||||
}
|
||||
|
||||
// Begin accounts an in-flight request; the returned func must be called once
|
||||
// the request finished (defer ok).
|
||||
func (s *Stats) Begin() func() {
|
||||
s.mu.Lock()
|
||||
s.active++
|
||||
s.mu.Unlock()
|
||||
return func() {
|
||||
s.mu.Lock()
|
||||
s.active--
|
||||
s.mu.Unlock()
|
||||
}
|
||||
}
|
||||
|
||||
func inc(m map[string]*Stat, name string, r Req) {
|
||||
a := m[name]
|
||||
if a == nil {
|
||||
a = &Stat{}
|
||||
m[name] = a
|
||||
}
|
||||
a.Reqs++
|
||||
if r.OK {
|
||||
a.OK++
|
||||
} else {
|
||||
a.Err++
|
||||
}
|
||||
a.Tokens += r.Prompt + r.Compl
|
||||
a.Prompt += r.Prompt
|
||||
a.Compl += r.Compl
|
||||
a.LatSum += r.LatMs
|
||||
if r.LatMs > a.LatMax {
|
||||
a.LatMax = r.LatMs
|
||||
}
|
||||
}
|
||||
|
||||
// Record appends a finished request to the aggregates and ring buffer.
|
||||
func (s *Stats) Record(r Req) {
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
inc(s.byKey, r.Key, r)
|
||||
inc(s.byModel, r.Model, r)
|
||||
inc(s.bySrc, r.Source, r)
|
||||
s.recs = append(s.recs, r)
|
||||
if len(s.recs) > s.maxRecs {
|
||||
s.recs = s.recs[len(s.recs)-s.maxRecs:]
|
||||
}
|
||||
}
|
||||
|
||||
func rows(m map[string]*Stat) []StatsRow {
|
||||
out := make([]StatsRow, 0, len(m))
|
||||
for k, v := range m {
|
||||
out = append(out, StatsRow{Name: k, Stat: *v})
|
||||
}
|
||||
for i := 1; i < len(out); i++ {
|
||||
for j := i; j > 0 && out[j].Reqs > out[j-1].Reqs; j-- {
|
||||
out[j], out[j-1] = out[j-1], out[j]
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// StatsRow is one aggregated row for the dashboard.
|
||||
type StatsRow struct {
|
||||
Name string `json:"name"`
|
||||
Stat
|
||||
}
|
||||
|
||||
// Snapshot returns the whole dashboard payload.
|
||||
func (s *Stats) Snapshot(limit int) map[string]interface{} {
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
if limit <= 0 {
|
||||
limit = s.maxRecs
|
||||
}
|
||||
start := 0
|
||||
if len(s.recs) > limit {
|
||||
start = len(s.recs) - limit
|
||||
}
|
||||
var total Stat
|
||||
for _, a := range s.byModel {
|
||||
total.Reqs += a.Reqs
|
||||
total.OK += a.OK
|
||||
total.Err += a.Err
|
||||
total.Tokens += a.Tokens
|
||||
total.Prompt += a.Prompt
|
||||
total.Compl += a.Compl
|
||||
total.LatSum += a.LatSum
|
||||
if a.LatMax > total.LatMax {
|
||||
total.LatMax = a.LatMax
|
||||
}
|
||||
}
|
||||
return map[string]interface{}{
|
||||
"active": s.active,
|
||||
"total": total,
|
||||
"by_key": rows(s.byKey),
|
||||
"by_model": rows(s.byModel),
|
||||
"by_source": rows(s.bySrc),
|
||||
"records": append([]Req(nil), s.recs[start:]...),
|
||||
}
|
||||
}
|
||||
@ -35,11 +35,11 @@ header { display:flex; align-items:center; gap:14px; padding:14px 24px; border-b
|
||||
.hd-actions button { background:var(--card2); border:1px solid var(--line); color:var(--muted);
|
||||
padding:6px 12px; border-radius:8px; cursor:pointer; font:inherit; transition:border .15s,color .15s; }
|
||||
.hd-actions button:hover { border-color:var(--accent); color:var(--accent); }
|
||||
nav { display:flex; gap:6px; padding:14px 24px 0; max-width:1240px; margin:0 auto; }
|
||||
nav { display:flex; gap:6px; padding:0; }
|
||||
nav button { background:transparent; border:1px solid transparent; color:var(--muted); padding:7px 16px;
|
||||
cursor:pointer; border-radius:9px; font:inherit; font-weight:500; transition:all .15s; }
|
||||
nav button:hover { color:var(--fg); background:var(--card); }
|
||||
nav button.active { background:var(--card); border-color:var(--line); color:var(--accent); box-shadow:var(--shadow); }
|
||||
nav button:hover { color:var(--fg); background:var(--card2); }
|
||||
nav button.active { background:var(--card2); border-color:var(--line); color:var(--accent); box-shadow:var(--shadow); }
|
||||
main { padding:20px 24px 48px; max-width:1240px; margin:0 auto; }
|
||||
.card { background:var(--card); border:1px solid var(--line); border-radius:14px; padding:20px; margin-bottom:18px;
|
||||
box-shadow:var(--shadow); transition:background .2s; }
|
||||
@ -157,58 +157,90 @@ html[data-theme="dark"] .dropzone.dragover, html[data-theme="dark"] .dropzone:ho
|
||||
justify-content:center; overflow:auto; padding:48px 20px; z-index:50; }
|
||||
.twrap { overflow-x:auto; -webkit-overflow-scrolling:touch; }
|
||||
|
||||
/* ---------- scratch-style canvas & jigsaw blocks ---------- */
|
||||
.sort-canvas { position:relative; min-height:360px; padding:26px 10px 30px; margin:4px 0 10px;
|
||||
/* ---------- scratch-style priority canvas (no numbers, tier lanes) ---------- */
|
||||
.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) 1px,transparent 1px),linear-gradient(90deg,var(--line) 1px,transparent 1px);
|
||||
background-size:26px 26px;
|
||||
border:1px dashed var(--line); border-radius:12px; }
|
||||
.scr-block { position:relative; display:flex; align-items:center; gap:12px; margin:10px 24px;
|
||||
padding:16px 20px 15px 26px; color:#fff; font-weight:700; font-size:13.5px; cursor:grab;
|
||||
user-select:none; touch-action:none; border-radius:12px 12px 7px 7px;
|
||||
box-shadow:0 7px 0 rgba(0,0,0,.13), inset 0 2px 0 rgba(255,255,255,.28),
|
||||
inset 0 -6px 0 rgba(0,0,0,.07); }
|
||||
/* 顶部凸榫:像拼图块上缘伸出的插头 */
|
||||
.scr-block::before { content:''; position:absolute; left:50%; transform:translateX(-50%);
|
||||
top:-8px; width:56px; height:14px; border-radius:9px 9px 4px 4px; background:inherit; }
|
||||
/* 底部凹槽:被下一块凸榫咬合 */
|
||||
.scr-block::after { content:''; position:absolute; left:50%; transform:translateX(-50%);
|
||||
bottom:-6px; width:66px; height:11px; background:var(--card2);
|
||||
border-radius:4px 4px 9px 9px; box-shadow:inset 0 1px 0 var(--line); }
|
||||
/* 链首:没有顶部榫头,块顶圆润 + 大扭扣帽 */
|
||||
.scr-block.cap::before { display:none; }
|
||||
.scr-block.cap { border-radius:16px 16px 8px 8px; box-shadow:0 9px 0 rgba(0,0,0,.14),
|
||||
inset 0 2px 0 rgba(255,255,255,.25), inset 0 -6px 0 rgba(0,0,0,.07); }
|
||||
.scr-block .scr-ico { width:28px; height:28px; border-radius:50%; flex:0 0 auto; display:flex;
|
||||
align-items:center; justify-content:center; font-size:12px; font-weight:800; letter-spacing:.5px;
|
||||
background:rgba(0,0,0,.2); box-shadow:inset 0 2px 3px rgba(0,0,0,.15), 0 1px 0 rgba(255,255,255,.2); }
|
||||
.scr-block .scr-src { font-size:11px; opacity:.92; font-weight:600; padding:3px 11px; border-radius:20px;
|
||||
background:rgba(255,255,255,.22); white-space:nowrap; }
|
||||
.scr-block .scr-name { flex:1; min-width:0; overflow:hidden; text-overflow:ellipsis; white-space:nowrap;
|
||||
font-family:ui-monospace,Menlo,Consolas,monospace; font-size:12.5px; }
|
||||
/* 优先级 = 积木上的参数槽(Scratch 椭圆参数口样式) */
|
||||
.scr-block .scr-prio { width:86px; flex:0 0 auto; text-align:center; color:var(--accent); background:#fff;
|
||||
border:2px solid rgba(255,255,255,.65); border-radius:14px; padding:6px 10px;
|
||||
font:700 13px ui-monospace,Menlo,Consolas,monospace; outline:none; box-shadow:inset 0 1px 4px rgba(0,0,0,.12); }
|
||||
.scr-block .scr-prio:focus { border-color:var(--accent); }
|
||||
.scr-block .scr-grip { flex:0 0 auto; font-size:15px; opacity:.65; letter-spacing:2px; color:rgba(255,255,255,.9);
|
||||
text-shadow:0 1px 2px rgba(0,0,0,.3); }
|
||||
.scr-block.drag-src { opacity:.30; }
|
||||
.scr-ghost { position:fixed; z-index:200; pointer-events:none; margin:0!important; padding-right:0!important;
|
||||
transform:rotate(1.2deg) scale(1.045); filter:drop-shadow(0 16px 20px rgba(0,0,0,.35)); cursor:grabbing; }
|
||||
.scr-ghost .scr-prio, .scr-ghost .scr-grip { visibility:hidden; }
|
||||
.sort-dropline { height:10px; margin:2px 26px; border-radius:7px; background:var(--accent);
|
||||
box-shadow:0 1px 6px var(--accent); }
|
||||
.sortbar { display:flex; align-items:center; gap:12px; margin-bottom:18px; flex-wrap:wrap; }
|
||||
.sortbar .grow { flex:1; }
|
||||
.scr-hint { font-size:12px; color:var(--muted); margin-top:10px; display:flex; gap:12px; flex-wrap:wrap; }
|
||||
.scr-hint span { display:flex; align-items:center; gap:5px; }
|
||||
background-size:28px 28px;
|
||||
border:1px dashed var(--line); border-radius:14px; overflow-x:auto; }
|
||||
.scr-lane { position:relative; display:flex; align-items:stretch; margin:0; }
|
||||
.scr-tier { flex:0 0 auto; width:22px; display:flex; flex-direction:column; align-items:center;
|
||||
justify-content:center; gap:3px; padding:4px 2px; margin-right:10px; }
|
||||
.scr-tier span { display:block; width:9px; height:4px; border-radius:2px; background:var(--accent);
|
||||
opacity:.28; transition:opacity .15s; }
|
||||
.scr-lane.scr-top .scr-tier span { opacity:.95; }
|
||||
.scr-row { flex:1 1 auto; display:flex; align-items:center; padding:2px 18px;
|
||||
border-radius:8px; position:relative; transition:background .15s; }
|
||||
.scr-row.hover { background:rgba(63,110,245,.14); }
|
||||
.scr-blocks { display:flex; flex-wrap:wrap; align-items:center; min-width:0; }
|
||||
/* 块间嵌合:每个块的凸榫插进左侧块的凹槽(平铺同档模型,如积木搭肩) */
|
||||
.scr-block { position:relative; display:flex; align-items:center; gap:10px; padding:12px 14px 12px 20px;
|
||||
color:#fff; font-weight:700; font-size:13px; user-select:none; margin-right:7px;
|
||||
border-radius:11px 11px 7px 7px; box-shadow:0 6px 0 rgba(0,0,0,.13),
|
||||
inset 0 2px 0 rgba(255,255,255,.28), inset 0 -5px 0 rgba(0,0,0,.07); transition:opacity .15s; }
|
||||
/* 左侧凸榫:宽14px 从块左缘伸出,正好填满左侧凹槽(块间距 7px → 榫左半插进凹槽右半仍露 7px 搭肩) */
|
||||
.scr-block::before { content:''; position:absolute; left:-7px; top:50%; transform:translateY(-50%);
|
||||
width:7px; height:30px; border-radius:7px 0 0 7px; background:inherit; z-index:2; }
|
||||
/* 右侧凹槽:露出底层色,供右邻块的凸榫咬合(全圆角,视觉内凹不割裂) */
|
||||
.scr-block::after { content:''; position:absolute; right:-7px; top:50%; transform:translateY(-50%);
|
||||
width:14px; height:30px; background:rgba(0,0,0,.14); border-radius:7px; z-index:1;
|
||||
box-shadow: inset 2px 0 4px rgba(0,0,0,.22); }
|
||||
.scr-row .scr-block:first-child::before { display:none; }
|
||||
.scr-row .scr-block:last-child { margin-right:0; }
|
||||
.scr-row .scr-block:last-child::after { display:none; }
|
||||
/* 左右卡扣生长动画(块新获得左凸榫/右凹槽身份时)—— 延迟到位移完成后再弹出 */
|
||||
.scr-block.grow-l::before { animation:growlL .22s ease-out .24s both; }
|
||||
@keyframes growlL { from { transform: translateY(-50%) scaleX(0); transform-origin:100% 50%; } }
|
||||
.scr-block.grow-r::after { animation:growrR .22s ease-out .24s both; }
|
||||
@keyframes growrR { from { transform: translateY(-50%) scaleX(0); transform-origin:0 50%; } }
|
||||
/* 上下卡扣生长动画:新行首块的凸榫/凹槽从根部弹出(列位置定后) */
|
||||
.scr-block.grow-t .scr-knob { animation:growtT .22s ease-out .24s both; }
|
||||
@keyframes growtT { from { transform: scaleY(0); transform-origin:50% 100%; } }
|
||||
.scr-block.grow-b .scr-slot { animation:growbB .22s ease-out .24s both; }
|
||||
@keyframes growbB { from { transform: scaleY(0); transform-origin:50% 0; } }
|
||||
.scr-block .scr-ico { width:26px; height:26px; border-radius:50%; flex:0 0 auto; display:flex;
|
||||
align-items:center; justify-content:center; font-size:11px; font-weight:800; letter-spacing:.5px;
|
||||
background:rgba(0,0,0,.2); }
|
||||
.scr-block .scr-name { white-space:nowrap; font-family:ui-monospace,Menlo,Consolas,monospace; font-size:12px;
|
||||
max-width:200px; overflow:hidden; text-overflow:ellipsis; }
|
||||
.scr-block .scr-grip { flex:0 0 auto; display:flex; flex-direction:column; gap:2px; padding:6px 4px;
|
||||
margin-left:2px; border-radius:6px; cursor:grab; background:rgba(255,255,255,.18);
|
||||
box-shadow:inset 0 1px 2px rgba(0,0,0,.18); transition:background .12s; touch-action:none; }
|
||||
.scr-block .scr-grip:hover { background:rgba(255,255,255,.32); }
|
||||
.scr-block .scr-grip i { width:12px; height:2px; border-radius:1px; background:rgba(255,255,255,.9);
|
||||
display:block; }
|
||||
/* 上下嵌合:仅行首块带上下卡扣(行间嵌合),位置相对块左缘固定偏移,
|
||||
各行首块因左对齐而卡扣精准对齐;同行的其余块只做左右嵌合 */
|
||||
.scr-block .scr-knob { position:absolute; left:24px; top:-11px;
|
||||
width:30px; height:12px; border-radius:8px 8px 4px 4px; background:inherit; z-index:2; }
|
||||
.scr-block .scr-slot { position:absolute; left:24px; bottom:-9px;
|
||||
width:30px; height:11px; background:rgba(0,0,0,.14); border-radius:4px 4px 12px 12px; z-index:1;
|
||||
box-shadow: inset 0 3px 5px rgba(0,0,0,.18); }
|
||||
.scr-lane.scr-top .scr-block .scr-knob,
|
||||
.scr-lane.scr-last .scr-block .scr-slot { display:none; }
|
||||
.scr-block.drag-src { opacity:.28; }
|
||||
.scr-block.drag-src .scr-knob, .scr-block.drag-src .scr-slot { visibility:hidden; }
|
||||
.scr-ghost { position:fixed; z-index:200; pointer-events:none; transform:rotate(1.5deg) scale(1.05);
|
||||
filter:drop-shadow(0 16px 20px rgba(0,0,0,.35)); cursor:grabbing; }
|
||||
.scr-ghost .scr-knob, .scr-ghost .scr-slot { display:none; }
|
||||
.scr-gap { height:7px; margin:-2px 0 0; border-radius:8px; border:2px dashed transparent; transition:all .12s;
|
||||
display:flex; align-items:center; justify-content:center; color:var(--muted); font-size:11px;
|
||||
letter-spacing:2px; }
|
||||
.scr-gap.hover { border-color:var(--accent); background:rgba(63,110,245,.10);
|
||||
box-shadow:0 0 0 3px rgba(63,110,245,.12); color:var(--accent); }
|
||||
.scr-gap.hover::after { content:'+'; font-size:14px; font-weight:800; }
|
||||
.scr-gap.drophere { border-color:var(--accent); background:var(--accent); color:#fff; height:16px; }
|
||||
.scr-gap.drophere::after { content:'+'; font-size:16px; font-weight:800; }
|
||||
.scr-blockline { position:fixed; width:3px; border-radius:2px; background:var(--accent);
|
||||
box-shadow:0 0 8px var(--accent); z-index:150; pointer-events:none; }
|
||||
.scr-hint { font-size:12px; color:var(--muted); margin-top:12px; display:flex; gap:16px; flex-wrap:wrap; }
|
||||
.scr-hint span { display:flex; align-items:center; gap:6px; }
|
||||
.scr-hint i { font-style:normal; width:10px; height:10px; border-radius:3px; display:inline-block; }
|
||||
|
||||
/* ---------- responsive / narrow screens ---------- */
|
||||
@media (max-width: 900px) {
|
||||
header { padding:12px 16px; }
|
||||
nav { padding:12px 16px 0; overflow-x:auto; }
|
||||
nav { overflow-x:auto; max-width:1240px; margin:0 auto; }
|
||||
nav button { padding:7px 12px; white-space:nowrap; }
|
||||
main { padding:16px 16px 40px; }
|
||||
.card { padding:16px; }
|
||||
@ -255,6 +287,13 @@ html[data-theme="dark"] .dropzone.dragover, html[data-theme="dark"] .dropzone:ho
|
||||
<div class="logo">M</div>
|
||||
<div><h1>ModelRouter</h1><div class="sub" data-i="tagline">统一 LLM 网关</div></div>
|
||||
</div>
|
||||
<nav>
|
||||
<button data-tab="status" class="active" data-i="navStatus">状态</button>
|
||||
<button data-tab="chat" data-i="navChat">Chat 测试</button>
|
||||
<button data-tab="sort" data-i="navSort">优先级</button>
|
||||
<button data-tab="sources" data-i="navSources">源</button>
|
||||
<button data-tab="adapters" data-i="navAdapters">适配器</button>
|
||||
</nav>
|
||||
<div class="spacer"></div>
|
||||
<div class="hd-actions">
|
||||
<button id="btn-theme" title="theme">◐</button>
|
||||
@ -262,13 +301,6 @@ html[data-theme="dark"] .dropzone.dragover, html[data-theme="dark"] .dropzone:ho
|
||||
<button class="ghost" id="btn-logout" data-i="logout">退出登录</button>
|
||||
</div>
|
||||
</header>
|
||||
<nav>
|
||||
<button data-tab="status" class="active" data-i="navStatus">状态</button>
|
||||
<button data-tab="chat" data-i="navChat">Chat 测试</button>
|
||||
<button data-tab="sort" data-i="navSort">优先级</button>
|
||||
<button data-tab="sources" data-i="navSources">源</button>
|
||||
<button data-tab="adapters" data-i="navAdapters">适配器</button>
|
||||
</nav>
|
||||
<main>
|
||||
<div id="tab-status"></div>
|
||||
<div id="tab-chat" class="hidden"></div>
|
||||
@ -308,7 +340,7 @@ const STR = {
|
||||
toastDelOk:'已删除', confirmDelSrc:'确定删除源 %s 吗?', confirmDelAdp:'确定删除适配器 %s 吗?',
|
||||
u:'用户', a:'助手', at:'助手[思考]', aEmpty:'(空回复)', aErr:'请求失败: %s', cErr:'错误: %s',
|
||||
chatMeta:'模型=%s · 耗时 %s ms · %d 字符', connPinned:'# 固定到模型: %s (源 %s)', connAuto:'# 模型名 AUTO(自动选择可用源)',
|
||||
sortTitle:'积木排序:在画布上拖拽模型积木', sortHint:'拼图积木从上到下排列 = 优先级从高到低。拖动积木把它拼到链上任意位置;每个积木上的数字(priority)可直接编辑——多个积木可设相同值,此时按链上位置(上者先用)作为先后顺序。生图模型(kind=image)不参与链排序。',
|
||||
sortTitle:'画布排序:拖拽积木配置模型优先级', sortHint:'每行 = 一个优先级档位,行从上到下优先级递减;同一行的模型并排,视为同优先级。按住积木右侧 ⠿ 把手拖动:拖到行内 = 放入该档位(或调整同档顺序),拖到行与行之间的缝隙 = 提升/降低到新档位。生图模型(kind=image)不参与排序。', sortDragGrip:'拖拽前须按住把手',
|
||||
sortSave:'保存排序', sortReset:'重置', sortSaved:'排序已保存并热重载', sortNoChange:'无变更',
|
||||
sortSource:'源', sortPrio:'优先级 %s', sortEmpty:'(该源暂无模型)',
|
||||
},
|
||||
@ -340,7 +372,7 @@ const STR = {
|
||||
toastDelOk:'Deleted', confirmDelSrc:'Delete source %s?', confirmDelAdp:'Delete adapter %s?',
|
||||
u:'You:', a:'Assistant:', at:'Assistant [thinking]', aEmpty:'(empty)', aErr:'Request failed: %s',
|
||||
cErr:'Error: %s', chatMeta:'Model=%s · %s ms · %d chars', connPinned:'# Pinned to model: %s (source %s)', connAuto:'# Model "AUTO" picks healthy source by priority',
|
||||
sortTitle:'Block sorting: drag model blocks on the canvas', sortHint:'Blocks chain top→bottom = priority high→low. Drag a block to snap it anywhere in the chain; the number on each block (priority) is editable — blocks may share the same value, then chain order (top first) breaks ties. Image models (kind=image) stay out of the chain.',
|
||||
sortTitle:'Canvas sorting: drag blocks to set model priority', sortHint:'Each row = one priority tier, rows go high→low; models on the same row sit side by side and share that priority. Grab the ⠿ handle on the right of a block to drag: drop into a row = join that tier (or reorder within it), drop into the gap between rows = move up/down a tier. Image models (kind=image) stay out.', sortDragGrip:'grab the handle to drag',
|
||||
sortSave:'Save order', sortReset:'Reset', sortSaved:'Order saved & hot-reloaded', sortNoChange:'No changes',
|
||||
sortSource:'source', sortPrio:'priority %s', sortEmpty:'(no models in this source)',
|
||||
}
|
||||
@ -789,146 +821,361 @@ function srcColor(name) {
|
||||
return SORT_COLORS[h % SORT_COLORS.length];
|
||||
}
|
||||
function srcShort(name) { return (name || '?').slice(0, 2).toUpperCase(); }
|
||||
const sortState = { items: [], origin: null, drag: null };
|
||||
const sortState = { lanes: [], origin: null, drag: null };
|
||||
async function renderSort() {
|
||||
const j = await api('/api/sources');
|
||||
sortState.items = [];
|
||||
const map = new Map();
|
||||
j.sources.forEach(s => (s.models || []).forEach(m => {
|
||||
if (m.kind === 'image') return; // image models never share the chat chain
|
||||
sortState.items.push({ src: s.name, id: m.id, prio: m.priority || 0 });
|
||||
const p = m.priority || 0;
|
||||
if (!map.has(p)) map.set(p, []);
|
||||
map.get(p).push({ src: s.name, id: m.id });
|
||||
}));
|
||||
sortState.origin = JSON.stringify(sortState.items);
|
||||
sortState.lanes = [...map.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);
|
||||
return { prio, models };
|
||||
});
|
||||
sortState.origin = JSON.stringify(sortState.lanes);
|
||||
$('#tab-sort').innerHTML = `
|
||||
<div class="card"><h2><span>${t('sortTitle')}</span><span class="grow"></span>
|
||||
<button class="ghost small" onclick="sortReset()">${t('sortReset')}</button>
|
||||
<button class="small" onclick="saveSort()">${t('sortSave')}</button></h2>
|
||||
<div class="muted" style="margin-bottom:16px">${t('sortHint')}</div>
|
||||
<div class="sort-canvas" id="scr-canvas"></div>
|
||||
<div class="scr-hint">
|
||||
${sortState.items.map((it, i) => { const c = srcColor(it.src);
|
||||
${sortState.lanes.map(l => l.models[0]).map(it => { const c = srcColor(it.src);
|
||||
return `<span><i style="background:linear-gradient(135deg,${c[0]},${c[1]})"></i>${esc(it.src)}</span>`; })
|
||||
.filter((v, i, a) => a.indexOf(v) === i).join('') || ''}
|
||||
</div>
|
||||
</div>`;
|
||||
paintSort();
|
||||
}
|
||||
function scrBlockHtml(it, isCap) {
|
||||
function scrBlockHtml(it, isFirst) {
|
||||
const c = srcColor(it.src);
|
||||
return `
|
||||
<div class="scr-block ${isCap ? 'cap' : ''}" data-key="${escAttr(it.src + '|' + it.id)}"
|
||||
<div class="scr-block" data-key="${escAttr(it.src + '|' + it.id)}"
|
||||
style="background:linear-gradient(135deg,${c[0]},${c[1]})">
|
||||
${isFirst ? '<i class="scr-knob"></i><i class="scr-slot"></i>' : ''}
|
||||
<span class="scr-ico">${esc(srcShort(it.src))}</span>
|
||||
<span class="scr-src">${esc(it.src)}</span>
|
||||
<span class="scr-name">${esc(it.id)}</span>
|
||||
<input class="scr-prio" type="number" value="${it.prio}" min="-9999" max="9999" spellcheck="false">
|
||||
<span class="scr-grip">⠿</span>
|
||||
<span class="scr-grip"><i></i><i></i><i></i></span>
|
||||
</div>`;
|
||||
}
|
||||
function paintSort() {
|
||||
function paintSort(affected) {
|
||||
const cv = $('#scr-canvas');
|
||||
if (!cv) return;
|
||||
const srcIdx = sortState.items.map((it, i) => i);
|
||||
const html = [];
|
||||
for (let i = 0; i <= sortState.items.length; i++) {
|
||||
html.push(`<div class="sort-dropline" data-line="${i}" style="display:none"></div>`);
|
||||
if (i < sortState.items.length) html.push(scrBlockHtml(sortState.items[i], i === 0));
|
||||
}
|
||||
cv.innerHTML = html.join('');
|
||||
cv.querySelectorAll('.scr-prio').forEach(inp => {
|
||||
inp.addEventListener('input', () => {
|
||||
const blk = inp.closest('.scr-block');
|
||||
const idx = sortState.items.findIndex(x => x.src + '|' + x.id === blk.dataset.key);
|
||||
if (idx >= 0) { const v = parseInt(inp.value, 10); sortState.items[idx].prio = isFinite(v) ? v : 0; }
|
||||
});
|
||||
inp.addEventListener('pointerdown', e => e.stopPropagation());
|
||||
// compute the next lane-leaders so we can retract the outgoing ones first
|
||||
const nextFirst = new Set();
|
||||
sortState.lanes.forEach(lane => {
|
||||
if (lane.models.length) nextFirst.add(lane.models[0].src + '|' + lane.models[0].id);
|
||||
});
|
||||
cv.querySelectorAll('.scr-block').forEach(block => {
|
||||
block.addEventListener('pointerdown', e => {
|
||||
if (e.target.classList.contains('scr-prio')) return;
|
||||
// outgoing leaders: currently first in their row but no longer first post-move
|
||||
const outgoing = [];
|
||||
cv.querySelectorAll('.scr-row').forEach(row => {
|
||||
const blk = row.querySelector('.scr-block');
|
||||
if (blk && !nextFirst.has(blk.dataset.key)) outgoing.push(blk);
|
||||
});
|
||||
if (outgoing.length) {
|
||||
// clone old tenons/sockets as detached overlays, shrink them away, and
|
||||
// rebuild the DOM immediately so nothing stalls
|
||||
outgoing.forEach(b => {
|
||||
b.querySelectorAll('.scr-knob, .scr-slot').forEach(el => {
|
||||
const rect = el.getBoundingClientRect();
|
||||
const ghost = el.cloneNode(true);
|
||||
const cs = getComputedStyle(el);
|
||||
ghost.style.cssText = 'position:fixed;left:' + rect.left + 'px;top:' + rect.top +
|
||||
'px;width:' + rect.width + 'px;height:' + rect.height + 'px;margin:0;' +
|
||||
'background:' + cs.background + ';border-radius:' + cs.borderRadius +
|
||||
';box-shadow:' + cs.boxShadow + ';pointer-events:none;';
|
||||
document.body.appendChild(ghost);
|
||||
ghost.animate(
|
||||
[{ transform: 'scaleY(1)', opacity: 1 }, { transform: 'scaleY(.4)', opacity: 0 }],
|
||||
{ duration: 220, easing: 'ease-in' }).finished.then(() => ghost.remove());
|
||||
});
|
||||
});
|
||||
}
|
||||
paintSortNow(affected);
|
||||
}
|
||||
function paintSortNow(affected) {
|
||||
const cv = $('#scr-canvas');
|
||||
if (!cv) return;
|
||||
// FLIP: capture old positions of lanes and blocks before repaint
|
||||
const prev = new Map();
|
||||
const prevFirst = new Set(); // keys that used to be the first block of their lane
|
||||
const prevLast = new Set(); // keys that used to be the last block of their lane
|
||||
cv.querySelectorAll('.scr-row').forEach(row => {
|
||||
const blks = row.querySelectorAll('.scr-block');
|
||||
if (blks[0]) prevFirst.add(blks[0].dataset.key);
|
||||
if (blks.length) prevLast.add(blks[blks.length - 1].dataset.key);
|
||||
});
|
||||
cv.querySelectorAll('.scr-lane').forEach(l => prev.set('lane:' + l.dataset.lane, l.getBoundingClientRect()));
|
||||
cv.querySelectorAll('.scr-block').forEach(b => prev.set('blk:' + b.dataset.key, b.getBoundingClientRect()));
|
||||
const html = [];
|
||||
sortState.lanes.forEach((lane, li) => {
|
||||
const n = sortState.lanes.length - li;
|
||||
const cls = 'scr-lane' + (li === 0 ? ' scr-top' : '') + (li === sortState.lanes.length - 1 ? ' scr-last' : '');
|
||||
html.push(`<div class="scr-gap" data-gap="${li}"></div>`);
|
||||
html.push(`<div class="${cls}" data-lane="${li}">
|
||||
<div class="scr-tier">${Array.from({ length: n }, () => '<span></span>').join('')}</div>
|
||||
<div class="scr-row">${lane.models.map((m, i) => scrBlockHtml(m, i === 0)).join('')}</div>
|
||||
</div>`);
|
||||
});
|
||||
html.push(`<div class="scr-gap" data-gap="${sortState.lanes.length}"></div>`);
|
||||
cv.innerHTML = html.join('');
|
||||
// FLIP: animate lanes near the affected area (and follow-up rows sliding up);
|
||||
// only animate lanes listed in `affected` (or all movable ones when a lane
|
||||
// vanishes, since every row below shifts).
|
||||
requestAnimationFrame(() => {
|
||||
const animLanes = new Set(affected === undefined ? [] : affected);
|
||||
if (affected === undefined) {
|
||||
// initial render / reset: no animation at all
|
||||
return;
|
||||
}
|
||||
prev.forEach((r0, key) => {
|
||||
const isLane = key.startsWith('lane:');
|
||||
const el = isLane
|
||||
? cv.querySelector(`.scr-lane[data-lane="${key.slice(5)}"]`)
|
||||
: cv.querySelector(`.scr-block[data-key="${CSS.escape(key.slice(4))}"]`);
|
||||
if (!el) return;
|
||||
// blocks/slide-up for any lane below a removed lane must still animate
|
||||
let laneIdx = isLane ? +key.slice(5) : null;
|
||||
if (laneIdx === null) {
|
||||
const lane = el.closest('.scr-lane');
|
||||
if (lane) laneIdx = +lane.dataset.lane;
|
||||
}
|
||||
if (laneIdx !== null && animLanes.has(laneIdx)) {
|
||||
const r1 = el.getBoundingClientRect();
|
||||
const dx = r0.left - r1.left, dy = r0.top - r1.top;
|
||||
if (dx || dy) {
|
||||
el.animate([{ transform: `translate(${dx}px,${dy}px)` }, { transform: 'none' }],
|
||||
{ duration: 240, easing: 'ease-out' });
|
||||
}
|
||||
}
|
||||
});
|
||||
// grow-in animations, scheduled AFTER the 240ms slide so the block settles
|
||||
// into place first, then its knobs pop out:
|
||||
// grow-t/b : brand-new lane-leader gets its top knob / bottom socket
|
||||
// grow-l : block leaves the leader slot, now gains a left tenon
|
||||
// grow-r : block reaches the lane tail, now gains the right notch end
|
||||
cv.querySelectorAll('.scr-lane .scr-block').forEach(b => {
|
||||
const lane = b.closest('.scr-lane');
|
||||
if (!lane || !animLanes.has(+lane.dataset.lane)) return;
|
||||
const key = b.dataset.key;
|
||||
const blks = b.parentElement.querySelectorAll('.scr-block');
|
||||
const isFirst = blks[0] === b;
|
||||
const isLast = blks[blks.length - 1] === b;
|
||||
if (isFirst && !prevFirst.has(key)) {
|
||||
b.classList.add('grow-t'); b.classList.add('grow-b');
|
||||
// the vertical joint is shared across rows: re-etch the socket of the
|
||||
// leader above (receives this knob) and the knob of the leader below
|
||||
// (rises into this socket), so the whole chain animates together
|
||||
const li = +lane.dataset.lane;
|
||||
const peer = cv.querySelector(`.scr-lane[data-lane="${li - 1}"] .scr-block`);
|
||||
if (peer) peer.classList.add('grow-b');
|
||||
const peer2 = cv.querySelector(`.scr-lane[data-lane="${li + 1}"] .scr-block`);
|
||||
if (peer2) peer2.classList.add('grow-t');
|
||||
}
|
||||
if (!isFirst && prevFirst.has(key)) b.classList.add('grow-l');
|
||||
if (!isLast && prevLast.has(key)) b.classList.add('grow-r');
|
||||
});
|
||||
});
|
||||
cv.querySelectorAll('.scr-grip').forEach(grip => {
|
||||
grip.addEventListener('pointerdown', e => {
|
||||
const block = grip.closest('.scr-block');
|
||||
if (!block) return;
|
||||
e.preventDefault();
|
||||
const idx = sortState.items.findIndex(x => x.src + '|' + x.id === block.dataset.key);
|
||||
if (idx < 0) return;
|
||||
sortState.drag = { idx, key: block.dataset.key };
|
||||
e.stopPropagation();
|
||||
const key = block.dataset.key;
|
||||
const li = cv.querySelector(`.scr-lane .scr-block[data-key="${CSS.escape(key)}"]`).closest('.scr-lane').dataset.lane;
|
||||
sortState.drag = { key, li: +li, px: e.clientX, py: e.clientY };
|
||||
block.classList.add('drag-src');
|
||||
makeGhost(block, e.clientX, e.clientY);
|
||||
const move = ev => onSortMove(ev);
|
||||
const mv = ev => onSortMove(ev);
|
||||
const up = ev => {
|
||||
block.removeEventListener('pointermove', move);
|
||||
block.removeEventListener('pointerup', up);
|
||||
block.removeEventListener('pointercancel', up);
|
||||
window.removeEventListener('pointermove', mv);
|
||||
window.removeEventListener('pointerup', up);
|
||||
window.removeEventListener('pointercancel', up);
|
||||
onSortUp(ev);
|
||||
};
|
||||
block.addEventListener('pointermove', move);
|
||||
block.addEventListener('pointerup', up);
|
||||
block.addEventListener('pointercancel', up);
|
||||
window.addEventListener('pointermove', mv);
|
||||
window.addEventListener('pointerup', up);
|
||||
window.addEventListener('pointercancel', up);
|
||||
try { block.setPointerCapture(e.pointerId); } catch (err) {}
|
||||
});
|
||||
});
|
||||
}
|
||||
function onSortMove(e) {
|
||||
if (!sortState.drag) return;
|
||||
const d = sortState.drag;
|
||||
if (!d) return;
|
||||
const cv = document.getElementById('scr-canvas');
|
||||
if (!cv) return;
|
||||
let toLine = sortState.items.length;
|
||||
cv.querySelectorAll('.scr-block').forEach(b => {
|
||||
const idx = sortState.items.findIndex(x => x.src + '|' + x.id === b.dataset.key);
|
||||
if (idx < 0 || idx === sortState.drag.idx) return;
|
||||
const r = b.getBoundingClientRect();
|
||||
if (e.clientY < r.top + r.height / 2) { if (idx < toLine) toLine = idx; }
|
||||
else { if (idx + 1 > toLine) toLine = idx + 1; }
|
||||
});
|
||||
cv.querySelectorAll('.sort-dropline').forEach(l => l.style.display = 'none');
|
||||
const line = cv.querySelector(`[data-line="${Math.max(0, Math.min(toLine, sortState.items.length))}"]`);
|
||||
if (line) line.style.display = 'block';
|
||||
if (gEl) { gEl.style.left = (e.clientX + 12) + 'px'; gEl.style.top = (e.clientY - 40) + 'px'; }
|
||||
const laneEls = [...cv.querySelectorAll('.scr-lane')];
|
||||
const gapEls = [...cv.querySelectorAll('.scr-gap')];
|
||||
const y = e.clientY, x = e.clientX;
|
||||
const laneRect = laneEls.map(el => el.getBoundingClientRect());
|
||||
const gapRect = gapEls.map(el => el.getBoundingClientRect());
|
||||
// 1) drop in a gap -> new priority lane between rows
|
||||
// narrow hit zone: only the middle band of the gap counts, so a pointer
|
||||
// sweeping near a row edge stays committed to that row
|
||||
let gapIdx = -1;
|
||||
for (let i = 0; i < gapRect.length; i++) {
|
||||
const r = gapRect[i];
|
||||
const midY = (r.top + r.bottom) / 2;
|
||||
if (Math.abs(y - midY) <= 5 && x >= r.left && x <= r.right) { gapIdx = +gapEls[i].dataset.gap; break; }
|
||||
}
|
||||
// 2) drop inside a lane -> reorder within that lane / move between lanes
|
||||
let li = -1;
|
||||
if (gapIdx < 0) {
|
||||
for (let i = 0; i < laneRect.length; i++) {
|
||||
const r = laneRect[i];
|
||||
if (y >= r.top && y <= r.bottom) { li = +laneEls[i].dataset.lane; break; }
|
||||
}
|
||||
}
|
||||
// 3) else: nearest lane edge (top/bottom half of canvas) -> gap around it
|
||||
if (gapIdx < 0 && li < 0 && laneRect.length) {
|
||||
const first = laneRect[0], last = laneRect[laneRect.length - 1];
|
||||
if (y < first.top) gapIdx = 0;
|
||||
else if (y > last.bottom) gapIdx = laneRect.length;
|
||||
}
|
||||
// 4) outside the canvas -> cancel target, block snaps back
|
||||
const cvr = cv.getBoundingClientRect();
|
||||
if (y < cvr.top || y > cvr.bottom) { gapIdx = -1; li = -1; }
|
||||
// clear previous hover state
|
||||
cv.querySelectorAll('.scr-gap, .scr-lane').forEach(el => { el.classList.remove('hover', 'drophere'); });
|
||||
const hline = $('#scr-blockline'); if (hline) hline.remove();
|
||||
if (gapIdx >= 0) {
|
||||
const gap = cv.querySelector(`.scr-gap[data-gap="${gapIdx}"]`);
|
||||
if (gap) gap.classList.add('drophere');
|
||||
d.gap = gapIdx; d.li = undefined; d.ins = undefined;
|
||||
} else if (li >= 0) {
|
||||
const laneEl = cv.querySelector(`.scr-lane[data-lane="${li}"]`);
|
||||
laneEl.classList.add('hover');
|
||||
const rowEl = laneEl.querySelector('.scr-row');
|
||||
const blocks = rowEl.querySelectorAll('.scr-block');
|
||||
let ins = 0; // insertion index inside lane
|
||||
if (blocks.length) {
|
||||
const targets = [...blocks].filter(b => b.dataset.key !== d.key);
|
||||
// find insertion point by x position among visible blocks
|
||||
let idx = 0;
|
||||
for (const b of targets) {
|
||||
const r = b.getBoundingClientRect();
|
||||
if (x > r.left + r.width / 2) idx++; else break;
|
||||
}
|
||||
ins = idx;
|
||||
}
|
||||
d.li = li;
|
||||
d.ins = ins;
|
||||
d.gap = undefined;
|
||||
// vertical guide line at insertion point
|
||||
const before = [...blocks][ins]; const after = [...blocks][ins - 1];
|
||||
const g = document.createElement('div');
|
||||
g.id = 'scr-blockline'; g.className = 'scr-blockline';
|
||||
let gx;
|
||||
if (before) gx = before.getBoundingClientRect().left - 6;
|
||||
else if (after) gx = after.getBoundingClientRect().right + 6;
|
||||
else { const wr = rowEl.getBoundingClientRect(); gx = wr.left; }
|
||||
g.style.left = gx + 'px';
|
||||
const laneR = laneEl.getBoundingClientRect();
|
||||
g.style.top = laneR.top + 'px'; g.style.height = laneR.height + 'px';
|
||||
document.body.appendChild(g);
|
||||
} else if (typeof d.ins === 'number') {
|
||||
// lost between rows: drop the previous target so the block snaps back
|
||||
d.li = undefined; d.ins = undefined; d.gap = undefined;
|
||||
}
|
||||
d.px = x; d.py = y;
|
||||
placeGhostXY(x, y);
|
||||
}
|
||||
function onSortUp(e) {
|
||||
const d = sortState.drag;
|
||||
if (!d) return;
|
||||
const line = document.querySelector('#scr-canvas .sort-dropline[style*="display: block"]');
|
||||
let to = d.idx;
|
||||
if (line) {
|
||||
to = +line.dataset.line;
|
||||
if (d.idx < to) to -= 1;
|
||||
to = Math.max(0, Math.min(to, sortState.items.length - 1));
|
||||
const line = document.getElementById('scr-blockline'); if (line) line.remove();
|
||||
const cv = document.getElementById('scr-canvas');
|
||||
cv.querySelectorAll('.scr-gap, .scr-lane').forEach(el => el.classList.remove('hover', 'drophere'));
|
||||
const item = findLaneItem(d.key);
|
||||
if (!item) { removeGhost(); sortState.drag = null; return; }
|
||||
let minLi = item.li, maxLi = item.li;
|
||||
if (d.gap !== undefined) {
|
||||
// move to a new lane positioned at that gap
|
||||
sortState.lanes[item.li].models.splice(item.idx, 1);
|
||||
let dst = d.gap;
|
||||
if (!sortState.lanes[item.li].models.length) {
|
||||
sortState.lanes.splice(item.li, 1);
|
||||
if (item.li < dst) dst -= 1;
|
||||
}
|
||||
sortState.lanes.splice(dst, 0, { models: [{ src: item.src, id: item.id }] });
|
||||
minLi = Math.min(item.li, dst);
|
||||
maxLi = Math.max(item.li, dst);
|
||||
} else if (typeof d.ins === 'number') {
|
||||
// reorder within lane li
|
||||
const srcL = item.li;
|
||||
const m = sortState.lanes[srcL].models.splice(item.idx, 1)[0];
|
||||
let tgt = (d.li === undefined) ? srcL : d.li;
|
||||
if (!sortState.lanes[srcL].models.length && srcL !== tgt) {
|
||||
// source lane became empty: remove it, target index shifts accordingly
|
||||
sortState.lanes.splice(srcL, 1);
|
||||
if (srcL < tgt) tgt -= 1;
|
||||
}
|
||||
sortState.lanes[tgt].models.splice(d.ins, 0, m);
|
||||
minLi = Math.min(srcL, tgt);
|
||||
maxLi = Math.max(srcL, tgt);
|
||||
}
|
||||
const [moved] = sortState.items.splice(d.idx, 1);
|
||||
sortState.items.splice(to, 0, moved);
|
||||
// affected lane range: from the earliest changed lane to the very end
|
||||
// (rows below an emptied lane slide up to re-seal the chain)
|
||||
const affected = [];
|
||||
for (let i = minLi; i < sortState.lanes.length; i++) affected.push(i);
|
||||
removeGhost();
|
||||
sortState.drag = null;
|
||||
paintSort();
|
||||
paintSort(affected);
|
||||
}
|
||||
function findLaneItem(key) {
|
||||
for (let i = 0; i < sortState.lanes.length; i++) {
|
||||
const idx = sortState.lanes[i].models.findIndex(m => m.src + '|' + m.id === key);
|
||||
if (idx >= 0) return { li: i, idx, src: sortState.lanes[i].models[idx].src, id: sortState.lanes[i].models[idx].id };
|
||||
}
|
||||
return null;
|
||||
}
|
||||
let gEl = null;
|
||||
function makeGhost(block, x, y) {
|
||||
removeGhost();
|
||||
gEl = block.cloneNode(true);
|
||||
gEl.classList.remove('drag-src');
|
||||
gEl.classList.add('scr-ghost');
|
||||
gEl.style.position = 'fixed';
|
||||
gEl.style.left = (x + 12) + 'px';
|
||||
gEl.style.top = (y - 40) + 'px';
|
||||
gEl.style.width = block.offsetWidth + 'px';
|
||||
gEl.style.margin = '0';
|
||||
gEl.style.left = (x - 20) + 'px';
|
||||
gEl.style.top = (y - 20) + 'px';
|
||||
gEl.style.pointerEvents = 'none';
|
||||
document.body.appendChild(gEl);
|
||||
const br = block.getBoundingClientRect();
|
||||
gEl.dataset.dx = x - br.left; gEl.dataset.dy = y - br.top;
|
||||
}
|
||||
function placeGhostXY(x, y) {
|
||||
if (!gEl) return;
|
||||
gEl.style.left = (x - gEl.dataset.dx) + 'px';
|
||||
gEl.style.top = (y - gEl.dataset.dy) + 'px';
|
||||
}
|
||||
function removeGhost() { if (gEl) { gEl.remove(); gEl = null; } }
|
||||
function sortReset() {
|
||||
if (sortState.origin) sortState.items = JSON.parse(sortState.origin);
|
||||
if (sortState.origin) sortState.lanes = JSON.parse(sortState.origin);
|
||||
sortState.drag = null; removeGhost();
|
||||
paintSort();
|
||||
const all = [];
|
||||
for (let i = 0; i < sortState.lanes.length; i++) all.push(i);
|
||||
paintSort(all);
|
||||
}
|
||||
async function saveSort() {
|
||||
const snap = await api('/api/sources');
|
||||
const byName = {}; snap.sources.forEach(x => byName[x.name] = x);
|
||||
const groups = {};
|
||||
sortState.items.forEach(it => (groups[it.src] = groups[it.src] || []).push(it));
|
||||
sortState.lanes.forEach((lane, li) => lane.models.forEach(it => {
|
||||
(groups[it.src] = groups[it.src] || []).push({ id: it.id, priority: (sortState.lanes.length - li) * 10 });
|
||||
}));
|
||||
let dirty = 0;
|
||||
for (const name of Object.keys(groups)) {
|
||||
const base = byName[name];
|
||||
if (!base) continue;
|
||||
const seen = new Set();
|
||||
const ordered = groups[name].filter(it => { if (seen.has(it.id)) return false; seen.add(it.id); return true; })
|
||||
.map(it => ({ id: it.id, priority: it.prio, kind: 'chat' }));
|
||||
.map(it => ({ id: it.id, priority: it.priority, kind: 'chat' }));
|
||||
(base.models || []).forEach(x => {
|
||||
if (seen.has(x.id)) return;
|
||||
ordered.push({ id: x.id, priority: x.priority || 0, kind: x.kind === 'image' ? 'image' : 'chat' });
|
||||
@ -940,7 +1187,7 @@ async function saveSort() {
|
||||
}
|
||||
}
|
||||
toast(dirty ? t('sortSaved') : t('sortNoChange'));
|
||||
sortState.origin = JSON.stringify(sortState.items);
|
||||
sortState.origin = JSON.stringify(sortState.lanes);
|
||||
}
|
||||
|
||||
/* ---------- adapters tab ---------- */
|
||||
|
||||
Reference in New Issue
Block a user