Files
LocalAI/pkg/model/backend_log_store_test.go
Ettore Di Giacinto 091eda8d70 feat: react chat redesign (#9616)
* feat(react-ui): redesign chat — popover history, focus on send, density pass

Replace the persistent 260px conversation sidebar with a Cmd/Ctrl+K
popover (ChatsMenu) so the conversation owns the page. Once a chat has
at least one message we auto-collapse the global app rail and fade
non-essential header chrome; Esc gives the user back the full chrome
for the rest of the session.

Move Canvas mode and the MCP dropdown into the input wrapper as mode
chips — they describe what's armed for the next message and now live
where the user composes. The chat header drops to Chats · title ·
ModelSelector · overflow · settings, and an overflow menu carries
admin-only Manage mode along with Info / Edit / Export / Clear.

Density pass: tighter header (40px), smaller avatars with the assistant
left-border accent doing the work, 88% bubble width, modern
field-sizing on the textarea, 32px send/stop buttons.

Empty state now surfaces a Recent strip (top 4 non-empty chats) and a
Cmd+K hint, replacing the discoverability the persistent sidebar used
to provide.

Signed-off-by: Ettore Di Giacinto <mudler@localai.io>
Assisted-by: Claude:claude-opus-4-7

* feat(react-ui): chat input chips, slimmer menu, focus mode polish

Move Canvas mode and the MCP dropdown into the input wrapper as compact
mode chips — they describe what's armed for the next message and now
sit where the user composes. The MCP popover flips upward when anchored
to the input row so it stays on-screen.

Eliminate the chat header overflow ("…") menu entirely; relocate each
item to its semantic home so users don't have to remember a
miscellany drawer:

- Manage mode toggle → top of the Settings drawer, alongside the
  other sticky chat knobs. The shield next to the title still
  signals state at a glance.
- Model info / Edit config → small admin-only "ⓘ" button next to the
  ModelSelector; the existing model-info panel now hosts the Edit
  config link.
- Export as Markdown → per-row hover action in ChatsMenu, so it works
  for any chat (not just the active one).
- Clear chat history → destructive button at the bottom of the
  Settings drawer.

Make the Sidebar listen to its own `sidebar-collapse` event so the
chat's focus mode actually shrinks the rail (it previously only
flipped the layout class, leaving the sidebar element at full width
and overlapping the chat). Drop the focus-mode toast — the visual
shift is enough; the toast was noise.

Define `--color-text-tertiary` in both themes; without it metadata
text (recent strip timestamps and a few other sites) was inheriting
the platform default, which read as black on the dark surface.

Signed-off-by: Ettore Di Giacinto <mudler@localai.io>
Assisted-by: Claude:claude-opus-4-7

* fix(model/log-store): close merged channel exactly once; clean up Remove

Two latent races in BackendLogStore.Subscribe could panic under load
(distributed e2e test triggered "send on closed channel" at
backend_log_store.go:288):

1. The aggregated path closed the merged channel `ch` from two
   places — the fan-in waiter goroutine (after all source channels
   drained) and unsubscribe(). When unsubscribe ran while a fan-in
   goroutine was mid-flight on `ch <- line`, the close beat the send
   and the runtime panicked. Now `ch` is closed by exactly one
   goroutine: the waiter that observes all fan-in goroutines finish.
   unsubscribe() only closes the per-buffer source channels — the
   for-range in each fan-in goroutine then exits naturally and the
   waiter takes care of the merged close.

2. Remove() closed every subscriber channel but didn't delete the
   entries from the subscribers map, so a concurrent unsubscribe()
   would call close() again on the already-closed channel
   ("close of closed channel"). Clear the map entry while closing.

Add a regression test that hammers AppendLine concurrently with
Subscribe + unsubscribe + Remove; the race detector catches both
classes of regression.

Signed-off-by: Ettore Di Giacinto <mudler@localai.io>
Assisted-by: Claude:claude-opus-4-7

