Skip to content

Commit 6c4e880

Browse files
committed
Add settings UI for InferPage
Introduce a browser-based settings experience and improve model path resolution. Adds a Settings panel (Settings.razor) with styles and JS (settings.css, settings.js) and services to persist settings and API keys (SettingsService, InferPageSettings, SettingsStateService). Wire the settings UX into NavBar and Home to request/show/save settings and apply them at runtime via Utils.ApplySettings; Program.cs now distinguishes CLI-provided config vs browser configuration (Utils.NeedsConfiguration). Update Utils to support manual capability overrides and sanitize model paths, and tweak app CSS and script includes. Improve LLMService local model resolution and path handling (fallback GenericLocalModel, ResolvePath) so unregistered or absolute model paths load correctly.
1 parent a32de60 commit 6c4e880

15 files changed

Lines changed: 1056 additions & 80 deletions

File tree

src/MaIN.InferPage/Components/App.razor

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -27,6 +27,7 @@
2727
<script src="_framework/blazor.web.js"></script>
2828
<script src="scroll.js"></script>
2929
<script src="editor.js"></script>
30+
<script src="settings.js"></script>
3031
<script>
3132
window.themeManager = {
3233
save: function (theme) { localStorage.setItem('theme', theme); },

src/MaIN.InferPage/Components/Layout/MainLayout.razor

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
@inherits LayoutComponentBase
1+
@inherits LayoutComponentBase
22
<NavBar/>
33
<div class="content">
44

@@ -9,4 +9,4 @@
99
An unhandled error has occurred.
1010
<a href="." class="reload">Reload</a>
1111
<span class="dismiss">🗙</span>
12-
</div>
12+
</div>

src/MaIN.InferPage/Components/Layout/NavBar.razor

Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,10 @@
11
@using Microsoft.FluentUI.AspNetCore.Components.Icons.Regular
22
@using MaIN.Domain.Configuration
3+
@using MaIN.InferPage.Services
34
@inject NavigationManager _navigationManager
45
@inject IJSRuntime JS
6+
@inject SettingsStateService SettingsState
7+
@implements IDisposable
58
@rendermode @(new InteractiveServerRenderMode(prerender: false))
69

710
<FluentDesignTheme @bind-Mode="@Mode"
@@ -48,6 +51,11 @@
4851
Style="margin-left: 10px">Vision 👁️</FluentBadge>
4952
}
5053
<div style="margin-left: auto; align-self: flex-end;">
54+
<FluentButton Style="background-color: transparent;"
55+
BackgroundColor="rgba(0, 0, 0, 0)"
56+
Appearance="Appearance.Lightweight"
57+
OnClick="@OpenSettings" IconStart="@(new Icons.Regular.Size24.Settings().WithColor(AccentColor))">
58+
</FluentButton>
5159
<FluentButton Style="padding: 10px; background-color: transparent;"
5260
BackgroundColor="rgba(0, 0, 0, 0)"
5361
Appearance="Appearance.Lightweight"
@@ -72,10 +80,16 @@
7280
{
7381
var stored = await JS.InvokeAsync<string>("themeManager.load");
7482
Mode = stored == "dark" ? DesignThemeModes.Dark : DesignThemeModes.Light;
83+
SettingsState.OnSettingsApplied += OnSettingsChanged;
7584
StateHasChanged();
7685
}
7786
}
7887

88+
private void OnSettingsChanged()
89+
{
90+
InvokeAsync(StateHasChanged);
91+
}
92+
7993
private void SetTheme()
8094
{
8195
if (_isChangingTheme) return;
@@ -89,6 +103,11 @@
89103
_navigationManager.Refresh(true);
90104
}
91105

106+
private void OpenSettings()
107+
{
108+
SettingsState.RequestSettings();
109+
}
110+
92111
private string GetBackendColor()
93112
{
94113
return Utils.IsLocal ? "#6b7280" : "#10a37f";
@@ -103,4 +122,9 @@
103122
_ => Utils.BackendType.ToString()
104123
};
105124
}
125+
126+
public void Dispose()
127+
{
128+
SettingsState.OnSettingsApplied -= OnSettingsChanged;
129+
}
106130
}

src/MaIN.InferPage/Components/Pages/Home.razor

