Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
45 changes: 45 additions & 0 deletions core/http/react-ui/e2e/agent-kb-models.spec.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,45 @@
import { test, expect } from './coverage-fixtures.js'

test('agent knowledge-base selectors filter models and save their selections', async ({ page }) => {
await page.route('**/api/agents/config/metadata', route => route.fulfill({
json: { Fields: [
{ name: 'name', label: 'Name', type: 'text', required: true, tags: { section: 'BasicInfo' } },
{ name: 'model', label: 'Reasoning Model', type: 'text', defaultValue: 'chat-model', tags: { section: 'ModelSettings' } },
{ name: 'embedding_model', label: 'Embedding Model', type: 'text', tags: { section: 'ModelSettings' } },
{ name: 'reranker_model', label: 'Reranker Model', type: 'text', tags: { section: 'ModelSettings' } },
] },
}))
await page.route('**/api/models/capabilities', route => route.fulfill({
json: { data: [
{ id: 'chat-model', capabilities: ['FLAG_CHAT'] },
{ id: 'embed-model', capabilities: ['FLAG_EMBEDDINGS'] },
{ id: 'rank-model', capabilities: ['FLAG_RERANK'] },
] },
}))
await page.route('**/api/agents', route => route.fulfill({
json: route.request().method() === 'POST' ? {} : { agents: [], statuses: {} },
}))

await page.goto('/app/agents/new')
await page.locator('#field-name').fill('research')
await page.getByRole('button', { name: /^Model Settings/ }).click()

const embedding = page.locator('.form-row').filter({ has: page.getByText('Embedding Model', { exact: true }) })
const reranker = page.locator('.form-row').filter({ has: page.getByText('Reranker Model', { exact: true }) })
await expect(embedding.locator('input')).toHaveValue('')
await expect(reranker.locator('input')).toHaveValue('')

await embedding.locator('input').click()
await expect(embedding.getByRole('option')).toHaveCount(1)
await embedding.getByRole('option', { name: /embed-model/ }).click()
await reranker.locator('input').click()
await expect(reranker.getByRole('option')).toHaveCount(1)
await reranker.getByRole('option', { name: /rank-model/ }).click()

const saved = page.waitForRequest(request => request.method() === 'POST' && new URL(request.url()).pathname === '/api/agents')
await page.getByRole('button', { name: /Create Agent$/ }).first().click()
expect((await saved).postDataJSON()).toMatchObject({
name: 'research', model: 'chat-model', embedding_model: 'embed-model', reranker_model: 'rank-model',
})
await expect(page).toHaveURL(/\/app\/agents$/)
})
6 changes: 4 additions & 2 deletions core/http/react-ui/e2e/chat-history.spec.js
Original file line number Diff line number Diff line change
Expand Up @@ -18,13 +18,15 @@ async function openMenu(page) {
}