* test(model/log-store): port backend log store tests to ginkgo

Bring backend_log_store_test.go in line with the rest of pkg/model
(loader_test, watchdog_test, store_test): same external test package
(`model_test`), same ginkgo + gomega imports, same Describe/It
nesting around the public API. Behaviour is unchanged — the four
existing scenarios plus the unsubscribe race regression all run as
specs under the existing `TestModel` suite.

Signed-off-by: Ettore Di Giacinto <mudler@localai.io>
Assisted-by: Claude:claude-opus-4-7

---------

Signed-off-by: Ettore Di Giacinto <mudler@localai.io>
2026-04-29 22:33:26 +02:00

202 lines
6.4 KiB
Go

package model_test
import (
"fmt"
"sort"
"sync"
"time"
"github.com/mudler/LocalAI/pkg/model"
. "github.com/onsi/ginkgo/v2"
. "github.com/onsi/gomega"
)
var _ = Describe("BackendLogStore", func() {
var s *model.BackendLogStore
BeforeEach(func() {
s = model.NewBackendLogStore(100)
})
// Pins the multi-replica behavior added when the worker's process key
// changed from `modelID` to `modelID#replicaIndex`. The frontend still
// asks for logs of `qwen3-0.6b`, but the actual buffers live under
// `qwen3-0.6b#0` and `qwen3-0.6b#1` — without aggregation, operators
// see no logs in distributed mode.
Describe("GetLines", func() {
It("aggregates lines across replicas when called with a bare model ID", func() {
// Two replicas of the same model, plus a different model that should
// never leak in. AppendLine timestamps via time.Now(), so add small
// sleeps so the merged order is deterministic.
s.AppendLine("qwen3-0.6b#0", "stderr", "r0-line-1")
time.Sleep(2 * time.Millisecond)
s.AppendLine("qwen3-0.6b#1", "stderr", "r1-line-1")
time.Sleep(2 * time.Millisecond)
s.AppendLine("qwen3-0.6b#0", "stdout", "r0-line-2")
time.Sleep(2 * time.Millisecond)
s.AppendLine("other-model#0", "stderr", "should-not-appear")
texts := []string{}
for _, l := range s.GetLines("qwen3-0.6b") {
texts = append(texts, l.Text)
}
Expect(texts).To(Equal([]string{"r0-line-1", "r1-line-1", "r0-line-2"}))
})
It("returns only the matching replica when called with a full process key", func() {
s.AppendLine("qwen3-0.6b#0", "stderr", "r0-line-1")
s.AppendLine("qwen3-0.6b#1", "stderr", "r1-line-1")
s.AppendLine("qwen3-0.6b#0", "stdout", "r0-line-2")
lines := s.GetLines("qwen3-0.6b#0")
Expect(lines).To(HaveLen(2))
for _, l := range lines {
Expect(l.Text).NotTo(Equal("r1-line-1"), "replica 0 must not include replica 1's lines")
}
})
It("returns an empty slice for an unknown model", func() {
Expect(s.GetLines("never-loaded-model")).To(BeEmpty())
})
})
// Confirms the /v1/backend-logs listing shows one entry per model, not
// one per replica — operators don't think about replica indexes; they
// pick a model.
Describe("ListModels", func() {
It("dedupes replicas and keeps non-replica suffixes intact", func() {
s.AppendLine("model-a#0", "stderr", "x")
s.AppendLine("model-a#1", "stderr", "y")
s.AppendLine("model-b#0", "stderr", "z")
s.AppendLine("model-c", "stderr", "no-replica-suffix") // back-compat for non-distributed
got := s.ListModels()
sort.Strings(got)
Expect(got).To(Equal([]string{"model-a", "model-b", "model-c"}))
})
})
Describe("Subscribe", func() {
// Confirms the WebSocket streaming path (the live tail UI) receives
// lines from every replica when the caller subscribes by bare modelID.
It("aggregates live lines across replicas", func() {
// Pre-create both replica buffers so Subscribe can find them.
s.AppendLine("model-a#0", "stderr", "preload-r0")
s.AppendLine("model-a#1", "stderr", "preload-r1")
ch, unsubscribe := s.Subscribe("model-a")
defer unsubscribe()
// Emit one line per replica after subscribing.
s.AppendLine("model-a#0", "stderr", "live-r0")
s.AppendLine("model-a#1", "stderr", "live-r1")
// Different model — must not appear.
s.AppendLine("model-b#0", "stderr", "leak-check")
seen := map[string]bool{}
deadline := time.After(500 * time.Millisecond)
for len(seen) < 2 {
select {
case line, ok := <-ch:
Expect(ok).To(BeTrue(), "subscribe channel closed early; saw %v", seen)
seen[line.Text] = true
Expect(line.Text).NotTo(Equal("leak-check"), "subscribe leaked a line from a different model")
case <-deadline:
Fail(fmt.Sprintf("timed out waiting for fan-in lines; saw %v", seen))
}
}
Expect(seen).To(HaveKey("live-r0"))
Expect(seen).To(HaveKey("live-r1"))
})
// Pins that callers passing the full process key get only that
// replica — useful for a future per-replica logs view.
It("filters to a single replica when called with a full process key", func() {
ch, unsubscribe := s.Subscribe("model-a#0")
defer unsubscribe()
s.AppendLine("model-a#0", "stderr", "wanted")
s.AppendLine("model-a#1", "stderr", "unwanted")
select {
case line := <-ch:
Expect(line.Text).To(Equal("wanted"))
case <-time.After(500 * time.Millisecond):
Fail("no line received from replica-scoped subscription")
}
// Drain quickly: confirm replica 1 didn't leak in.
select {
case line := <-ch:
Fail(fmt.Sprintf("replica-scoped sub leaked line from replica 1: %q", line.Text))
case <-time.After(50 * time.Millisecond):
}
})
// Pins the panic CI hit: the aggregated fan-in goroutine sending
// on the merged channel raced with unsubscribe closing it. Hammer
// AppendLine concurrently with Subscribe + unsubscribe + Remove
// to make sure neither "send on closed channel" nor "close of
// closed channel" panics can resurface. The race detector should
// catch any regression.
It("survives concurrent unsubscribe and Remove without panicking", func() {
// Pre-create replica buffers so Subscribe finds them.
for r := 0; r < 3; r++ {
s.AppendLine(fmt.Sprintf("model-x#%d", r), "stderr", "preload")
}
stop := make(chan struct{})
var wg sync.WaitGroup
// Writers — keep the per-buffer channels under constant pressure.
for r := 0; r < 3; r++ {
wg.Add(1)
go func(r int) {
defer wg.Done()
id := fmt.Sprintf("model-x#%d", r)
for {
select {
case <-stop:
return
default:
s.AppendLine(id, "stderr", "burst")
}
}
}(r)
}
// Subscribers — repeatedly Subscribe and unsubscribe while writers run.
for w := 0; w < 4; w++ {
wg.Add(1)
go func() {
defer wg.Done()
for i := 0; i < 200; i++ {
ch, unsubscribe := s.Subscribe("model-x")
// Drain a couple of lines, then unsubscribe.
select {
case <-ch:
case <-time.After(2 * time.Millisecond):
}
unsubscribe()
}
}()
}
// Buffer reaper — exercises the Remove path while subscribers are live.
wg.Add(1)
go func() {
defer wg.Done()
for i := 0; i < 50; i++ {
s.Remove("model-x#0")
s.AppendLine("model-x#0", "stderr", "respawn")
time.Sleep(time.Millisecond)
}
}()
time.Sleep(150 * time.Millisecond)
close(stop)
wg.Wait()
})
})
})