Lines changed: 151 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,9 @@
11
@page "/"
22
@rendermode @(new InteractiveServerRenderMode(prerender: true))
33
@inject IJSRuntime JS
4+
@inject SettingsService SettingsStorage
5+
@inject SettingsStateService SettingsState
6+
@inject MaIN.Domain.Configuration.MaINSettings MaINSettings
47
@implements IDisposable
58
@using MaIN.Core.Hub
69
@using MaIN.Core.Hub.Contexts.Interfaces.ChatContext
@@ -18,6 +21,13 @@
1821

1922
<ErrorNotification @bind-ErrorMessage="_errorMessage" />
2023

24+
@if (_showSettings)
25+
{
26+
<Settings ShowCloseButton="@_hasExistingConfig"
27+
OnSettingsApplied="@HandleSettingsApplied"
28+
OnClose="@HideSettings" />
29+
}
30+
2131
<div class="chat-container" id="chat-container">
2232
@if (_isDragging)
2333
{
@@ -204,6 +214,8 @@
204214
</div>
205215

206216
@code {
217+
private bool _showSettings;
218+
private bool _hasExistingConfig;
207219
private bool _isLoading;
208220
private bool _isThinking;
209221
private bool _isDragging;
@@ -214,7 +226,7 @@
214226
private string? _incomingReasoning;
215227
private IChatMessageBuilder? ctx;
216228
private CancellationTokenSource? _cancellationTokenSource;
217-
private Chat Chat { get; } = new() { Name = "MaIN Infer", ModelId = Utils.Model! };
229+
private Chat Chat { get; set; } = new() { Name = "MaIN Infer", ModelId = Utils.Model ?? "unknown" };
218230
private List<MessageExt> Messages { get; set; } = new();
219231
private ElementReference? _bottomElement;
220232
private ElementReference _editorRef;
@@ -243,6 +255,30 @@
243255
await JS.InvokeVoidAsync("editorManager.attachPasteHandler", _editorRef, _dotNetRef);
244256
await JS.InvokeVoidAsync("editorManager.attachDropZone", "chat-container", _dotNetRef);
245257

258+
// Settings initialization (requires JS interop, so must be in OnAfterRenderAsync)
259+
SettingsState.OnSettingsRequested += ShowSettingsFromGear;
260+
261+
if (Utils.NeedsConfiguration)
262+
{
263+
var hasBrowserSettings = await SettingsStorage.HasSettingsAsync();
264+
if (hasBrowserSettings)
265+
{
266+
await LoadAndApplyBrowserSettings();
267+
_hasExistingConfig = true;
268+
InitializeChatContext();
269+
SettingsState.NotifySettingsApplied();
270+
}
271+
else
272+
{
273+
_showSettings = true;
274+
_hasExistingConfig = false;
275+
}
276+
}
277+
else
278+
{
279+
_hasExistingConfig = true;
280+
}
281+
246282
StateHasChanged();
247283
}
248284
else if (_preserveScroll)
@@ -254,24 +290,28 @@
254290

255291
protected override Task OnInitializedAsync()
256292
{
257-
AIModel? model = null;
293+
// Only init chat if CLI args provided (no browser settings needed)
294+
if (!Utils.NeedsConfiguration && !string.IsNullOrEmpty(Utils.Model))
295+
{
296+
InitializeChatContext();
297+
}
258298

299+
return base.OnInitializedAsync();
300+
}
301+
302+
private void InitializeChatContext()
303+
{
259304
try
260305
{
261-
if (Utils.BackendType == BackendType.Self && Utils.Path != null)
306+
var model = ResolveModel();
307+
if (model != null)
262308
{
263-
model = ModelRegistry.TryGetById(Utils.Model!, out var foundModel1)
264-
? foundModel1!
265-
: new GenericLocalModel($"{Utils.Model}.gguf");
309+
var newCtx = AIHub.Chat().WithModel(model, imageGen: Utils.ImageGen);
310+
// Preserve history on model switch; cast is safe — ChatContext implements both interfaces.
311+
ctx = Chat.Messages.Count > 0
312+
? (IChatMessageBuilder)newCtx.WithMessages(Chat.Messages)
313+
: newCtx;
266314
}
267-
else
268-
{
269-
model = ModelRegistry.TryGetById(Utils.Model!, out var foundModel1)
270-
? foundModel1!
271-
: new GenericCloudModel(Id: Utils.Model!, Backend: Utils.BackendType);
272-
}
273-
274-
ctx = AIHub.Chat().WithModel(model, imageGen: Utils.ImageGen);
275315
}
276316
catch (MaINCustomException ex)
277317
{
@@ -281,8 +321,103 @@
281321
{
282322
_errorMessage = ex.Message;
283323
}
324+
}
284325

285-
return base.OnInitializedAsync();
326+
private AIModel? ResolveModel()
327+
{
328+
if (string.IsNullOrEmpty(Utils.Model)) return null;
329+
330+
if (ModelRegistry.TryGetById(Utils.Model, out var registeredModel))
331+
return registeredModel;
332+
333+
// Self = local GGUF file; Ollama Local is an HTTP server, not a file path
334+
if (Utils.BackendType == BackendType.Self)
335+
{
336+
string modelFileName;
337+
if (!string.IsNullOrEmpty(Utils.Path))
338+
{
339+
modelFileName = Utils.Path.EndsWith(".gguf", StringComparison.OrdinalIgnoreCase)
340+
? Utils.Path
341+
: System.IO.Path.Combine(Utils.Path, $"{Utils.Model.Replace(':', '-')}.gguf");
342+
}
343+
else
344+
{
345+
modelFileName = $"{Utils.Model.Replace(':', '-')}.gguf";
346+
}
347+
return new GenericLocalModel(FileName: modelFileName);
348+
}
349+
350+
// Cloud model — pick the right generic type based on capabilities
351+
bool vision = Utils.Vision;
352+
bool reasoning = Utils.Reason;
353+
354+
if (vision && reasoning)
355+
return new GenericCloudVisionReasoningModel(Id: Utils.Model, Backend: Utils.BackendType);
356+
if (vision)
357+
return new GenericCloudVisionModel(Id: Utils.Model, Backend: Utils.BackendType);
358+
if (reasoning)
359+
return new GenericCloudReasoningModel(Id: Utils.Model, Backend: Utils.BackendType);
360+
361+
return new GenericCloudModel(Id: Utils.Model, Backend: Utils.BackendType);
362+
}
363+
364+
private void ReinitializeChat()
365+
{
366+
if (string.IsNullOrEmpty(Utils.Model)) return;
367+
368+
_errorMessage = null;
369+
Chat.ModelId = Utils.Model;
370+
Chat.ImageGen = Utils.ImageGen;
371+
InitializeChatContext();
372+
StateHasChanged();
373+
}
374+
375+
private async Task LoadAndApplyBrowserSettings()
376+
{
377+
var settings = await SettingsStorage.LoadSettingsAsync();
378+
if (settings == null) return;
379+
380+
var backendType = (BackendType)settings.BackendType;
381+
382+
string? apiKey = null;
383+
if (backendType != BackendType.Self)
384+
{
385+
var backendKey = backendType == BackendType.Ollama
386+
? (settings.IsOllamaCloud ? "OllamaCloud" : "OllamaLocal")
387+
: backendType.ToString();
388+
389+
apiKey = await SettingsStorage.GetApiKeyForBackendAsync(backendKey);
390+
}
391+
392+
Utils.ApplySettings(
393+
backendType,
394+
settings.Model!,
395+
settings.ModelPath,
396+
settings.HasVision,
397+
settings.HasReasoning,
398+
settings.HasImageGen,
399+
MaINSettings,
400+
apiKey);
401+
}
402+
403+
private void ShowSettingsFromGear()
404+
{
405+
_showSettings = true;
406+
InvokeAsync(StateHasChanged);
407+
}
408+
409+
private void HandleSettingsApplied()
410+
{
411+
_hasExistingConfig = true;
412+
_showSettings = false;
413+
ReinitializeChat();
414+
SettingsState.NotifySettingsApplied();
415+
}
416+
417+
private void HideSettings()
418+
{
419+
_showSettings = false;
420+
StateHasChanged();
286421
}
287422

288423
private async Task HandleKeyDown(KeyboardEventArgs e)
@@ -635,6 +770,7 @@
635770

636771
public void Dispose()
637772
{
773+
SettingsState.OnSettingsRequested -= ShowSettingsFromGear;
638774
_cancellationTokenSource?.Dispose();
639775
_dotNetRef?.Dispose();
640776
}

0 commit comments

Comments
 (0)