mirror of
https://github.com/mudler/LocalAI.git
synced 2026-05-16 20:52:08 -04:00
* 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>
202 lines
6.4 KiB
Go
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()
|
|
})
|
|
})
|
|
})
|