test.describe('Chat conversations menu', () => {
const now = new Date('2026-10-09T12:00:00Z').getTime()
test.beforeEach(async ({ page }) => {
await page.clock.setFixedTime(now)
await mockChat(page, { models: [
{ id: 'qwen3-8b-instruct', capabilities: ['FLAG_CHAT'] },
{ id: 'gemma-4-e4b-it-qat-q4_0', capabilities: ['FLAG_CHAT'] },
{ id: 'qwen2.5-vl-7b-instruct', capabilities: ['FLAG_CHAT'] },
], loaded: ['qwen3-8b-instruct'] })
await openChat(page, sampleChats())
await openChat(page, sampleChats(now))
await expect(page.getByTestId('chat-message').first()).toBeVisible()
})

Expand Down Expand Up @@ -153,7 +155,7 @@ test.describe('Chat conversations menu', () => {
})

test('the undo time runs out by itself after six seconds', async ({ page }) => {
await page.clock.install()
await page.clock.install({ time: now })
await page.reload()
await page.getByTestId('chat-composer').waitFor()
await openMenu(page)
Expand Down
10 changes: 6 additions & 4 deletions core/http/react-ui/e2e/home-console.spec.js
Original file line number Diff line number Diff line change
Expand Up @@ -317,9 +317,11 @@ test.describe('Home in a cluster', () => {
})

test.describe('Home resume list', () => {
const now = new Date('2026-10-09T12:00:00Z').getTime()
test.beforeEach(async ({ page }) => {
await page.clock.setFixedTime(now)
await mockHome(page)
await seedChats(page)
await seedChats(page, sampleChats(now))
})

test('groups conversations by day, one card per day', async ({ page }) => {
Expand Down Expand Up @@ -408,7 +410,7 @@ test.describe('Home resume list', () => {
})

test('without undo the delete becomes final when the time ends', async ({ page }) => {
await page.clock.install()
await page.clock.install({ time: now })
await ready(page)
const row = page.getByTestId('home-conversation').filter({ hasText: 'invoice' })
await row.hover()
Expand All @@ -430,12 +432,12 @@ test.describe('Home resume list', () => {
})

test('old conversations wait behind one button', async ({ page }) => {
const chats = sampleChats()
const chats = sampleChats(now)
const old = Array.from({ length: 8 }, (_, i) => ({
...chats[0],
id: `old-${i}`,
name: `Old chat ${i}`,
updatedAt: Date.now() - (10 + i) * 24 * 60 * 60 * 1000,
updatedAt: now - (10 + i) * 24 * 60 * 60 * 1000,
}))
await page.addInitScript(([key, data]) => localStorage.setItem(key, JSON.stringify(data)),
[CHATS_KEY, { chats: [...chats, ...old], activeChatId: chats[0].id }])
Expand Down
8 changes: 5 additions & 3 deletions core/http/react-ui/e2e/model-page.spec.js
Original file line number Diff line number Diff line change
Expand Up @@ -199,15 +199,17 @@ test.describe('Model page - walking and going back', () => {
const { state } = await setup(page)
await expect(rows(page).first()).toBeVisible({ timeout: 15_000 })
await page.waitForTimeout(500)
const before = state.listingRequests.length
// Facet counts load in the background; they do not reload the list.
const listings = () => state.listingRequests.filter(search => new URLSearchParams(search).get('items') !== '1')
const before = listings().length
await page.locator('[data-entity="qwen3-8b-instruct"] [data-row-open]').click()
await expect(name(page)).toHaveText('qwen3-8b-instruct')
await page.keyboard.press('Escape')
await expect(rows(page).first()).toBeVisible()
await page.waitForTimeout(500)
// The page itself looks its own entry up once; the list asks for nothing.
expect(state.listingRequests.length - before).toBeLessThanOrEqual(1)
expect(state.listingRequests.slice(before).every(search => search.includes('term=qwen3-8b-instruct'))).toBe(true)
expect(listings().length - before).toBeLessThanOrEqual(1)
expect(listings().slice(before).every(search => search.includes('term=qwen3-8b-instruct'))).toBe(true)
})

test('changing tabs keeps the walker, and Back still leaves for the list in one step', async ({ page }) => {
Expand Down
9 changes: 4 additions & 5 deletions core/http/react-ui/e2e/models-cleanup.spec.js
Original file line number Diff line number Diff line change
Expand Up @@ -92,11 +92,10 @@ test.describe('Cleanup sheet - what it knows and what it does not', () => {

test('each tier is ranked by size and a model with no known size goes last', async ({ page }) => {
await openSheet(page)
const names = await tier(page, 'call').locator('.ledger-cr__name').evaluateAll(
(els) => els.map((e) => e.firstChild.textContent.trim()),
)
expect(names[0]).toBe('llama-3.3-70b-instruct-iq2')
expect(names[names.length - 1]).toBe('my-finetune-q4')
// Estimates arrive after the sheet becomes interactive and can reorder it.
const names = tier(page, 'call').locator('.ledger-cr__name')
await expect(names.first()).toContainText('llama-3.3-70b-instruct-iq2')
await expect(names.last()).toContainText('my-finetune-q4')
await expect(tier(page, 'call').locator('li').last()).toContainText('size unknown')
await expect(tier(page, 'call').locator('li').first()).toContainText(`${gb1(sizeOf('llama-3.3-70b-instruct-iq2'))} GB`)
})
Expand Down
3 changes: 1 addition & 2 deletions core/http/react-ui/e2e/studio-work.spec.js
Original file line number Diff line number Diff line change
Expand Up @@ -135,9 +135,8 @@ test.describe('Your work', () => {

test('the masonry uses fewer columns on a narrow screen', async ({ page }) => {
await open(page)
const wide = await page.locator('.studio-masonry').getAttribute('data-columns')
await expect.poll(async () => Number(await page.locator('.studio-masonry').getAttribute('data-columns'))).toBeGreaterThan(1)
await page.setViewportSize({ width: 390, height: 844 })
await expect.poll(() => page.locator('.studio-masonry').getAttribute('data-columns')).toBe('1')
expect(Number(wide)).toBeGreaterThan(1)
})
})
3 changes: 2 additions & 1 deletion core/http/react-ui/e2e/traffic-models-host.spec.js
Original file line number Diff line number Diff line change
Expand Up @@ -17,7 +17,8 @@ test.describe('Traffic models', () => {
await expect(bge.locator('td.dk-num').last()).toHaveText('-')
// The failed load of flux-dev is in the backend-operation buffer.
await expect(page.locator('tr[data-entity="flux-dev"]')).toContainText('1')
await expect(page.getByText(/p50|p95|p99/)).toHaveCount(0)
// A row's concatenated text can contain llama-cpp followed by 99 requests.
await expect(page.getByText(/\bp(?:50|95|99)\b/i)).toHaveCount(0)
})

test('a row opens in place with the split, the buffer and the links', async ({ page }) => {
Expand Down
6 changes: 4 additions & 2 deletions core/http/react-ui/src/components/models/page/model-page.css
Original file line number Diff line number Diff line change
Expand Up @@ -262,15 +262,17 @@ a.modelpage-chip { cursor: pointer; text-decoration: none; color: inherit; }
.modelpage-keys { display: none; }
}

.modelpage-table .dk-table-sub { max-width: none; white-space: normal; overflow-wrap: anywhere; }
.modelpage-table .dk-table-sub .modelpage-inline-badge:first-child { margin-left: 0; }
.modelpage-table .dk-table .dk-table-sub { max-width: none; white-space: normal; overflow-wrap: anywhere; }
.modelpage-table .dk-table .dk-table-sub .modelpage-inline-badge:first-child { margin-left: 0; }

/* A grid child is as wide as its content unless told otherwise; a table inside
one would push the whole page sideways instead of scrolling in its own box. */
.modelpage-variants > section, .modelpage-usage > section, .modelpage-columns > *, .modelpage-fit > * { min-width: 0; }
.modelpage-variants__size { display: none; }
@media (max-width: 640px) {
.modelpage-variants__size { display: inline; }
/* Leave room for Fit and Install while long build names wrap. */
.modelpage-table .dk-table .dk-table-sub { max-width: 14ch; }
.modelpage-table .dk-table-actions .dk-btn { padding-inline: var(--dk-space-2); }
}

Expand Down
7 changes: 4 additions & 3 deletions core/http/react-ui/src/pages/AgentCreate.jsx
Original file line number Diff line number Diff line change
Expand Up @@ -13,7 +13,7 @@ import './library.css'
import SearchableModelSelect from '../components/SearchableModelSelect'
// eslint-disable-next-line no-unused-vars
import UnsavedChangesGuard from '../components/UnsavedChangesGuard'
import { CAP_CHAT, CAP_TRANSCRIPT, CAP_TTS } from '../utils/capabilities'
import { CAP_CHAT, CAP_TRANSCRIPT, CAP_TTS, CAP_EMBEDDINGS, CAP_RERANK } from '../utils/capabilities'
// eslint-disable-next-line no-unused-vars
import Toggle from '../components/Toggle'
// eslint-disable-next-line no-unused-vars
Expand Down Expand Up @@ -128,14 +128,15 @@ function FormField({ field, value, onChange, disabled }) {
</SettingRow>
)
default: {
const isModelField = /^(model|multimodal_model|transcription_model|tts_model|embedding_model)$/.test(field.name)
const isModelField = /^(model|multimodal_model|transcription_model|tts_model|embedding_model|reranker_model)$/.test(field.name)
if (isModelField && !disabled && !field.disabled) {
const capabilityMap = {
model: CAP_CHAT,
multimodal_model: CAP_CHAT,
transcription_model: CAP_TRANSCRIPT,
tts_model: CAP_TTS,
embedding_model: undefined,
embedding_model: CAP_EMBEDDINGS,
reranker_model: CAP_RERANK,
}
return (
<SettingRow label={label} description={field.helpText}>
Expand Down
10 changes: 4 additions & 6 deletions core/services/agentpool/agent_pool.go
Original file line number Diff line number Diff line change
Expand Up @@ -191,6 +191,7 @@ func (s *AgentPoolService) Start(ctx context.Context) error {
func (s *AgentPoolService) buildCollectionsConfig(apiURL, apiKey, collectionDBPath, fileAssets string) *collections.Config {
cfg := s.appConfig.AgentPool
return &collections.Config{
ModelSettings: s.collectionModelSettings(""),
LLMAPIURL: apiURL,
LLMAPIKey: apiKey,
LLMModel: cfg.DefaultModel,
Expand All @@ -209,6 +210,7 @@ func (s *AgentPoolService) buildCollectionsConfig(apiURL, apiKey, collectionDBPa
// Skills and collections are still initialized for the frontend UI.
func (s *AgentPoolService) startDistributed(ctx context.Context, apiURL, apiKey string) error {
cfg := s.appConfig.AgentPool
s.configBackend = newDistributedAgentConfigBackend(s, s.distributed.agentStore)

// State dir for skills and outputs
stateDir := cmp.Or(cfg.StateDir, s.appConfig.DataPath, s.appConfig.DynamicConfigsDir, "agents")
Expand Down Expand Up @@ -264,9 +266,6 @@ func (s *AgentPoolService) startDistributed(ctx context.Context, apiURL, apiKey
go scheduler.Start(ctx)
}

// Wire the distributed config backend
s.configBackend = newDistributedAgentConfigBackend(s, s.distributed.agentStore)

xlog.Info("Agent pool started in distributed mode (frontend dispatcher only)", "apiURL", apiURL, "stateDir", stateDir)
return nil
}
Expand Down Expand Up @@ -339,6 +338,7 @@ func (s *AgentPoolService) startLocalAGI(_ context.Context, cfg config.AgentPool
return fmt.Errorf("failed to create agent pool: %w", err)
}
s.localAGI.pool = pool
s.configBackend = newLocalAgentConfigBackend(s)

// Create in-process collections backend and RAG provider
collectionsCfg := s.buildCollectionsConfig(apiURL, apiKey, collectionDBPath, fileAssets)
Expand All @@ -363,9 +363,6 @@ func (s *AgentPoolService) startLocalAGI(_ context.Context, cfg config.AgentPool
xlog.Error("Failed to start agent pool", "error", err)
}

// Wire the local config backend
s.configBackend = newLocalAgentConfigBackend(s)

xlog.Info("Agent pool started (standalone/LocalAGI mode)", "stateDir", stateDir, "apiURL", apiURL)
return nil
}
Expand Down Expand Up @@ -709,6 +706,7 @@ func (s *AgentPoolService) ExportAgent(name string) ([]byte, error) {

// SetUserServicesManager sets the user services manager for per-user scoping.
func (s *AgentPoolService) SetUserServicesManager(usm *UserServicesManager) {
usm.collectionModelSettings = s.collectionModelSettings
s.users.userServices = usm
}

Expand Down
78 changes: 78 additions & 0 deletions core/services/agentpool/collection_models.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,78 @@
package agentpool

import (
"fmt"
"slices"
"strings"

"github.com/mudler/LocalAGI/core/state"
"github.com/mudler/LocalAGI/webui/collections"
"github.com/mudler/LocalAI/core/http/auth"
)

// Resolve on each operation so edits and permission changes apply to existing
// collections. The callback must not hold the pool or user-services mutex.
func (s *AgentPoolService) collectionModelSettings(owner string) func(string) (collections.CollectionModelSettings, error) {
return func(collection string) (collections.CollectionModelSettings, error) {
userID, name := owner, collection
if prefix, agentName, namespaced := strings.Cut(collection, ":"); namespaced {
if owner != "" && prefix != owner {
return collections.CollectionModelSettings{}, fmt.Errorf("collection belongs to another user")
}
userID, name = prefix, agentName
}
settings := collections.CollectionModelSettings{}
if s.configBackend == nil {
return settings, fmt.Errorf("agent configuration unavailable")
}
cfg := s.configBackend.GetConfig(userID, name)
if cfg == nil {
// Embedded RAG normalizes storage names. Resolve REST requests only when
// that normalized name identifies one agent in this user's namespace.
var match *state.AgentConfig
for candidate := range s.configBackend.ListAgents(userID) {
if strings.TrimSpace(strings.ToLower(candidate)) == name {
if match != nil {
return settings, fmt.Errorf("ambiguous agent collection %q", name)
}
match = s.configBackend.GetConfig(userID, candidate)
}
}
cfg = match
}
if cfg == nil {
return settings, nil
}
settings.EmbeddingModel, settings.RerankerModel = cfg.EmbeddingModel, cfg.RerankerModel
if err := s.checkCollectionModels(userID, settings); err != nil {
return collections.CollectionModelSettings{}, err
}
return settings, nil
}
}

func (s *AgentPoolService) checkCollectionModels(userID string, settings collections.CollectionModelSettings) error {
if userID == "" || s.users.authDB == nil || (settings.EmbeddingModel == "" && settings.RerankerModel == "") {
return nil
}
var user auth.User
if err := s.users.authDB.Where("id = ?", userID).First(&user).Error; err != nil {
return fmt.Errorf("check collection model access: %w", err)
}
if user.Role == auth.RoleAdmin {
return nil
}
permissions, err := auth.GetUserPermissions(s.users.authDB, userID)
if err != nil {
return fmt.Errorf("check collection model permissions: %w", err)
}
if !permissions.AllowedModels.Enabled {
return nil
}
for _, model := range []string{settings.EmbeddingModel, settings.RerankerModel} {
if model != "" && !slices.Contains(permissions.AllowedModels.Models, model) {
return fmt.Errorf("model %q is not allowed for this user", model)
}
}
return nil
}
Loading
Loading