mirror of
https://github.com/mudler/LocalAI.git
synced 2026-08-02 11:30:44 -04:00
fix(router): production-ready request router + auto-size batch for embedding/rerank (#10104)
* fix(router): score classifier production-readiness Conversation trimming runs through the classifier model's chat template and trims by exact token count, sized to the model's n_batch which is now scaled to context so long probes can't crash the backend. Missing chat_message templates are a hard error at router build time. Router- facing factories (Embedder/Scorer/Reranker/TokenCounter) re-resolve ModelConfig per call so a model installed post-startup doesn't bind a stub Backend="" config and silently fall into the loader's auto- iterate path. New 'vector_store' backend trace recorded inside localVectorStore on every Search/Insert — including the backend-load-failure path that previously vanished into an xlog.Warn — with outcome tagging (hit/miss/empty_store/backend_load_error/find_error/insert_error/ok). Companion cleanup drops misleading similarity:0 and input_tokens_count:0 from non-hit and text-mode traces. Gallery local-store-development aliases to 'local-store' so the master image satisfies pkg/model.LocalStoreBackend lookups from the embedding cache. Misc: llama-cpp TokenizeString reads the correct 'prompt' JSON key (the original bug); ModelTokenize nil-guard; non-fatal mitm proxy startup; PII 'route_local' renamed to 'allow' with docs/UI in sync; model-editor footer no longer eats the edit area on small screens; several config-editor template/dropdown/section fixes. Tests: e2e router specs (casual/code-hint + long-conversation trim), vector_store trace specs, lazy-factory specs, gallery dev-alias resolution, Playwright trace badge + scroll regression. Assisted-by: Claude:claude-opus-4-7 [Claude Code] Signed-off-by: Richard Palethorpe <io@richiejp.com> * feat(backend): auto-size batch to context for embedding and rerank models Embedding and rerank models pool over the whole input in a single physical batch (n_ubatch). With batch left at the 512 default, the backend rejects longer inputs with "input is too large to process", silently capping a large-context embedder (e.g. 8k/32k) at 512 tokens. Size n_batch to the context for these single-pass usecases, mirroring the existing FLAG_SCORE behaviour; an explicit batch: still wins. Extracts EffectiveContextSize/EffectiveBatchSize from grpcModelOpts so the effective decode window has one home for other callers to reuse. Adds an e2e-aio regression test that embeds a >512-token input. The AIO embedding model is switched to nomic-embed-text-v1.5 (2048 context) because the previous granite model was capped at 512 tokens and could not exercise the larger batch. Assisted-by: claude-code:claude-opus-4-8 [Claude Code] Signed-off-by: Richard Palethorpe <io@richiejp.com> * fix(gallery): raise arch-router scoring output cap via parallel:64 Scoring decodes the whole prompt+candidate in a single llama_decode and reads one logit row per candidate token. The vendored llama.cpp server caps causal output rows at n_parallel, so the default of 1 aborts with GGML_ASSERT(n_outputs_max <= cparams.n_outputs_max) on multi-token route labels. Set options: [parallel:64] on both arch-router quant entries to lift the cap; kv_unified (the grpc-server default) keeps the full context per sequence, so this does not split the KV cache. Assisted-by: claude-code:claude-opus-4-8 [Claude Code] Signed-off-by: Richard Palethorpe <io@richiejp.com> --------- Signed-off-by: Richard Palethorpe <io@richiejp.com>
This commit is contained in:
committed by
GitHub
parent
56cc4f63fc
commit
085fc53bbc
@@ -353,7 +353,7 @@ func handleAnthropicStream(c echo.Context, id string, input *schema.AnthropicReq
|
||||
overrides = make(map[string]pii.Action, len(raw))
|
||||
for ovid, action := range raw {
|
||||
switch pii.Action(action) {
|
||||
case pii.ActionMask, pii.ActionBlock, pii.ActionRouteLocal:
|
||||
case pii.ActionMask, pii.ActionBlock, pii.ActionAllow:
|
||||
overrides[ovid] = pii.Action(action)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -102,7 +102,7 @@ var instructionDefs = []instructionDef{
|
||||
Name: "pii-filtering",
|
||||
Description: "Inspect and tune the regex PII filter applied to chat requests",
|
||||
Tags: []string{"pii"},
|
||||
Intro: "GET /api/pii/patterns lists the active pattern set with each one's action (mask, block, route_local). GET /api/pii/events returns recent redaction events filtered by correlation_id / user_id / pattern_id (admin or local-user only). POST /api/pii/test dry-runs the redactor against an admin-supplied string. POST /api/pii/decide is the programmatic decision oracle for external routers: send `{text}`, receive `{findings, suggested_action, redacted_preview}` without LocalAI mutating, recording, or acting on the call — caller composes the action with its own policy. Default patterns: email, phone, SSN, credit card (Luhn), IPv4, common API key prefixes (sk-, pk-, ghp_, github_pat_). PII is per-model: by default it is OFF for non-proxy backends and ON for backends starting with proxy-* (cloud passthroughs). Opt in with `pii: { enabled: true }` in a model's YAML; use `pii: { patterns: [{id, action}] }` to upgrade or downgrade individual actions for that model. Override global default actions via --pii-config pii.yaml; --disable-pii turns the filter off entirely.",
|
||||
Intro: "GET /api/pii/patterns lists the active pattern set with each one's action (mask, block, allow). GET /api/pii/events returns recent redaction events filtered by correlation_id / user_id / pattern_id (admin or local-user only). POST /api/pii/test dry-runs the redactor against an admin-supplied string. POST /api/pii/decide is the programmatic decision oracle for external routers: send `{text}`, receive `{findings, suggested_action, redacted_preview}` without LocalAI mutating, recording, or acting on the call — caller composes the action with its own policy. Default patterns: email, phone, SSN, credit card (Luhn), IPv4, common API key prefixes (sk-, pk-, ghp_, github_pat_). PII is per-model: by default it is OFF for non-proxy backends and ON for backends starting with proxy-* (cloud passthroughs). Opt in with `pii: { enabled: true }` in a model's YAML; use `pii: { patterns: [{id, action}] }` to upgrade or downgrade individual actions for that model. Override global default actions via --pii-config pii.yaml; --disable-pii turns the filter off entirely.",
|
||||
},
|
||||
{
|
||||
Name: "middleware-admin",
|
||||
|
||||
@@ -124,6 +124,8 @@ func AutocompleteEndpoint(cl *config.ModelConfigLoader, ml *model.ModelLoader, a
|
||||
filterFn = config.BuildUsecaseFilterFn(config.FLAG_VAD)
|
||||
case config.UsecaseTranscript:
|
||||
filterFn = config.BuildUsecaseFilterFn(config.FLAG_TRANSCRIPT)
|
||||
case "score": // router classifier usecase (FLAG_SCORE); not in UsecaseInfoMap
|
||||
filterFn = config.BuildUsecaseFilterFn(config.FLAG_SCORE)
|
||||
default:
|
||||
filterFn = config.NoFilterFn
|
||||
}
|
||||
|
||||
@@ -15,9 +15,9 @@ import (
|
||||
//
|
||||
// External routers (e.g. the localai-org/platform router) call this
|
||||
// before dispatching to learn whether to mask the prompt in place,
|
||||
// route to a local-only backend, block the request, or pass it
|
||||
// through. LocalAI's in-band PII middleware is the alternative path
|
||||
// for direct-to-LocalAI clients — same Redactor, different framing.
|
||||
// block the request, or pass it through. LocalAI's in-band PII
|
||||
// middleware is the alternative path for direct-to-LocalAI clients —
|
||||
// same Redactor, different framing.
|
||||
//
|
||||
// Takes the *pii.Redactor directly rather than the whole
|
||||
// *application.Application so the handler stays unit-testable with a
|
||||
@@ -62,24 +62,18 @@ func PIIDecideEndpoint(redactor *pii.Redactor) echo.HandlerFunc {
|
||||
}
|
||||
}
|
||||
|
||||
// actionAllow is the wire-only value for "no findings". The other
|
||||
// three map to existing pii.Action* constants; allow has no in-band
|
||||
// counterpart because the in-band middleware simply passes through.
|
||||
const actionAllow = "allow"
|
||||
|
||||
// suggestedAction collapses the Redactor's Result flags onto a single
|
||||
// wire-format action using the in-band ordering (block > route_local
|
||||
// > mask > allow). Spans-without-Blocked-or-LocalOnly means every
|
||||
// match resolved to ActionMask.
|
||||
// wire-format action using the in-band ordering (block > mask >
|
||||
// allow). "allow" covers both "nothing matched" and "matched but every
|
||||
// span resolved to the allow action" — in both cases the caller may
|
||||
// dispatch unchanged, with the Findings list reporting what was seen.
|
||||
func suggestedAction(res pii.Result) string {
|
||||
switch {
|
||||
case res.Blocked:
|
||||
return string(pii.ActionBlock)
|
||||
case res.LocalOnly:
|
||||
return string(pii.ActionRouteLocal)
|
||||
case len(res.Spans) > 0:
|
||||
case res.Masked:
|
||||
return string(pii.ActionMask)
|
||||
default:
|
||||
return actionAllow
|
||||
return string(pii.ActionAllow)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -16,8 +16,8 @@ import (
|
||||
|
||||
// PIIDecideEndpoint exposes the redactor as a decision oracle. These
|
||||
// specs pin the validation surface and the suggested_action mapping
|
||||
// across all four actions (allow/mask/route_local/block). The redactor
|
||||
// itself is covered in core/services/routing/pii/redactor_test.go.
|
||||
// across the three actions (allow/mask/block). The redactor itself is
|
||||
// covered in core/services/routing/pii/redactor_test.go.
|
||||
|
||||
var _ = Describe("PIIDecideEndpoint", func() {
|
||||
var redactor *pii.Redactor
|
||||
@@ -68,16 +68,17 @@ var _ = Describe("PIIDecideEndpoint", func() {
|
||||
Expect(len(body.Findings)).To(BeNumerically(">=", 1))
|
||||
})
|
||||
|
||||
It("returns route_local when an override sets that action", func() {
|
||||
// Promote the email pattern to route_local for this test —
|
||||
// exercises the route_local branch of suggestedAction without
|
||||
// needing a custom pattern set.
|
||||
Expect(redactor.SetAction("email", pii.ActionRouteLocal)).To(Succeed())
|
||||
It("returns allow when a matched pattern's action is allow", func() {
|
||||
// Downgrade the email pattern to allow for this test —
|
||||
// exercises the allow branch of suggestedAction: a match is
|
||||
// found, but the strongest action is allow so the suggestion
|
||||
// is "allow" and the text is left intact.
|
||||
Expect(redactor.SetAction("email", pii.ActionAllow)).To(Succeed())
|
||||
rec, body := invokePIIDecide(redactor, `{"text":"contact alice@example.com"}`)
|
||||
Expect(rec.Code).To(Equal(http.StatusOK))
|
||||
Expect(body.SuggestedAction).To(Equal("route_local"))
|
||||
// route_local leaves the original text intact — caller decides
|
||||
// whether to forward it to a local-only backend.
|
||||
Expect(body.SuggestedAction).To(Equal("allow"))
|
||||
Expect(body.Findings).To(HaveLen(1), "allow still reports the finding")
|
||||
// allow leaves the original text intact.
|
||||
Expect(body.RedactedPreview).To(ContainSubstring("alice@example.com"))
|
||||
})
|
||||
|
||||
|
||||
@@ -130,7 +130,7 @@ func CompletionEndpoint(cl *config.ModelConfigLoader, ml *model.ModelLoader, eva
|
||||
overrides = make(map[string]pii.Action, len(raw))
|
||||
for ovid, action := range raw {
|
||||
switch pii.Action(action) {
|
||||
case pii.ActionMask, pii.ActionBlock, pii.ActionRouteLocal:
|
||||
case pii.ActionMask, pii.ActionBlock, pii.ActionAllow:
|
||||
overrides[ovid] = pii.Action(action)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -451,13 +451,14 @@ func buildRealtimeRoutingContext(a *application.Application, sessionID string) *
|
||||
return nil
|
||||
}
|
||||
deps := &middleware.ClassifierDeps{
|
||||
Scorer: a.Scorer,
|
||||
Embedder: a.Embedder,
|
||||
VectorStore: a.VectorStore,
|
||||
Reranker: a.Reranker,
|
||||
ModelLookup: a.ModelConfigLookup(),
|
||||
Registry: a.RouterClassifierRegistry(),
|
||||
Evaluator: a.TemplatesEvaluator(),
|
||||
Scorer: a.Scorer,
|
||||
TokenCounter: a.TokenCounter,
|
||||
Embedder: a.Embedder,
|
||||
VectorStore: a.VectorStore,
|
||||
Reranker: a.Reranker,
|
||||
ModelLookup: a.ModelConfigLookup(),
|
||||
Registry: a.RouterClassifierRegistry(),
|
||||
Evaluator: a.TemplatesEvaluator(),
|
||||
}
|
||||
userID := ""
|
||||
if u := a.FallbackUser(); u != nil {
|
||||
|
||||
139
core/http/middleware/probe_trim_test.go
Normal file
139
core/http/middleware/probe_trim_test.go
Normal file
@@ -0,0 +1,139 @@
|
||||
package middleware
|
||||
|
||||
import (
|
||||
"strings"
|
||||
|
||||
"github.com/mudler/LocalAI/core/config"
|
||||
"github.com/mudler/LocalAI/core/schema"
|
||||
|
||||
. "github.com/onsi/ginkgo/v2"
|
||||
. "github.com/onsi/gomega"
|
||||
)
|
||||
|
||||
var _ = Describe("routerConfigFingerprint", func() {
|
||||
rc := config.RouterConfig{Classifier: "score", ClassifierModel: "arch-router"}
|
||||
ctx4096 := 4096
|
||||
ctx8192 := 8192
|
||||
|
||||
// Regression: the score classifier bakes context_size into its token
|
||||
// budget at build time, and the built classifier is cached by this
|
||||
// fingerprint. If context_size weren't hashed, editing it and reloading
|
||||
// would return a classifier carrying the stale budget.
|
||||
It("changes when the classifier model's context_size changes", func() {
|
||||
cfgA := &config.ModelConfig{LLMConfig: config.LLMConfig{ContextSize: &ctx4096}}
|
||||
cfgB := &config.ModelConfig{LLMConfig: config.LLMConfig{ContextSize: &ctx8192}}
|
||||
Expect(routerConfigFingerprint(rc, cfgA)).NotTo(Equal(routerConfigFingerprint(rc, cfgB)))
|
||||
})
|
||||
|
||||
It("is stable for identical classifier configs", func() {
|
||||
cfgA := &config.ModelConfig{LLMConfig: config.LLMConfig{ContextSize: &ctx4096}}
|
||||
cfgB := &config.ModelConfig{LLMConfig: config.LLMConfig{ContextSize: &ctx4096}}
|
||||
Expect(routerConfigFingerprint(rc, cfgA)).To(Equal(routerConfigFingerprint(rc, cfgB)))
|
||||
})
|
||||
})
|
||||
|
||||
var _ = Describe("routing probe extraction and trimming", func() {
|
||||
Describe("OpenAIProbeFromRequest", func() {
|
||||
It("keeps a short conversation intact, newline-terminated per message", func() {
|
||||
req := &schema.OpenAIRequest{Messages: []schema.Message{
|
||||
{Role: "user", Content: "first"},
|
||||
{Role: "assistant", Content: "second"},
|
||||
{Role: "user", Content: "third"},
|
||||
}}
|
||||
Expect(OpenAIProbeFromRequest(req).Prompt).To(Equal("first\nsecond\nthird\n"))
|
||||
})
|
||||
|
||||
It("flattens text blocks and skips image-only messages", func() {
|
||||
req := &schema.OpenAIRequest{Messages: []schema.Message{
|
||||
{Role: "user", Content: []any{
|
||||
map[string]any{"type": "text", "text": "describe this"},
|
||||
map[string]any{"type": "image_url", "image_url": map[string]any{"url": "data:..."}},
|
||||
}},
|
||||
{Role: "user", Content: []any{
|
||||
map[string]any{"type": "image_url", "image_url": map[string]any{"url": "data:..."}},
|
||||
}},
|
||||
}}
|
||||
// Second message contributes no text, so it neither adds a blank
|
||||
// line nor a stray newline.
|
||||
Expect(OpenAIProbeFromRequest(req).Prompt).To(Equal("describe this\n"))
|
||||
})
|
||||
|
||||
It("carries the full conversation untrimmed — trimming is each classifier's job", func() {
|
||||
// The middleware no longer caps the probe by a fixed rune budget;
|
||||
// every turn reaches the Probe and each classifier trims to its own
|
||||
// model's context (see modelTokenTrim / promptTrimmer).
|
||||
block := strings.Repeat("x", 999)
|
||||
msgs := make([]schema.Message, 0, 20)
|
||||
msgs = append(msgs, schema.Message{Role: "user", Content: "OLDEST" + strings.Repeat("o", 994)})
|
||||
for range 18 {
|
||||
msgs = append(msgs, schema.Message{Role: "user", Content: block})
|
||||
}
|
||||
msgs = append(msgs, schema.Message{Role: "user", Content: "NEWEST" + strings.Repeat("n", 994)})
|
||||
|
||||
probe := OpenAIProbeFromRequest(&schema.OpenAIRequest{Messages: msgs})
|
||||
Expect(probe.Prompt).To(ContainSubstring("OLDEST"), "no turn is dropped at probe-build time")
|
||||
Expect(probe.Prompt).To(ContainSubstring("NEWEST"))
|
||||
// Messages preserves the per-turn split the classifier trims from.
|
||||
Expect(probe.Messages).To(HaveLen(20))
|
||||
Expect(probe.Messages[0]).To(ContainSubstring("OLDEST"))
|
||||
Expect(probe.Messages[19]).To(ContainSubstring("NEWEST"))
|
||||
})
|
||||
})
|
||||
|
||||
Describe("AnthropicProbe", func() {
|
||||
It("extracts and trims the same way as the OpenAI path", func() {
|
||||
req := &schema.AnthropicRequest{Messages: []schema.AnthropicMessage{
|
||||
{Role: "user", Content: "alpha"},
|
||||
{Role: "assistant", Content: []any{
|
||||
map[string]any{"type": "text", "text": "beta"},
|
||||
}},
|
||||
}}
|
||||
probe, ok := AnthropicProbe(req)
|
||||
Expect(ok).To(BeTrue())
|
||||
Expect(probe.Prompt).To(Equal("alpha\nbeta\n"))
|
||||
})
|
||||
|
||||
It("returns ok=false for a non-Anthropic payload", func() {
|
||||
_, ok := AnthropicProbe(&schema.OpenAIRequest{})
|
||||
Expect(ok).To(BeFalse())
|
||||
})
|
||||
})
|
||||
|
||||
Describe("modelTokenTrim", func() {
|
||||
tok := func(string) (int, error) { return 1, nil }
|
||||
depsFor := func(cfg *config.ModelConfig) ClassifierDeps {
|
||||
return ClassifierDeps{
|
||||
ModelLookup: func(string) *config.ModelConfig { return cfg },
|
||||
TokenCounter: func(string) func(string) (int, error) { return tok },
|
||||
}
|
||||
}
|
||||
|
||||
It("still trims to the backend default when context_size is unset", func() {
|
||||
// Regression: with the fixed middleware rune cap gone, an unset
|
||||
// context_size must NOT disable trimming — otherwise a non-trivial
|
||||
// prompt overflows the default 4096 window and every score fails.
|
||||
score := config.FLAG_SCORE
|
||||
cfg := &config.ModelConfig{KnownUsecases: &score} // FLAG_SCORE → batch follows context
|
||||
count, ceiling := modelTokenTrim("classifier", depsFor(cfg))
|
||||
Expect(count).NotTo(BeNil())
|
||||
Expect(ceiling).To(Equal(4096), "unset context_size falls back to the backend default, not 0")
|
||||
})
|
||||
|
||||
It("is bounded by the batch when the batch is smaller than the context", func() {
|
||||
// The probe is one decode (n_tokens <= n_batch). A model with a
|
||||
// large context but a small batch can only process the batch — the
|
||||
// ceiling must follow it, not the context.
|
||||
ctx8k := 8192
|
||||
cfg := &config.ModelConfig{LLMConfig: config.LLMConfig{ContextSize: &ctx8k}}
|
||||
cfg.Batch = 512
|
||||
_, ceiling := modelTokenTrim("embedder", depsFor(cfg))
|
||||
Expect(ceiling).To(Equal(512), "batch is the binding single-decode limit")
|
||||
})
|
||||
|
||||
It("disables trimming only when no tokenizer is available", func() {
|
||||
count, ceiling := modelTokenTrim("x", ClassifierDeps{ModelLookup: func(string) *config.ModelConfig { return &config.ModelConfig{} }})
|
||||
Expect(count).To(BeNil())
|
||||
Expect(ceiling).To(Equal(0))
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -6,6 +6,7 @@ import (
|
||||
"encoding/hex"
|
||||
"fmt"
|
||||
"hash/fnv"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
@@ -86,6 +87,12 @@ type ClassifierDeps struct {
|
||||
// templates.Evaluator so any model the operator points at gets
|
||||
// its own chat template applied.
|
||||
Evaluator *templates.Evaluator
|
||||
|
||||
// TokenCounter binds the classifier model's tokenizer for the score
|
||||
// classifier's token-trim path. Optional; nil falls back to the
|
||||
// backend's n_ctx guard. Plain func type so core/application supplies
|
||||
// it as a method value without importing this package.
|
||||
TokenCounter func(modelName string) func(text string) (int, error)
|
||||
}
|
||||
|
||||
// ProbeExtractor pulls the prompt content out of a parsed request so
|
||||
@@ -212,7 +219,6 @@ func recordHTTPDecision(c echo.Context, store router.DecisionStore, result *rout
|
||||
_ = store.Record(context.Background(), result.ToDecisionRecord(newDecisionID(), correlationID, userID, source))
|
||||
}
|
||||
|
||||
|
||||
// GetOrBuildClassifier looks up a built Classifier for the named router
|
||||
// model in the registry and builds it on miss. Exported so the
|
||||
// /api/router/decide decision-oracle endpoint can share the same
|
||||
@@ -262,9 +268,10 @@ func routerConfigFingerprint(rc config.RouterConfig, classifierCfg *config.Model
|
||||
h := fnv.New64a()
|
||||
h.Write(bytes)
|
||||
if classifierCfg != nil {
|
||||
// Narrow projection: only the fields newTemplateRenderer and
|
||||
// firstStopWord actually read. Hashing the whole ModelConfig
|
||||
// would invalidate the cache on irrelevant parameter changes.
|
||||
// Narrow projection: only the fields buildClassifier reads (renderer,
|
||||
// stop tokens, context_size → MaxContextTokens). Hashing the whole
|
||||
// ModelConfig would invalidate the cache on irrelevant changes;
|
||||
// omitting context_size would let a reload leave a stale token budget.
|
||||
h.Write([]byte{0}) // separator so empty fields don't collide
|
||||
h.Write([]byte(classifierCfg.TemplateConfig.Chat))
|
||||
h.Write([]byte{0})
|
||||
@@ -274,6 +281,10 @@ func routerConfigFingerprint(rc config.RouterConfig, classifierCfg *config.Model
|
||||
h.Write([]byte(sw))
|
||||
h.Write([]byte{0})
|
||||
}
|
||||
h.Write([]byte{0})
|
||||
if classifierCfg.ContextSize != nil {
|
||||
h.Write([]byte(strconv.Itoa(*classifierCfg.ContextSize)))
|
||||
}
|
||||
}
|
||||
return h.Sum64()
|
||||
}
|
||||
@@ -319,11 +330,30 @@ func buildClassifier(cfg *config.ModelConfig, deps ClassifierDeps) (router.Class
|
||||
if deps.ModelLookup != nil {
|
||||
if classifierCfg := deps.ModelLookup(rc.ClassifierModel); classifierCfg != nil {
|
||||
if deps.Evaluator != nil {
|
||||
opts.PromptRenderer = newTemplateRenderer(deps.Evaluator, classifierCfg)
|
||||
// The router renders the scoring prompt client-side, so the
|
||||
// classifier model MUST carry a chat template — refusing
|
||||
// here beats silently falling back to a generic ChatML
|
||||
// envelope the model may not have been trained on.
|
||||
renderer := newTemplateRenderer(deps.Evaluator, classifierCfg)
|
||||
if renderer == nil {
|
||||
return nil, fmt.Errorf(
|
||||
"router classifier score: classifier_model %q has no chat template "+
|
||||
"(set template.chat and template.chat_message in its config). The router "+
|
||||
"renders the scoring prompt with the classifier model's own template; "+
|
||||
"without it the prompt format would not match the model",
|
||||
rc.ClassifierModel)
|
||||
}
|
||||
opts.PromptRenderer = renderer
|
||||
}
|
||||
if st := pickAssistantTurnEnd(classifierCfg.StopWords, classifierCfg.TemplateConfig.ChatMessage); st != "" {
|
||||
opts.StopToken = st
|
||||
}
|
||||
// Token-exact conversation trim — score classifier drops the
|
||||
// oldest turns using the model's own tokenizer.
|
||||
if count, ctxTokens := modelTokenTrim(rc.ClassifierModel, deps); count != nil {
|
||||
opts.TokenCounter = count
|
||||
opts.MaxContextTokens = ctxTokens
|
||||
}
|
||||
}
|
||||
}
|
||||
inner = router.NewScoreClassifier(policies, scorer, opts)
|
||||
@@ -335,7 +365,11 @@ func buildClassifier(cfg *config.ModelConfig, deps ClassifierDeps) (router.Class
|
||||
if reranker == nil {
|
||||
return nil, fmt.Errorf("router classifier colbert: classifier_model %q not loadable", rc.ClassifierModel)
|
||||
}
|
||||
inner = router.NewRerankClassifier(policies, reranker, cacheCap, rc.ActivationThreshold)
|
||||
rerankClassifier := router.NewRerankClassifier(policies, reranker, cacheCap, rc.ActivationThreshold)
|
||||
if count, ctxTokens := modelTokenTrim(rc.ClassifierModel, deps); count != nil {
|
||||
rerankClassifier = rerankClassifier.WithTokenTrim(count, ctxTokens)
|
||||
}
|
||||
inner = rerankClassifier
|
||||
default:
|
||||
return nil, fmt.Errorf("router: unknown classifier %q (supported: %s)", name, strings.Join([]string{router.ClassifierScore, router.ClassifierColbert}, ", "))
|
||||
}
|
||||
@@ -523,7 +557,41 @@ func wrapWithEmbeddingCache(cfg *config.ModelConfig, inner router.Classifier, de
|
||||
if vstore == nil {
|
||||
return nil, fmt.Errorf("vector store %q not loadable", storeName)
|
||||
}
|
||||
return router.NewEmbeddingCacheClassifier(inner, embedder, vstore, ec.SimilarityThreshold, ec.ConfidenceThreshold), nil
|
||||
cache := router.NewEmbeddingCacheClassifier(inner, embedder, vstore, ec.SimilarityThreshold, ec.ConfidenceThreshold)
|
||||
// Trim the probe to the embedder model's own context (e.g. nomic-embed at
|
||||
// 8k) rather than a fixed guess — otherwise the cache key is an embedding
|
||||
// of a silently-truncated conversation.
|
||||
if count, ctxTokens := modelTokenTrim(ec.EmbeddingModel, deps); count != nil {
|
||||
cache = cache.WithTokenTrim(count, ctxTokens)
|
||||
}
|
||||
return cache, nil
|
||||
}
|
||||
|
||||
// modelTokenTrim returns a model's own tokenizer and the token ceiling its
|
||||
// probe must fit, or (nil, 0) when no tokenizer is available (only then can we
|
||||
// not trim exactly). The ceiling is min(effective context, effective batch):
|
||||
// score/embed/rerank all decode the whole prompt in one pass, so it must fit
|
||||
// both the context window and a single batch. Using the backend's *effective*
|
||||
// values — not the raw config fields — means trimming still works when
|
||||
// context_size and batch are unset; otherwise a non-trivial prompt overflows
|
||||
// the default window and every classification fails.
|
||||
func modelTokenTrim(modelName string, deps ClassifierDeps) (func(string) (int, error), int) {
|
||||
if deps.TokenCounter == nil || deps.ModelLookup == nil {
|
||||
return nil, 0
|
||||
}
|
||||
cfg := deps.ModelLookup(modelName)
|
||||
if cfg == nil {
|
||||
return nil, 0
|
||||
}
|
||||
count := deps.TokenCounter(modelName)
|
||||
if count == nil {
|
||||
return nil, 0
|
||||
}
|
||||
ceiling := backend.EffectiveContextSize(*cfg)
|
||||
if b := backend.EffectiveBatchSize(*cfg); b < ceiling {
|
||||
ceiling = b
|
||||
}
|
||||
return count, ceiling
|
||||
}
|
||||
|
||||
func newDecisionID() string {
|
||||
@@ -545,6 +613,41 @@ func OpenAIProbe(parsed any) (router.Probe, bool) {
|
||||
return OpenAIProbeFromRequest(req), true
|
||||
}
|
||||
|
||||
// messageText flattens a chat message's Content to plain text: string content
|
||||
// verbatim; []any structured content contributes only its "text" blocks.
|
||||
func messageText(content any) string {
|
||||
switch ct := content.(type) {
|
||||
case string:
|
||||
return ct
|
||||
case []any:
|
||||
var b strings.Builder
|
||||
for _, block := range ct {
|
||||
if bm, ok := block.(map[string]any); ok && bm["type"] == "text" {
|
||||
if t, ok := bm["text"].(string); ok {
|
||||
if b.Len() > 0 {
|
||||
b.WriteByte('\n')
|
||||
}
|
||||
b.WriteString(t)
|
||||
}
|
||||
}
|
||||
}
|
||||
return b.String()
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
// messageProbeParts drops empty (e.g. image-only) messages so they don't
|
||||
// consume budget or emit blank lines.
|
||||
func messageProbeParts(texts []string) []string {
|
||||
parts := make([]string, 0, len(texts))
|
||||
for _, t := range texts {
|
||||
if t != "" {
|
||||
parts = append(parts, t)
|
||||
}
|
||||
}
|
||||
return parts
|
||||
}
|
||||
|
||||
// OpenAIProbeFromRequest is the typed counterpart of OpenAIProbe — same
|
||||
// extraction logic, but takes the request struct directly. Realtime and
|
||||
// other non-HTTP callers use it to feed a probe to router.Resolve
|
||||
@@ -553,24 +656,15 @@ func OpenAIProbeFromRequest(req *schema.OpenAIRequest) router.Probe {
|
||||
if req == nil {
|
||||
return router.Probe{}
|
||||
}
|
||||
var b strings.Builder
|
||||
texts := make([]string, len(req.Messages))
|
||||
for i := range req.Messages {
|
||||
switch ct := req.Messages[i].Content.(type) {
|
||||
case string:
|
||||
b.WriteString(ct)
|
||||
b.WriteByte('\n')
|
||||
case []any:
|
||||
for _, block := range ct {
|
||||
if bm, ok := block.(map[string]any); ok && bm["type"] == "text" {
|
||||
if t, ok := bm["text"].(string); ok {
|
||||
b.WriteString(t)
|
||||
b.WriteByte('\n')
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
texts[i] = messageText(req.Messages[i].Content)
|
||||
}
|
||||
return router.Probe{Prompt: b.String()}
|
||||
parts := messageProbeParts(texts)
|
||||
// Prompt carries the full conversation; each classifier trims it to its own
|
||||
// model's context (see modelTokenTrim). Messages preserves the per-turn
|
||||
// split the trimmer drops oldest-first.
|
||||
return router.Probe{Prompt: router.JoinTurns(parts), Messages: parts}
|
||||
}
|
||||
|
||||
// AnthropicProbe is the AnthropicRequest analogue of OpenAIProbe.
|
||||
@@ -579,25 +673,10 @@ func AnthropicProbe(parsed any) (router.Probe, bool) {
|
||||
if !ok || req == nil {
|
||||
return router.Probe{}, false
|
||||
}
|
||||
var b strings.Builder
|
||||
texts := make([]string, len(req.Messages))
|
||||
for i := range req.Messages {
|
||||
switch ct := req.Messages[i].Content.(type) {
|
||||
case string:
|
||||
b.WriteString(ct)
|
||||
b.WriteByte('\n')
|
||||
case []any:
|
||||
for _, block := range ct {
|
||||
if bm, ok := block.(map[string]any); ok && bm["type"] == "text" {
|
||||
if t, ok := bm["text"].(string); ok {
|
||||
b.WriteString(t)
|
||||
b.WriteByte('\n')
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
texts[i] = messageText(req.Messages[i].Content)
|
||||
}
|
||||
return router.Probe{
|
||||
Prompt: b.String(),
|
||||
}, true
|
||||
parts := messageProbeParts(texts)
|
||||
return router.Probe{Prompt: router.JoinTurns(parts), Messages: parts}, true
|
||||
}
|
||||
|
||||
|
||||
@@ -246,11 +246,12 @@ var _ = Describe("RouteModel rendered classifier prompt", func() {
|
||||
"rendered prompt must end at assistant-open marker. got: %q", s.lastPrompt)
|
||||
})
|
||||
|
||||
It("falls back to chatMLRenderer when the classifier model has no chat_message template", func() {
|
||||
// Partial template config: only outer Chat, no per-role
|
||||
// piece. The renderer must refuse rather than emit a prompt
|
||||
// that drops the system turn, so the score classifier's
|
||||
// built-in ChatML default takes over.
|
||||
It("refuses to build the router when the classifier model has no chat_message template", func() {
|
||||
// Partial template config: only the outer Chat, no per-role piece.
|
||||
// The router renders the scoring prompt client-side from the
|
||||
// classifier model's own template, so a missing template is a hard
|
||||
// error rather than a silent fall back to a generic ChatML envelope
|
||||
// the model may not have been trained on.
|
||||
writePartialClassifierModel(modelDir, "arch-router")
|
||||
routerCfg := newScoreRouterModel(modelDir, "smart-router")
|
||||
|
||||
@@ -266,19 +267,9 @@ var _ = Describe("RouteModel rendered classifier prompt", func() {
|
||||
ModelLookup: loaderLookup(loader, appConfig),
|
||||
Evaluator: eval,
|
||||
})
|
||||
Expect(err).NotTo(HaveOccurred())
|
||||
|
||||
// chatMLRenderer fallback emits its own envelope and still
|
||||
// embeds the routing system prompt. OpenAIProbeFromRequest
|
||||
// appends "\n" after each message body, so the user content
|
||||
// reaches the renderer as "hello world\n" — the substring
|
||||
// match accounts for that.
|
||||
Expect(s.lastPrompt).To(ContainSubstring("<routes>"),
|
||||
"fallback renderer also dropped the system prompt")
|
||||
Expect(s.lastPrompt).To(ContainSubstring("<|im_start|>system\n"))
|
||||
Expect(s.lastPrompt).To(ContainSubstring("<|im_start|>user\nhello world\n<|im_end|>"))
|
||||
Expect(strings.HasSuffix(s.lastPrompt, "<|im_start|>assistant\n")).To(BeTrue(),
|
||||
"chatMLRenderer fallback must end at assistant-open marker. got: %q", s.lastPrompt)
|
||||
Expect(err).To(HaveOccurred())
|
||||
Expect(err.Error()).To(ContainSubstring("no chat template"),
|
||||
"missing classifier template must surface as a clear config error. got: %v", err)
|
||||
})
|
||||
|
||||
It("uses the classifier model's first stopword as the candidate suffix", func() {
|
||||
@@ -533,8 +524,8 @@ template:
|
||||
|
||||
// writePartialClassifierModel writes a classifier model that has the
|
||||
// outer Chat template but no ChatMessage — exercises the
|
||||
// newTemplateRenderer "refuse partial templating" branch that hands
|
||||
// off to chatMLRenderer.
|
||||
// newTemplateRenderer "refuse partial templating" branch, which makes
|
||||
// buildClassifier reject the router with a missing-template error.
|
||||
func writePartialClassifierModel(modelDir, name string) {
|
||||
body := `name: ` + name + `
|
||||
backend: llama-cpp
|
||||
|
||||
@@ -224,4 +224,38 @@ test.describe('Model Editor - Interactive Tab', () => {
|
||||
expect(estimateCalled).toBe(true)
|
||||
})
|
||||
|
||||
test('interactive tab scrolls at body height (no inner overflow pane) and tracks the active section', async ({ page }) => {
|
||||
// Regression: the form sections used to live inside an overflow:auto pane
|
||||
// with maxHeight: calc(100vh - 340px), which kept the global footer in
|
||||
// view on every screen and ate ~50px of editing room on short windows.
|
||||
// Pin two pieces of the fix:
|
||||
// 1. The two-column container (sticky nav + content) has no scrollable
|
||||
// inner element on its content side — body-scroll handles overflow.
|
||||
// 2. The active-section tracker now listens to window scroll. Scrolling
|
||||
// the window should run the tracker without throwing, and the
|
||||
// `<nav>` sidebar must still render.
|
||||
const contentOverflowY = await page.evaluate(() => {
|
||||
const sidebar = document.querySelector('nav')
|
||||
// The content column is the next sibling of the sticky sidebar.
|
||||
const content = sidebar?.nextElementSibling
|
||||
return content ? getComputedStyle(content).overflowY : 'no-content'
|
||||
})
|
||||
expect(['visible', 'normal', 'auto', 'scroll', 'no-content']).toContain(contentOverflowY)
|
||||
expect(contentOverflowY).not.toBe('scroll')
|
||||
// 'auto' could exist on some browsers but should NOT — the fix removes it.
|
||||
// We assert the strong invariant separately.
|
||||
expect(['auto']).not.toContain(contentOverflowY)
|
||||
|
||||
// Add a couple of fields to give the page a touch more height, then
|
||||
// force a window scroll. The tracker should run; the sidebar should
|
||||
// remain visible.
|
||||
const searchInput = page.locator('input[placeholder="Search fields to add..."]')
|
||||
await searchInput.fill('Temperature')
|
||||
const dropdown = searchInput.locator('..').locator('..')
|
||||
await dropdown.locator('div', { hasText: 'Temperature' }).first().click()
|
||||
await page.evaluate(() => window.scrollTo(0, 200))
|
||||
await page.waitForTimeout(50)
|
||||
await expect(page.locator('nav').first()).toBeVisible()
|
||||
})
|
||||
|
||||
})
|
||||
|
||||
94
core/http/react-ui/e2e/model-editor-back-nav.spec.js
Normal file
94
core/http/react-ui/e2e/model-editor-back-nav.spec.js
Normal file
@@ -0,0 +1,94 @@
|
||||
import { test, expect } from './coverage-fixtures.js'
|
||||
|
||||
// Exercises the "Back to <page>" navigation convention: whichever page links
|
||||
// into the Model Editor stamps its origin as react-router location state, and
|
||||
// the editor's Back button returns there (captioned with the origin) instead
|
||||
// of a hardcoded route. Also covers the Middleware page's ?tab= persistence,
|
||||
// which is what lets the editor return you to the exact tab you came from.
|
||||
|
||||
const MOCK_METADATA = {
|
||||
sections: [{ id: 'general', label: 'General', icon: 'settings', order: 0 }],
|
||||
fields: [
|
||||
{ path: 'name', yaml_key: 'name', go_type: 'string', ui_type: 'string', section: 'general', label: 'Model Name', description: 'id', component: 'input', order: 0 },
|
||||
],
|
||||
}
|
||||
const MOCK_YAML = 'name: mock-model\nbackend: mock-backend\n'
|
||||
|
||||
// Router config with one model, so the Routing tab renders an editable model
|
||||
// link we can click through to the editor.
|
||||
const MOCK_MIDDLEWARE_STATUS = {
|
||||
pii: { enabled_globally: false, default_enabled_for_backends: [], patterns: [], models: [], recent_event_count: 0 },
|
||||
router: {
|
||||
configured: true,
|
||||
models: [{ name: 'smart-router', classifier: 'score', fallback: 'qwen-7b', policies: [], candidates: [] }],
|
||||
recent_decision_count: 0,
|
||||
available_classifiers: ['score'],
|
||||
},
|
||||
}
|
||||
|
||||
// Make the editor render for any model name (the header — and thus the Back
|
||||
// button — only appears once metadata + config have loaded).
|
||||
async function mockEditorEndpoints(page) {
|
||||
await page.route('**/api/models/config-metadata*', (route) =>
|
||||
route.fulfill({ contentType: 'application/json', body: JSON.stringify(MOCK_METADATA) }))
|
||||
await page.route('**/api/models/edit/**', (route) =>
|
||||
route.fulfill({ contentType: 'application/json', body: JSON.stringify({ config: MOCK_YAML, name: 'mock-model' }) }))
|
||||
await page.route('**/api/models/config-json/**', (route) =>
|
||||
route.fulfill({ contentType: 'application/json', body: '{}' }))
|
||||
}
|
||||
|
||||
test.describe('Model Editor — Back navigation', () => {
|
||||
test.beforeEach(async ({ page }) => {
|
||||
await page.route('**/api/auth/status', (route) =>
|
||||
route.fulfill({ contentType: 'application/json', body: JSON.stringify({ authEnabled: false, staticApiKeyRequired: false, providers: [] }) }))
|
||||
await mockEditorEndpoints(page)
|
||||
})
|
||||
|
||||
test('Back returns to Manage with a "Back to Manage" caption', async ({ page }) => {
|
||||
await page.goto('/app/manage')
|
||||
await expect(page.locator('.table')).toBeVisible({ timeout: 10_000 })
|
||||
|
||||
// Open the first row's action menu and pick "Edit configuration".
|
||||
const trigger = page.locator('button.action-menu__trigger').first()
|
||||
await expect(trigger).toBeVisible()
|
||||
await trigger.click()
|
||||
await page.getByRole('menuitem', { name: 'Edit configuration' }).click()
|
||||
|
||||
await expect(page).toHaveURL(/\/app\/model-editor\//)
|
||||
const back = page.getByRole('button', { name: /Back to Manage/ })
|
||||
await expect(back).toBeVisible({ timeout: 10_000 })
|
||||
|
||||
await back.click()
|
||||
await expect(page).toHaveURL(/\/app\/manage/)
|
||||
})
|
||||
|
||||
test('returns to the originating Middleware tab (?tab=routing) it was opened from', async ({ page }) => {
|
||||
await page.route('**/api/middleware/status', (route) =>
|
||||
route.fulfill({ contentType: 'application/json', body: JSON.stringify(MOCK_MIDDLEWARE_STATUS) }))
|
||||
await page.route('**/api/pii/events?**', (route) =>
|
||||
route.fulfill({ contentType: 'application/json', body: JSON.stringify({ events: [] }) }))
|
||||
await page.route('**/api/router/decisions?**', (route) =>
|
||||
route.fulfill({ contentType: 'application/json', body: JSON.stringify({ decisions: [] }) }))
|
||||
|
||||
await page.goto('/app/middleware')
|
||||
// Switching to Routing must push the tab into the URL.
|
||||
await page.getByRole('button', { name: /Routing/i }).click()
|
||||
await expect(page).toHaveURL(/[?&]tab=routing/)
|
||||
|
||||
// Click through to the router model's config, then back.
|
||||
await page.getByRole('link', { name: 'smart-router' }).click()
|
||||
await expect(page).toHaveURL(/\/app\/model-editor\/smart-router/)
|
||||
const back = page.getByRole('button', { name: /Back to Middleware/ })
|
||||
await expect(back).toBeVisible({ timeout: 10_000 })
|
||||
|
||||
await back.click()
|
||||
// Returns to the exact tab, not the default Filtering tab.
|
||||
await expect(page).toHaveURL(/\/app\/middleware\?tab=routing/)
|
||||
await expect(page.getByText('smart-router').first()).toBeVisible()
|
||||
})
|
||||
|
||||
test('falls back to "Back to Manage" on a direct visit with no origin state', async ({ page }) => {
|
||||
await page.goto('/app/model-editor/mock-model')
|
||||
await expect(page.getByRole('button', { name: /Back to Manage/ })).toBeVisible({ timeout: 10_000 })
|
||||
})
|
||||
})
|
||||
@@ -48,3 +48,77 @@ test.describe('Traces - Error Display', () => {
|
||||
await expect(page.locator('th', { hasText: 'Type' })).toBeVisible()
|
||||
})
|
||||
})
|
||||
|
||||
// Pin the BackendTraceDetail expansion path for a vector_store trace —
|
||||
// the type that surfaces the router's embedding-cache plumbing. The
|
||||
// row click triggers the detail render, which exercises typeBadgeStyle
|
||||
// (with the new vector_store badge color), the DataFields component
|
||||
// (op / outcome / vector_dim / similarity), and the "View backend
|
||||
// logs" link that resolves to the store namespace. Without this spec
|
||||
// the new color entry plus the data-field render branches stay
|
||||
// uncovered, dragging UI line coverage below the regression gate.
|
||||
test.describe('Traces - vector_store backend trace detail', () => {
|
||||
test.beforeEach(async ({ page }) => {
|
||||
await page.route('**/api/traces', (route) => {
|
||||
route.fulfill({ contentType: 'application/json', body: '[]' })
|
||||
})
|
||||
await page.route('**/api/backend-traces', (route) => {
|
||||
route.fulfill({
|
||||
contentType: 'application/json',
|
||||
body: JSON.stringify([
|
||||
{
|
||||
type: 'vector_store',
|
||||
timestamp: '2026-05-28T13:56:25.558Z',
|
||||
model_name: 'router-cache-smart-router',
|
||||
backend: 'local-store',
|
||||
summary: 'search hit (sim=0.989)',
|
||||
duration: 160_000_000,
|
||||
error: '',
|
||||
data: {
|
||||
op: 'search',
|
||||
outcome: 'hit',
|
||||
vector_dim: 768,
|
||||
similarity: 0.9899752140045166,
|
||||
},
|
||||
},
|
||||
{
|
||||
type: 'vector_store',
|
||||
timestamp: '2026-05-28T13:49:07.545Z',
|
||||
model_name: 'router-cache-smart-router',
|
||||
backend: 'local-store',
|
||||
summary: 'search miss',
|
||||
duration: 100_000_000,
|
||||
error: '',
|
||||
data: {
|
||||
op: 'search',
|
||||
outcome: 'miss',
|
||||
vector_dim: 768,
|
||||
},
|
||||
},
|
||||
]),
|
||||
})
|
||||
})
|
||||
await page.goto('/app/traces')
|
||||
await expect(page.locator('text=Tracing is')).toBeVisible({ timeout: 10_000 })
|
||||
await page.locator('button', { hasText: 'Backend Traces' }).click()
|
||||
})
|
||||
|
||||
test('renders type badge and expands data fields on row click', async ({ page }) => {
|
||||
// The vector_store badge appears in the type column.
|
||||
await expect(page.locator('td span', { hasText: 'vector_store' }).first()).toBeVisible()
|
||||
|
||||
// Clicking the first row expands BackendTraceDetail, which renders
|
||||
// the four data fields. Use the first row's "search hit" summary
|
||||
// as the anchor to disambiguate from the miss row below.
|
||||
await page.locator('tr', { hasText: 'search hit' }).first().click()
|
||||
|
||||
// DataFields renders op/outcome/vector_dim/similarity as label/value pairs.
|
||||
// 'hit' appears as the rendered outcome value.
|
||||
await expect(page.locator('text=outcome').first()).toBeVisible()
|
||||
await expect(page.locator('text=hit').first()).toBeVisible()
|
||||
|
||||
// The model_name → /app/backend-logs link is the BackendTraceDetail
|
||||
// affordance for jumping to logs for the store namespace.
|
||||
await expect(page.locator('a', { hasText: 'View backend logs' })).toBeVisible()
|
||||
})
|
||||
})
|
||||
|
||||
@@ -4,6 +4,12 @@ export default defineConfig({
|
||||
testDir: './e2e',
|
||||
timeout: 30_000,
|
||||
retries: process.env.CI ? 2 : 0,
|
||||
// TEMPORARY: cap parallelism. Playwright's default (cores/2) oversubscribes
|
||||
// high-core dev machines and intermittently starves the page-teardown
|
||||
// coverage harvest past the 30s test timeout (flaky "Tearing down page"
|
||||
// failures, different specs each run). Capped at 8 pending a proper
|
||||
// root-cause fix; override with PW_WORKERS.
|
||||
workers: process.env.PW_WORKERS ? Number(process.env.PW_WORKERS) : 8,
|
||||
reporter: process.env.CI ? 'html' : 'list',
|
||||
use: {
|
||||
baseURL: 'http://127.0.0.1:8089',
|
||||
|
||||
@@ -13,6 +13,7 @@ import { useCodeMirror } from '../hooks/useCodeMirror'
|
||||
import { useTheme } from '../contexts/ThemeContext'
|
||||
import { getThemeExtension } from '../utils/cmTheme'
|
||||
import { createYamlCompletionSource } from '../utils/cmYamlComplete'
|
||||
import { goTemplate } from '../utils/cmGoTemplate'
|
||||
|
||||
function yamlIssueToDiagnostic(issue, cmDoc, severity) {
|
||||
const len = cmDoc.length
|
||||
@@ -43,14 +44,17 @@ const yamlLinter = linter(view => {
|
||||
return diagnostics
|
||||
})
|
||||
|
||||
export default function CodeEditor({ value, onChange, disabled, minHeight = '500px', fields }) {
|
||||
export default function CodeEditor({ value, onChange, disabled, minHeight = '500px', fields, language = 'yaml' }) {
|
||||
const containerRef = useRef(null)
|
||||
const { theme } = useTheme()
|
||||
const isGoTemplate = language === 'gotemplate'
|
||||
|
||||
// Static extensions — only recreate when fields change
|
||||
// Static extensions — only recreate when fields/language change
|
||||
const extensions = useMemo(() => {
|
||||
const exts = [
|
||||
yaml(),
|
||||
// Go templates aren't YAML — skip the YAML mode/linter so valid
|
||||
// `{{ ... }}` syntax isn't flagged as a YAML parse error.
|
||||
isGoTemplate ? goTemplate : yaml(),
|
||||
lineNumbers(),
|
||||
highlightActiveLineGutter(),
|
||||
highlightActiveLine(),
|
||||
@@ -59,8 +63,6 @@ export default function CodeEditor({ value, onChange, disabled, minHeight = '500
|
||||
indentOnInput(),
|
||||
bracketMatching(),
|
||||
highlightSelectionMatches(),
|
||||
yamlLinter,
|
||||
lintGutter(),
|
||||
history(),
|
||||
indentUnit.of(' '),
|
||||
EditorState.tabSize.of(2),
|
||||
@@ -77,15 +79,18 @@ export default function CodeEditor({ value, onChange, disabled, minHeight = '500
|
||||
}),
|
||||
]
|
||||
|
||||
if (fields && fields.length > 0) {
|
||||
exts.push(autocompletion({
|
||||
override: [createYamlCompletionSource(fields)],
|
||||
activateOnTyping: true,
|
||||
}))
|
||||
if (!isGoTemplate) {
|
||||
exts.push(yamlLinter, lintGutter())
|
||||
if (fields && fields.length > 0) {
|
||||
exts.push(autocompletion({
|
||||
override: [createYamlCompletionSource(fields)],
|
||||
activateOnTyping: true,
|
||||
}))
|
||||
}
|
||||
}
|
||||
|
||||
return exts
|
||||
}, [minHeight, fields])
|
||||
}, [minHeight, fields, isGoTemplate])
|
||||
|
||||
// Dynamic extensions — reconfigured via Compartments (preserves undo/cursor/scroll)
|
||||
const dynamicExtensions = useMemo(() => ({
|
||||
|
||||
@@ -16,6 +16,7 @@ const PROVIDER_TO_CAPABILITY = {
|
||||
'models:tts': 'FLAG_TTS',
|
||||
'models:transcript': 'FLAG_TRANSCRIPT',
|
||||
'models:vad': 'FLAG_VAD',
|
||||
'models:score': 'FLAG_SCORE',
|
||||
}
|
||||
|
||||
function coerceValue(raw, uiType) {
|
||||
@@ -325,7 +326,7 @@ export default function ConfigFieldRenderer({ field, value, onChange, onRemove,
|
||||
</div>
|
||||
{isStructured
|
||||
? <StructuredCodeEditor value={value} onChange={handleChange} minHeight="80px" />
|
||||
: <CodeEditor value={value || ''} onChange={handleChange} minHeight="80px" />}
|
||||
: <CodeEditor value={value || ''} onChange={handleChange} minHeight="80px" language={field.language} />}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -5,7 +5,7 @@ import SearchableSelect from './SearchableSelect'
|
||||
const ACTION_OPTIONS = [
|
||||
{ value: 'mask', label: 'Mask — replace with a [REDACTED:id] placeholder' },
|
||||
{ value: 'block', label: 'Block — reject the request (request side) / mask in stream' },
|
||||
{ value: 'route_local', label: 'Route local — keep text, force local-only routing' },
|
||||
{ value: 'allow', label: 'Allow — detect & log, leave text unchanged' },
|
||||
]
|
||||
|
||||
export default function PIIPatternListEditor({ value, onChange }) {
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { useState, useEffect, useCallback, useRef } from 'react'
|
||||
import { useNavigate, useOutletContext } from 'react-router-dom'
|
||||
import { useNavigate, useOutletContext, useLocation } from 'react-router-dom'
|
||||
import { agentJobsApi, modelsApi } from '../utils/api'
|
||||
import { fromState } from '../utils/editorNav'
|
||||
import { useModels } from '../hooks/useModels'
|
||||
import { useAuth } from '../context/AuthContext'
|
||||
import { useUserMap } from '../hooks/useUserMap'
|
||||
@@ -13,6 +14,7 @@ import ConfirmDialog from '../components/ConfirmDialog'
|
||||
export default function AgentJobs() {
|
||||
const { addToast } = useOutletContext()
|
||||
const navigate = useNavigate()
|
||||
const location = useLocation()
|
||||
const { models } = useModels()
|
||||
const { isAdmin, authEnabled, user } = useAuth()
|
||||
const userMap = useUserMap()
|
||||
@@ -338,7 +340,7 @@ export default function AgentJobs() {
|
||||
</td>
|
||||
<td>
|
||||
{task.model ? (
|
||||
<a onClick={() => navigate(`/app/model-editor/${encodeURIComponent(task.model)}`)} style={{ cursor: 'pointer', color: 'var(--color-primary)', fontSize: '0.8125rem' }}>
|
||||
<a onClick={() => navigate(`/app/model-editor/${encodeURIComponent(task.model)}`, { state: fromState(location, 'Agent Jobs') })} style={{ cursor: 'pointer', color: 'var(--color-primary)', fontSize: '0.8125rem' }}>
|
||||
{task.model}
|
||||
</a>
|
||||
) : '-'}
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { useState, useEffect, useRef, useCallback, useMemo } from 'react'
|
||||
import { useParams, useOutletContext, useNavigate } from 'react-router-dom'
|
||||
import { useParams, useOutletContext, useNavigate, useLocation } from 'react-router-dom'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { fromState } from '../utils/editorNav'
|
||||
import { useChat } from '../hooks/useChat'
|
||||
import ModelSelector from '../components/ModelSelector'
|
||||
import { renderMarkdown, highlightAll } from '../utils/markdown'
|
||||
@@ -285,6 +286,7 @@ export default function Chat() {
|
||||
const { model: urlModel } = useParams()
|
||||
const { addToast } = useOutletContext()
|
||||
const navigate = useNavigate()
|
||||
const location = useLocation()
|
||||
const { t } = useTranslation('chat')
|
||||
const { isAdmin } = useAuth()
|
||||
const { operations } = useOperations()
|
||||
@@ -904,7 +906,7 @@ export default function Chat() {
|
||||
<button
|
||||
type="button"
|
||||
className="btn btn-secondary btn-sm"
|
||||
onClick={() => navigate(`/app/model-editor/${encodeURIComponent(activeChat.model)}`)}
|
||||
onClick={() => navigate(`/app/model-editor/${encodeURIComponent(activeChat.model)}`, { state: fromState(location, 'Chat') })}
|
||||
title={t('header.editConfig')}
|
||||
>
|
||||
<i className="fas fa-pen-to-square" /> {t('header.editConfig')}
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { useState, useEffect, useCallback } from 'react'
|
||||
import { useNavigate, useOutletContext, useSearchParams } from 'react-router-dom'
|
||||
import { useNavigate, useOutletContext, useSearchParams, useLocation } from 'react-router-dom'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { fromState } from '../utils/editorNav'
|
||||
import ResourceMonitor from '../components/ResourceMonitor'
|
||||
import ConfirmDialog from '../components/ConfirmDialog'
|
||||
import NodeDistributionChip from '../components/NodeDistributionChip'
|
||||
@@ -121,6 +122,7 @@ function formatBackendVersion(metadata) {
|
||||
export default function Manage() {
|
||||
const { addToast } = useOutletContext()
|
||||
const navigate = useNavigate()
|
||||
const location = useLocation()
|
||||
const { t } = useTranslation('admin')
|
||||
const [searchParams, setSearchParams] = useSearchParams()
|
||||
const initialTab = searchParams.get('tab') || localStorage.getItem('manage-tab') || 'models'
|
||||
@@ -673,7 +675,7 @@ export default function Manage() {
|
||||
onClick: () => handleTogglePinned(model.id, model.pinned),
|
||||
disabled: pinningModels.has(model.id) || !!model.disabled },
|
||||
{ key: 'edit', icon: 'fa-pen-to-square', label: 'Edit configuration',
|
||||
onClick: () => navigate(`/app/model-editor/${encodeURIComponent(model.id)}`) },
|
||||
onClick: () => navigate(`/app/model-editor/${encodeURIComponent(model.id)}`, { state: fromState(location, 'Manage') }) },
|
||||
{ key: 'logs', icon: 'fa-terminal', label: 'Backend logs',
|
||||
onClick: () => navigate(`/app/backend-logs/${encodeURIComponent(model.id)}`) },
|
||||
{ divider: true },
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { useState, useEffect, useCallback, useRef, useMemo, Fragment } from 'react'
|
||||
import { useOutletContext, Link, useNavigate } from 'react-router-dom'
|
||||
import { useOutletContext, Link, useNavigate, useLocation, useSearchParams } from 'react-router-dom'
|
||||
import { apiUrl } from '../utils/basePath'
|
||||
import { fromState } from '../utils/editorNav'
|
||||
import { settingsApi } from '../utils/api'
|
||||
import LoadingSpinner from '../components/LoadingSpinner'
|
||||
|
||||
@@ -26,13 +27,13 @@ const TABS = [
|
||||
{ id: 'events', label: 'Events', icon: 'fa-list-ul' },
|
||||
]
|
||||
|
||||
const ACTIONS = ['mask', 'block', 'route_local']
|
||||
const ACTIONS = ['mask', 'block', 'allow']
|
||||
|
||||
function actionBadge(action) {
|
||||
const colors = {
|
||||
mask: 'var(--color-primary)',
|
||||
block: 'var(--color-error)',
|
||||
route_local: 'var(--color-warning)',
|
||||
allow: 'var(--color-warning)',
|
||||
}
|
||||
return (
|
||||
<span style={{
|
||||
@@ -75,9 +76,20 @@ export default function Middleware() {
|
||||
const [events, setEvents] = useState([])
|
||||
const [decisions, setDecisions] = useState([])
|
||||
const [loading, setLoading] = useState(true)
|
||||
const [activeTab, setActiveTab] = useState('filtering')
|
||||
// The active tab lives in the URL (?tab=) so deep links and the model-editor
|
||||
// Back button (which captures location.search) return to the same tab; a
|
||||
// localStorage fallback restores it on a bare visit. Mirrors the Manage page.
|
||||
const [searchParams, setSearchParams] = useSearchParams()
|
||||
const initialTab = searchParams.get('tab') || localStorage.getItem('middleware-tab') || 'filtering'
|
||||
const [activeTab, setActiveTab] = useState(TABS.some(t => t.id === initialTab) ? initialTab : 'filtering')
|
||||
const [pendingPattern, setPendingPattern] = useState(null) // id while a PUT is in flight
|
||||
|
||||
const selectTab = (id) => {
|
||||
setActiveTab(id)
|
||||
localStorage.setItem('middleware-tab', id)
|
||||
setSearchParams({ tab: id })
|
||||
}
|
||||
|
||||
// silent=true on background polls: skips the loading spinner and
|
||||
// suppresses toast spam if the server is briefly unreachable.
|
||||
const fetchAll = useCallback(async (silent = false) => {
|
||||
@@ -178,7 +190,7 @@ export default function Middleware() {
|
||||
<button
|
||||
key={tab.id}
|
||||
className={`btn btn-sm ${activeTab === tab.id ? 'btn-primary' : 'btn-secondary'}`}
|
||||
onClick={() => setActiveTab(tab.id)}
|
||||
onClick={() => selectTab(tab.id)}
|
||||
>
|
||||
<i className={`fas ${tab.icon}`} style={{ marginRight: 4 }} />
|
||||
{tab.label}
|
||||
@@ -215,6 +227,7 @@ export default function Middleware() {
|
||||
}
|
||||
|
||||
function FilteringTab({ status, pendingPattern, onSetAction, onSetDisabled, onPersist, persisting }) {
|
||||
const location = useLocation()
|
||||
if (!status?.pii) return null
|
||||
const pii = status.pii
|
||||
|
||||
@@ -353,6 +366,7 @@ function FilteringTab({ status, pendingPattern, onSetAction, onSetDisabled, onPe
|
||||
<td>
|
||||
<Link
|
||||
to={`/app/model-editor/${encodeURIComponent(m.name)}`}
|
||||
state={fromState(location, 'Middleware')}
|
||||
className="btn btn-secondary btn-sm"
|
||||
style={{ fontSize: '0.6875rem', padding: '2px 8px' }}
|
||||
title={`Edit ${m.name}.yaml`}
|
||||
@@ -485,6 +499,7 @@ function DecisionDetail({ d }) {
|
||||
|
||||
function RoutingTab({ status, decisions }) {
|
||||
const navigate = useNavigate()
|
||||
const location = useLocation()
|
||||
const router = status?.router || { configured: false }
|
||||
const [expanded, setExpanded] = useState(() => new Set())
|
||||
|
||||
@@ -519,7 +534,7 @@ function RoutingTab({ status, decisions }) {
|
||||
<button
|
||||
className="btn btn-primary"
|
||||
style={{ marginTop: 'var(--spacing-md)' }}
|
||||
onClick={() => navigate('/app/model-editor?template=router')}
|
||||
onClick={() => navigate('/app/model-editor?template=router', { state: fromState(location, 'Middleware') })}
|
||||
>
|
||||
<i className="fas fa-plus" /> Create routing model
|
||||
</button>
|
||||
@@ -539,7 +554,7 @@ function RoutingTab({ status, decisions }) {
|
||||
</span>
|
||||
<button
|
||||
className="btn btn-secondary btn-sm"
|
||||
onClick={() => navigate('/app/model-editor?template=router')}
|
||||
onClick={() => navigate('/app/model-editor?template=router', { state: fromState(location, 'Middleware') })}
|
||||
title="Open the model editor with the Routing Model template pre-selected"
|
||||
>
|
||||
<i className="fas fa-plus" /> Add routing model
|
||||
@@ -560,7 +575,9 @@ function RoutingTab({ status, decisions }) {
|
||||
<tbody>
|
||||
{router.models.map(m => (
|
||||
<tr key={m.name}>
|
||||
<td style={{ fontFamily: 'var(--font-mono)', fontSize: '0.8125rem', fontWeight: 600 }}>{m.name}</td>
|
||||
<td style={{ fontFamily: 'var(--font-mono)', fontSize: '0.8125rem', fontWeight: 600 }}>
|
||||
<Link to={`/app/model-editor/${encodeURIComponent(m.name)}`} state={fromState(location, 'Middleware')} title="Edit this router model's config">{m.name}</Link>
|
||||
</td>
|
||||
<td style={{ fontFamily: 'var(--font-mono)', fontSize: '0.75rem' }}>{m.classifier}</td>
|
||||
<td style={{ fontSize: '0.75rem' }}>
|
||||
{(m.candidates || []).map((c, i) => (
|
||||
@@ -657,6 +674,7 @@ function RoutingTab({ status, decisions }) {
|
||||
|
||||
function ProxyTab({ status, addToast, onChanged }) {
|
||||
const navigate = useNavigate()
|
||||
const location = useLocation()
|
||||
const mitm = status?.mitm
|
||||
const serverListen = mitm?.configured_addr || ''
|
||||
|
||||
@@ -722,7 +740,7 @@ function ProxyTab({ status, addToast, onChanged }) {
|
||||
<code style={{ fontFamily: 'var(--font-mono)' }}>{h}</code>
|
||||
{' claimed by: '}
|
||||
{(conflicts[h] || []).map(name => (
|
||||
<Link key={name} to={`/app/model-editor/${encodeURIComponent(name)}`} style={{ marginRight: 6, fontFamily: 'var(--font-mono)' }}>
|
||||
<Link key={name} to={`/app/model-editor/${encodeURIComponent(name)}`} state={fromState(location, 'Middleware')} style={{ marginRight: 6, fontFamily: 'var(--font-mono)' }}>
|
||||
{name}
|
||||
</Link>
|
||||
))}
|
||||
@@ -754,7 +772,7 @@ function ProxyTab({ status, addToast, onChanged }) {
|
||||
<ul style={{ margin: 0, paddingLeft: 20, fontFamily: 'var(--font-mono)' }}>
|
||||
{ownerEntries.map(([host, name]) => (
|
||||
<li key={host}>
|
||||
{host} → <Link to={`/app/model-editor/${encodeURIComponent(name)}`}>{name}</Link>
|
||||
{host} → <Link to={`/app/model-editor/${encodeURIComponent(name)}`} state={fromState(location, 'Middleware')}>{name}</Link>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
@@ -784,7 +802,7 @@ function ProxyTab({ status, addToast, onChanged }) {
|
||||
<h2 style={{ fontSize: '1rem', fontWeight: 600, margin: 0 }}>MITM Models</h2>
|
||||
<button
|
||||
className="btn btn-secondary btn-sm"
|
||||
onClick={() => navigate('/app/model-editor?template=mitm')}
|
||||
onClick={() => navigate('/app/model-editor?template=mitm', { state: fromState(location, 'Middleware') })}
|
||||
title="Open the model editor with the MITM Intercept template pre-selected"
|
||||
>
|
||||
<i className="fas fa-plus" /> Add MITM model
|
||||
@@ -815,6 +833,7 @@ function ProxyTab({ status, addToast, onChanged }) {
|
||||
<td>
|
||||
<Link
|
||||
to={`/app/model-editor/${encodeURIComponent(m.name)}`}
|
||||
state={fromState(location, 'Middleware')}
|
||||
className="btn btn-secondary btn-sm"
|
||||
style={{ fontSize: '0.6875rem', padding: '2px 8px' }}
|
||||
>
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { useState, useEffect, useRef, useMemo, useCallback } from 'react'
|
||||
import { useParams, useNavigate, useOutletContext, useSearchParams } from 'react-router-dom'
|
||||
import { useParams, useNavigate, useOutletContext, useSearchParams, useLocation } from 'react-router-dom'
|
||||
import YAML from 'yaml'
|
||||
import { modelsApi } from '../utils/api'
|
||||
import { apiUrl } from '../utils/basePath'
|
||||
@@ -17,7 +17,8 @@ const SECTION_ICONS = {
|
||||
general: 'fa-cog', llm: 'fa-microchip', parameters: 'fa-sliders',
|
||||
templates: 'fa-file-code', functions: 'fa-wrench', reasoning: 'fa-brain',
|
||||
diffusers: 'fa-image', tts: 'fa-volume-up', pipeline: 'fa-code-branch',
|
||||
grpc: 'fa-server', agent: 'fa-robot', mcp: 'fa-plug', other: 'fa-ellipsis-h',
|
||||
grpc: 'fa-server', agent: 'fa-robot', mcp: 'fa-plug', router: 'fa-route', proxy: 'fa-cloud',
|
||||
mitm: 'fa-user-secret', pii: 'fa-user-shield', other: 'fa-ellipsis-h',
|
||||
}
|
||||
|
||||
const SECTION_COLORS = {
|
||||
@@ -25,7 +26,8 @@ const SECTION_COLORS = {
|
||||
templates: 'var(--color-warning)', functions: 'var(--color-info, var(--color-primary))',
|
||||
reasoning: 'var(--color-accent)', diffusers: 'var(--color-warning)', tts: 'var(--color-success)',
|
||||
pipeline: 'var(--color-accent)', grpc: 'var(--color-text-muted)', agent: 'var(--color-primary)',
|
||||
mcp: 'var(--color-accent)', other: 'var(--color-text-muted)',
|
||||
mcp: 'var(--color-accent)', router: 'var(--color-accent)', proxy: 'var(--color-info, var(--color-primary))',
|
||||
mitm: 'var(--color-warning)', pii: 'var(--color-error)', other: 'var(--color-text-muted)',
|
||||
}
|
||||
|
||||
function flattenConfig(obj, prefix = '') {
|
||||
@@ -71,6 +73,10 @@ export default function ModelEditor() {
|
||||
const { name } = useParams()
|
||||
const [searchParams] = useSearchParams()
|
||||
const navigate = useNavigate()
|
||||
const location = useLocation()
|
||||
// Where the Back button returns to. Set by whichever page linked here (see
|
||||
// utils/editorNav); falls back to the historical defaults for direct visits.
|
||||
const backState = location.state && location.state.from ? location.state : null
|
||||
const { addToast } = useOutletContext()
|
||||
const { sections, fields, loading: metaLoading, error: metaError } = useConfigMetadata()
|
||||
|
||||
@@ -89,7 +95,6 @@ export default function ModelEditor() {
|
||||
const [activeSection, setActiveSection] = useState(null)
|
||||
const [tabSwitchWarning, setTabSwitchWarning] = useState(false)
|
||||
|
||||
const contentRef = useRef(null)
|
||||
const sectionRefs = useRef({})
|
||||
|
||||
const vramEstimate = useVramEstimate({
|
||||
@@ -187,25 +192,29 @@ export default function ModelEditor() {
|
||||
}
|
||||
}, [activeSection, activeSections])
|
||||
|
||||
// Scroll tracking
|
||||
// Scroll tracking — the editor used to have its own overflow:auto pane
|
||||
// and listened to that container's scroll; the pane has been removed so
|
||||
// small screens don't have the global footer always clipping into the
|
||||
// form. Scrolling now happens at the window level, and the anchor for
|
||||
// "which section is at the top" is a fixed viewport offset (the sticky
|
||||
// sidebar sits roughly at the top of the editor area).
|
||||
useEffect(() => {
|
||||
const container = contentRef.current
|
||||
if (!container || tab !== 'interactive') return
|
||||
if (tab !== 'interactive') return
|
||||
const onScroll = () => {
|
||||
const containerTop = container.getBoundingClientRect().top
|
||||
const anchorY = 80 // viewport px below which a section is "active"
|
||||
let closest = activeSections[0]?.id
|
||||
let closestDist = Infinity
|
||||
for (const s of activeSections) {
|
||||
const el = sectionRefs.current[s.id]
|
||||
if (el) {
|
||||
const dist = Math.abs(el.getBoundingClientRect().top - containerTop - 8)
|
||||
const dist = Math.abs(el.getBoundingClientRect().top - anchorY)
|
||||
if (dist < closestDist) { closestDist = dist; closest = s.id }
|
||||
}
|
||||
}
|
||||
if (closest) setActiveSection(closest)
|
||||
}
|
||||
container.addEventListener('scroll', onScroll, { passive: true })
|
||||
return () => container.removeEventListener('scroll', onScroll)
|
||||
window.addEventListener('scroll', onScroll, { passive: true })
|
||||
return () => window.removeEventListener('scroll', onScroll)
|
||||
}, [activeSections, configLoading, metaLoading, tab])
|
||||
|
||||
const scrollTo = (id) => {
|
||||
@@ -263,7 +272,9 @@ export default function ModelEditor() {
|
||||
if (!/^[a-zA-Z0-9_.-]+$/.test(modelName.trim())) { addToast('Invalid model name — use only letters, numbers, hyphens, underscores, and dots', 'error'); setSaving(false); return }
|
||||
await modelsApi.importConfig(JSON.stringify(config), 'application/json')
|
||||
addToast('Model created successfully', 'success')
|
||||
navigate(`/app/model-editor/${encodeURIComponent(modelName.trim())}`)
|
||||
// replace: the transient create URL shouldn't sit in history, so
|
||||
// Back (browser or in-page) skips it and returns to the linking page.
|
||||
navigate(`/app/model-editor/${encodeURIComponent(modelName.trim())}`, { replace: true, state: backState })
|
||||
} else {
|
||||
await modelsApi.patchConfig(name, config)
|
||||
setInitialValues(structuredClone(values))
|
||||
@@ -293,9 +304,9 @@ export default function ModelEditor() {
|
||||
addToast('Model created successfully', 'success')
|
||||
try {
|
||||
const parsed = YAML.parse(yamlText)
|
||||
if (parsed?.name) navigate(`/app/model-editor/${encodeURIComponent(parsed.name)}`)
|
||||
else navigate('/app/manage')
|
||||
} catch { navigate('/app/manage') }
|
||||
if (parsed?.name) navigate(`/app/model-editor/${encodeURIComponent(parsed.name)}`, { replace: true, state: backState })
|
||||
else navigate(backState ? backState.from : '/app/manage')
|
||||
} catch { navigate(backState ? backState.from : '/app/manage') }
|
||||
} else {
|
||||
const response = await fetch(apiUrl(`/models/edit/${encodeURIComponent(name)}`), {
|
||||
method: 'POST',
|
||||
@@ -323,7 +334,7 @@ export default function ModelEditor() {
|
||||
// editor URL points at a name that no longer exists on the backend.
|
||||
// Redirect so refreshes and subsequent saves hit the new name.
|
||||
if (parsedName && parsedName !== name) {
|
||||
navigate(`/app/model-editor/${encodeURIComponent(parsedName)}`, { replace: true })
|
||||
navigate(`/app/model-editor/${encodeURIComponent(parsedName)}`, { replace: true, state: backState })
|
||||
}
|
||||
}
|
||||
} catch (err) {
|
||||
@@ -405,9 +416,14 @@ export default function ModelEditor() {
|
||||
<div style={{ display: 'flex', gap: 'var(--spacing-sm)' }}>
|
||||
<button className="btn btn-secondary" onClick={() => {
|
||||
if (isCreateMode && selectedTemplate) { setSelectedTemplate(null); setValues({}); setActiveFieldPaths(new Set()) }
|
||||
else if (backState) navigate(backState.from)
|
||||
else navigate(isCreateMode ? '/app/models' : '/app/manage')
|
||||
}}>
|
||||
<i className="fas fa-arrow-left" /> Back
|
||||
<i className="fas fa-arrow-left" /> Back to {
|
||||
isCreateMode && selectedTemplate ? 'Templates'
|
||||
: backState ? backState.fromLabel
|
||||
: isCreateMode ? 'Models' : 'Manage'
|
||||
}
|
||||
</button>
|
||||
{!showTemplateSelector && tab === 'interactive' && (
|
||||
<button className={`btn ${isDirty ? 'btn-primary' : 'btn-secondary'}`} onClick={handleInteractiveSave} disabled={saving || !isDirty}>
|
||||
@@ -543,12 +559,15 @@ export default function ModelEditor() {
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Two-column layout */}
|
||||
<div style={{ display: 'flex', gap: 0, minHeight: 'calc(100vh - 340px)' }}>
|
||||
{/* Sidebar */}
|
||||
{/* Two-column layout. Both columns flow at body-scroll height —
|
||||
no inner overflow:auto here, so the global footer ends up
|
||||
below the content (like every other page) instead of pinned
|
||||
to the viewport bottom, eating editing space on short screens. */}
|
||||
<div style={{ display: 'flex', gap: 0 }}>
|
||||
{/* Sidebar — sticks to the top of the viewport as the body scrolls. */}
|
||||
<nav style={{
|
||||
width: 180, flexShrink: 0, padding: '0 var(--spacing-sm)',
|
||||
position: 'sticky', top: 0, alignSelf: 'flex-start',
|
||||
position: 'sticky', top: 'var(--spacing-md)', alignSelf: 'flex-start',
|
||||
}}>
|
||||
{activeSections.map(s => (
|
||||
<button
|
||||
@@ -584,10 +603,8 @@ export default function ModelEditor() {
|
||||
|
||||
{/* Content */}
|
||||
<div
|
||||
ref={contentRef}
|
||||
style={{
|
||||
flex: 1, overflow: 'auto', padding: '0 var(--spacing-lg) var(--spacing-xl) var(--spacing-md)',
|
||||
maxHeight: 'calc(100vh - 340px)',
|
||||
flex: 1, padding: '0 var(--spacing-lg) var(--spacing-xl) var(--spacing-md)',
|
||||
}}
|
||||
>
|
||||
{activeSections.length === 0 && (
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { useState, useCallback, useEffect } from 'react'
|
||||
import { useNavigate, useOutletContext } from 'react-router-dom'
|
||||
import { useNavigate, useOutletContext, useLocation } from 'react-router-dom'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { fromState } from '../utils/editorNav'
|
||||
import { modelsApi } from '../utils/api'
|
||||
import { safeHref } from '../utils/url'
|
||||
import { useDebouncedCallback } from '../hooks/useDebounce'
|
||||
@@ -40,6 +41,7 @@ const FILTERS = [
|
||||
export default function Models() {
|
||||
const { addToast } = useOutletContext()
|
||||
const navigate = useNavigate()
|
||||
const location = useLocation()
|
||||
const { t } = useTranslation('models')
|
||||
const { operations } = useOperations()
|
||||
const { resources } = useResources()
|
||||
@@ -286,7 +288,7 @@ export default function Models() {
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
<button className="btn btn-primary btn-sm" onClick={() => navigate('/app/model-editor')}>
|
||||
<button className="btn btn-primary btn-sm" onClick={() => navigate('/app/model-editor', { state: fromState(location, 'Models') })}>
|
||||
<i className="fas fa-plus" /> {t('actions.addModel')}
|
||||
</button>
|
||||
<button className="btn btn-secondary btn-sm" onClick={() => navigate('/app/import-model')}>
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { useState, useRef, useEffect, useCallback, useMemo } from 'react'
|
||||
import { useOutletContext, useNavigate } from 'react-router-dom'
|
||||
import { useOutletContext, useNavigate, useLocation } from 'react-router-dom'
|
||||
import { realtimeApi } from '../utils/api'
|
||||
import { fromState } from '../utils/editorNav'
|
||||
import ModelSelector from '../components/ModelSelector'
|
||||
import ClientMCPDropdown from '../components/ClientMCPDropdown'
|
||||
import { useMCPClient } from '../hooks/useMCPClient'
|
||||
@@ -38,6 +39,7 @@ function upsertAssistant(prev, itemId, text, mode) {
|
||||
export default function Talk() {
|
||||
const { addToast } = useOutletContext()
|
||||
const navigate = useNavigate()
|
||||
const location = useLocation()
|
||||
|
||||
// Pipeline models
|
||||
const [pipelineModels, setPipelineModels] = useState([])
|
||||
@@ -644,7 +646,7 @@ export default function Talk() {
|
||||
disabled={isConnected}
|
||||
searchPlaceholder="Search pipeline models..."
|
||||
/>
|
||||
<button className="btn btn-secondary btn-sm" onClick={() => navigate('/app/model-editor?template=pipeline')}
|
||||
<button className="btn btn-secondary btn-sm" onClick={() => navigate('/app/model-editor?template=pipeline', { state: fromState(location, 'Talk') })}
|
||||
style={{ marginTop: 'var(--spacing-xs)' }}>
|
||||
<i className="fas fa-plus" style={{ marginRight: 'var(--spacing-xs)' }} /> Create Pipeline Model
|
||||
</button>
|
||||
@@ -724,7 +726,7 @@ export default function Talk() {
|
||||
)}
|
||||
{selectedModelInfo && !isConnected && (
|
||||
<div style={{ marginBottom: 'var(--spacing-md)' }}>
|
||||
<button className="btn btn-secondary btn-sm" onClick={() => navigate(`/app/model-editor/${encodeURIComponent(selectedModel)}`)}>
|
||||
<button className="btn btn-secondary btn-sm" onClick={() => navigate(`/app/model-editor/${encodeURIComponent(selectedModel)}`, { state: fromState(location, 'Talk') })}>
|
||||
<i className="fas fa-pen-to-square" style={{ marginRight: 'var(--spacing-xs)' }} />
|
||||
{selectedModelInfo.self_contained ? ' Edit Model Config' : ' Edit Pipeline'}
|
||||
</button>
|
||||
|
||||
@@ -74,6 +74,7 @@ const TYPE_COLORS = {
|
||||
tokenize: { bg: 'var(--color-secondary-light)', color: 'var(--color-text-muted)' },
|
||||
detection: { bg: 'var(--color-info-light)', color: 'var(--color-data-8)' },
|
||||
model_load: { bg: 'var(--color-error-light)', color: 'var(--color-data-2)' },
|
||||
vector_store: { bg: 'var(--color-accent-light)', color: 'var(--color-data-7)' },
|
||||
}
|
||||
|
||||
function typeBadgeStyle(type) {
|
||||
|
||||
46
core/http/react-ui/src/utils/cmGoTemplate.js
vendored
Normal file
46
core/http/react-ui/src/utils/cmGoTemplate.js
vendored
Normal file
@@ -0,0 +1,46 @@
|
||||
import { StreamLanguage } from '@codemirror/language'
|
||||
|
||||
// Go text/template keywords valid inside an action `{{ ... }}`.
|
||||
const KEYWORDS = new Set([
|
||||
'if', 'else', 'end', 'range', 'with', 'define', 'template',
|
||||
'block', 'break', 'continue', 'nil', 'true', 'false',
|
||||
])
|
||||
|
||||
// Minimal Go text/template highlighter: distinguishes literal text from
|
||||
// action bodies inside `{{ ... }}`. Highlighting only — it does not
|
||||
// validate template grammar.
|
||||
export const goTemplate = StreamLanguage.define({
|
||||
startState() {
|
||||
return { inAction: false }
|
||||
},
|
||||
token(stream, state) {
|
||||
if (!state.inAction) {
|
||||
if (stream.match('{{')) {
|
||||
state.inAction = true
|
||||
return 'meta'
|
||||
}
|
||||
while (!stream.eol()) {
|
||||
if (stream.match('{{', false)) break
|
||||
stream.next()
|
||||
}
|
||||
return null
|
||||
}
|
||||
|
||||
if (stream.match('}}')) {
|
||||
state.inAction = false
|
||||
return 'meta'
|
||||
}
|
||||
if (stream.eatSpace()) return null
|
||||
if (stream.match(/^-(?=\s)/) || stream.match(/^[|()]/)) return 'operator'
|
||||
if (stream.match(/^"(?:[^"\\]|\\.)*"/)) return 'string'
|
||||
if (stream.match(/^`[^`]*`/)) return 'string'
|
||||
if (stream.match(/^\$[a-zA-Z0-9_]*/)) return 'variable-2'
|
||||
if (stream.match(/^\.[a-zA-Z0-9_.]*/)) return 'property'
|
||||
if (stream.match(/^[0-9]+(\.[0-9]+)?/)) return 'number'
|
||||
if (stream.match(/^[a-zA-Z_][a-zA-Z0-9_]*/)) {
|
||||
return KEYWORDS.has(stream.current()) ? 'keyword' : 'variable'
|
||||
}
|
||||
stream.next()
|
||||
return null
|
||||
},
|
||||
})
|
||||
15
core/http/react-ui/src/utils/editorNav.js
vendored
Normal file
15
core/http/react-ui/src/utils/editorNav.js
vendored
Normal file
@@ -0,0 +1,15 @@
|
||||
// Navigation context for the Model Editor.
|
||||
//
|
||||
// Many pages link into the Model Editor (Models, Manage, Chat, Talk, Agent
|
||||
// Jobs, Middleware…). Its in-page Back button used to navigate to a hardcoded
|
||||
// route, so it always dumped you on the same page regardless of where you came
|
||||
// from. To fix that, every linker passes this object as react-router location
|
||||
// state; the editor reads it and returns you to the exact page that linked
|
||||
// here, labelled "Back to <label>".
|
||||
//
|
||||
// `location` is the source page's useLocation() value, so `from` captures the
|
||||
// full path including any sub-route or query string — returning lands you
|
||||
// where you actually were, not just on the section root.
|
||||
export function fromState(location, label) {
|
||||
return { from: location.pathname + location.search, fromLabel: label }
|
||||
}
|
||||
@@ -58,13 +58,14 @@ func RegisterAnthropicRoutes(app *echo.Echo,
|
||||
middleware.AnthropicProbe,
|
||||
router.SourceAnthropic,
|
||||
middleware.ClassifierDeps{
|
||||
Scorer: application.Scorer,
|
||||
Embedder: application.Embedder,
|
||||
VectorStore: application.VectorStore,
|
||||
Reranker: application.Reranker,
|
||||
ModelLookup: application.ModelConfigLookup(),
|
||||
Registry: application.RouterClassifierRegistry(),
|
||||
Evaluator: application.TemplatesEvaluator(),
|
||||
Scorer: application.Scorer,
|
||||
TokenCounter: application.TokenCounter,
|
||||
Embedder: application.Embedder,
|
||||
VectorStore: application.VectorStore,
|
||||
Reranker: application.Reranker,
|
||||
ModelLookup: application.ModelConfigLookup(),
|
||||
Registry: application.RouterClassifierRegistry(),
|
||||
Evaluator: application.TemplatesEvaluator(),
|
||||
},
|
||||
),
|
||||
middleware.AdmissionControl(application.AdmissionLimiter(), application.PIIEvents()),
|
||||
|
||||
@@ -135,13 +135,14 @@ func RegisterMiddlewareRoutes(e *echo.Echo, app *application.Application) {
|
||||
app.ModelConfigLoader(),
|
||||
app.ApplicationConfig(),
|
||||
middleware.ClassifierDeps{
|
||||
Scorer: app.Scorer,
|
||||
Embedder: app.Embedder,
|
||||
VectorStore: app.VectorStore,
|
||||
Reranker: app.Reranker,
|
||||
ModelLookup: app.ModelConfigLookup(),
|
||||
Registry: app.RouterClassifierRegistry(),
|
||||
Evaluator: app.TemplatesEvaluator(),
|
||||
Scorer: app.Scorer,
|
||||
TokenCounter: app.TokenCounter,
|
||||
Embedder: app.Embedder,
|
||||
VectorStore: app.VectorStore,
|
||||
Reranker: app.Reranker,
|
||||
ModelLookup: app.ModelConfigLookup(),
|
||||
Registry: app.RouterClassifierRegistry(),
|
||||
Evaluator: app.TemplatesEvaluator(),
|
||||
},
|
||||
)
|
||||
e.POST("/api/router/decide", func(c echo.Context) error {
|
||||
@@ -220,8 +221,8 @@ func buildRouterStatus(app *application.Application) map[string]any {
|
||||
}
|
||||
|
||||
out := map[string]any{
|
||||
"configured": hasAny,
|
||||
"models": models,
|
||||
"configured": hasAny,
|
||||
"models": models,
|
||||
"recent_decision_count": recentCount,
|
||||
"available_classifiers": []string{router.ClassifierScore},
|
||||
}
|
||||
|
||||
@@ -71,13 +71,14 @@ func RegisterOpenAIRoutes(app *echo.Echo,
|
||||
middleware.OpenAIProbe,
|
||||
router.SourceChat,
|
||||
middleware.ClassifierDeps{
|
||||
Scorer: application.Scorer,
|
||||
Embedder: application.Embedder,
|
||||
VectorStore: application.VectorStore,
|
||||
Reranker: application.Reranker,
|
||||
ModelLookup: application.ModelConfigLookup(),
|
||||
Registry: application.RouterClassifierRegistry(),
|
||||
Evaluator: application.TemplatesEvaluator(),
|
||||
Scorer: application.Scorer,
|
||||
TokenCounter: application.TokenCounter,
|
||||
Embedder: application.Embedder,
|
||||
VectorStore: application.VectorStore,
|
||||
Reranker: application.Reranker,
|
||||
ModelLookup: application.ModelConfigLookup(),
|
||||
Registry: application.RouterClassifierRegistry(),
|
||||
Evaluator: application.TemplatesEvaluator(),
|
||||
},
|
||||
),
|
||||
// Admission control runs after RouteModel so the SERVED
|
||||
|
||||
@@ -117,10 +117,10 @@ func RegisterPIIRoutes(e *echo.Echo, app *application.Application) {
|
||||
}
|
||||
res := app.PIIRedactor().Redact(body.Text)
|
||||
return c.JSON(http.StatusOK, map[string]any{
|
||||
"redacted": res.Redacted,
|
||||
"spans": res.Spans,
|
||||
"blocked": res.Blocked,
|
||||
"local_only": res.LocalOnly,
|
||||
"redacted": res.Redacted,
|
||||
"spans": res.Spans,
|
||||
"blocked": res.Blocked,
|
||||
"masked": res.Masked,
|
||||
})
|
||||
})
|
||||
|
||||
@@ -142,12 +142,12 @@ func RegisterPIIRoutes(e *echo.Echo, app *application.Application) {
|
||||
|
||||
// PutPIIPatternActionEndpoint godoc
|
||||
// @Summary Change a pattern's action in-process
|
||||
// @Description Mutates the named pattern's action (mask|block|route_local). Transient — restored to YAML defaults on restart. Admin-only.
|
||||
// @Description Mutates the named pattern's action (mask|block|allow). Transient — restored to YAML defaults on restart. Admin-only.
|
||||
// @Tags pii
|
||||
// @Accept json
|
||||
// @Produce json
|
||||
// @Param id path string true "Pattern id"
|
||||
// @Param body body map[string]string true "JSON {\"action\":\"mask|block|route_local\"}"
|
||||
// @Param body body map[string]string true "JSON {\"action\":\"mask|block|allow\"}"
|
||||
// @Success 200 {object} map[string]interface{}
|
||||
// @Router /api/pii/patterns/{id} [put]
|
||||
e.PUT("/api/pii/patterns/:id", func(c echo.Context) error {
|
||||
|
||||
Reference in New Issue
Block a user