refactor: Session 1 - critical safety bug fixes

- Remove hardcoded Replicate API key -> appsettings.Development.json
- Replace static Dictionary with ConcurrentDictionary for thread safety
- Remove static myHome field; ProcessAudio2 and OpenEmailForm2 now
  use session-keyed _instances lookup for correct multi-user routing
- Register _instances cleanup in DisposeAsync
- CreateSiteWizard: Singleton -> Scoped, static _instance replaced
  with DotNetObjectReference for per-circuit JS interop
- Remove duplicate DbContext registration from Program.cs
- HandleBrandNameChanged: remove spurious async void
- UpdateTextContentForVoice: add try/catch to contain exceptions
- UI event handlers (UpdateContent/Finished/Status): early-return
  guards + try/catch with _logger instead of silent failures

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
Adam 2026-03-07 00:44:56 +01:00
parent e0325bb4bb
commit f1aedcaa1e
8 changed files with 155 additions and 181 deletions

View File

@ -197,8 +197,8 @@
<script>
function openContactForm(emailAddress) {
console.log(emailAddress);
if (emailAddress) {
DotNet.invokeMethodAsync('BLAIzor', 'OpenEmailForm2', emailAddress)
if (emailAddress && sessionId) {
DotNet.invokeMethodAsync('BLAIzor', 'OpenEmailForm2', emailAddress, sessionId)
}
}
</script>
@ -337,11 +337,6 @@
StateHasChanged();
}
public Index()
{
myHome = this; // Set the static reference to the current instance
}
protected override async Task OnInitializedAsync()
{
await _logger.InfoAsync("Index component initialized.", $"{SiteId}");
@ -359,42 +354,25 @@
private async void UpdateContent(string receivedSessionId, string content, MenuItem? menuItem)
{
if (receivedSessionId == SessionId) // Only accept messages meant for this tab
if (receivedSessionId != SessionId) return;
try
{
HtmlContent.Clear();
HtmlContent.Append(content);
//InvokeAsync(StateHasChanged); // Ensures UI updates dynamically
await InvokeAsync(() =>
{
StateHasChanged();
});
//_scopedContentService.CurrentDOM = await jsRuntime.InvokeAsync<string>("getDivContent", "currentContent");
await InvokeAsync(StateHasChanged);
}
catch (Exception ex) { await _logger.ErrorAsync("UpdateContent failed", ex.Message); }
}
// private async void UpdateTextContentForVoice(string receivedSessionId, string content)
// {
// Console.WriteLine("UPDATETEXTCONTENT called");
// if (receivedSessionId == SessionId) // Only accept messages meant for this tab
// {
// TextContent = content;
// await ConvertTextToSpeech(content);
// //_scopedContentService.CurrentDOM = await jsRuntime.InvokeAsync<string>("getDivContent", "currentContent");
// }
// }
private async void UpdateFinished(string receivedSessionId)
{
if (receivedSessionId == SessionId) // Only accept messages meant for this tab
if (receivedSessionId != SessionId) return;
try
{
Console.WriteLine("Content update finished");
var result = await jsRuntime.InvokeAsync<object>("getDivContent", "currentContent");
//await ConvertTextToSpeech();
_scopedContentService.CurrentDOM = JsonSerializer.Serialize(result);
Console.Write(_scopedContentService.CurrentDOM);
}
catch (Exception ex) { await _logger.ErrorAsync("UpdateFinished failed", ex.Message); }
}
private async Task ContentChangedInForm()
@ -406,17 +384,16 @@
private async void UpdateStatus(string receivedSessionId, string content)
{
if (receivedSessionId == SessionId) // Only accept messages meant for this tab
if (receivedSessionId != SessionId) return;
try
{
StatusContent = content;
//InvokeAsync(StateHasChanged); // Ensures UI updates dynamically
await InvokeAsync(() =>
{
StateHasChanged();
});
await InvokeAsync(StateHasChanged);
}
catch (Exception ex) { await _logger.ErrorAsync("UpdateStatus failed", ex.Message); }
}
public async Task Enter(KeyboardEventArgs e)
{
if (e.Code == "Enter" || e.Code == "NumpadEnter")

View File

@ -5,6 +5,7 @@ using Microsoft.AspNetCore.Components;
using Microsoft.AspNetCore.Mvc;
using Microsoft.JSInterop;
using Radzen;
using System.Collections.Concurrent;
using System.Text;
using System.Text.Json;
@ -24,10 +25,9 @@ namespace BLAIzor.Components.Pages
[Inject] protected NotificationService NotificationService { get; set; }
[Inject] protected CacheService CacheService { get; set; }
public static readonly Dictionary<string, MainPageBase> _instances = new();
public static readonly ConcurrentDictionary<string, MainPageBase> _instances = new();
public string SessionId;
public static MainPageBase myHome;
public int SiteId;
public SiteInfo SiteInfo;
@ -65,29 +65,17 @@ namespace BLAIzor.Components.Pages
{
// Logic here
}
public async void HandleBrandNameChanged()
public void HandleBrandNameChanged()
{
SelectedBrandName = _scopedContentService.SelectedBrandName;
//await InvokeAsync(() =>
// {
// StateHasChanged();
// });
try
{
StateHasChanged();
//await Task.Run(() =>
//{
// StateHasChanged();
//}).ConfigureAwait(false);
}
catch (Exception ex)
{
Console.WriteLine(ex);
_ = _logger.ErrorAsync("HandleBrandNameChanged failed", ex.Message);
}
}
public async Task<string> GetMenuList(int siteId)
@ -216,7 +204,7 @@ namespace BLAIzor.Components.Pages
}
}
public async Task DisplayMenuContent(string input, bool forceUnmodified)
public virtual async Task DisplayMenuContent(string input, bool forceUnmodified)
{
welcomeStage = false;
if (!string.IsNullOrEmpty(UserInput))
@ -260,13 +248,15 @@ namespace BLAIzor.Components.Pages
protected async void UpdateTextContentForVoice(string receivedSessionId, string content)
{
Console.WriteLine("UPDATETEXTCONTENT called");
if (receivedSessionId == SessionId) // Only accept messages meant for this tab
if (receivedSessionId != SessionId) return;
TextContent = content;
try
{
TextContent = content;
await ConvertTextToSpeech(content);
//_scopedContentService.CurrentDOM = await jsRuntime.InvokeAsync<string>("getDivContent", "currentContent");
}
catch (Exception ex)
{
await _logger.ErrorAsync("UpdateTextContentForVoice failed", ex.Message);
}
}
@ -282,64 +272,47 @@ namespace BLAIzor.Components.Pages
}
[JSInvokable("ProcessAudio2")]
public static async Task ProcessAudio2(string base64Audio, string SessionId)
public static async Task ProcessAudio2(string base64Audio, string sessionId)
{
if (!_instances.TryGetValue(sessionId, out var instance)) return;
if (!instance.STTEnabled) return;
Console.Write("audio incoming");
if (myHome != null)
var languageCode = instance._scopedContentService.SelectedLanguage switch
{
if (myHome.STTEnabled == false) return;
Console.WriteLine("STT ENABLED -------------------------------------------------------------------------------");
var languageCode = "hu-HU";
if (myHome._scopedContentService.SelectedLanguage == "Hungarian")
{
languageCode = "hu-HU";
}
else if (myHome._scopedContentService.SelectedLanguage == "English")
{
languageCode = "en-US";
}
else if (myHome._scopedContentService.SelectedLanguage == "German")
{
languageCode = "de-DE";
}
var credentialsPath = myHome.configuration.GetSection("GoogleAPI").GetValue<string>("CredentialsPath");
Console.Write(credentialsPath);
var builder = new SpeechClientBuilder
{
CredentialsPath = credentialsPath
};
var speech = builder.Build();
"English" => "en-US",
"German" => "de-DE",
_ => "hu-HU"
};
byte[] audioBytes = Convert.FromBase64String(base64Audio);
myHome.HtmlContent.Clear();
var response = await speech.RecognizeAsync(new RecognitionConfig
var credentialsPath = instance.configuration.GetSection("GoogleAPI").GetValue<string>("CredentialsPath");
var speech = new SpeechClientBuilder { CredentialsPath = credentialsPath }.Build();
byte[] audioBytes = Convert.FromBase64String(base64Audio);
instance.HtmlContent.Clear();
var response = await speech.RecognizeAsync(new RecognitionConfig
{
Encoding = RecognitionConfig.Types.AudioEncoding.Mp3,
SampleRateHertz = 48000,
LanguageCode = languageCode
}, RecognitionAudio.FromBytes(audioBytes));
foreach (var result in response.Results)
{
foreach (var alternative in result.Alternatives)
{
Encoding = RecognitionConfig.Types.AudioEncoding.Mp3,
SampleRateHertz = 48000, // Match the actual sample rate
LanguageCode = languageCode
}, RecognitionAudio.FromBytes(audioBytes));
Console.Write("BILLED: " + response.TotalBilledTime);
foreach (var result in response.Results)
{
//Console.Write("RESULT: " + result.Alternatives.Count);
foreach (var alternative in result.Alternatives)
{
//Console.WriteLine($"Transcription: {alternative.Transcript}");
await myHome.HandleVoiceCommand(alternative.Transcript, SessionId);
}
await instance.HandleVoiceCommand(alternative.Transcript, sessionId);
}
}
}
[JSInvokable("OpenEmailForm2")]
public static async void OpenEmailForm2(string emailAddress)
public static async Task OpenEmailForm2(string emailAddress, string sessionId)
{
if (myHome != null)
if (_instances.TryGetValue(sessionId, out var instance))
{
await myHome.DisplayEmailForm(emailAddress);
await instance.DisplayEmailForm(emailAddress);
}
Console.Write("openEmail with: " + emailAddress);
}
public async Task SendMessage()
@ -377,11 +350,8 @@ namespace BLAIzor.Components.Pages
public async ValueTask DisposeAsync()
{
//await CssTemplateService.DeleteSessionCssFile(SessionId);
_instances.TryRemove(SessionId, out _);
_scopedContentService.OnBrandNameChanged -= HandleBrandNameChanged;
//AIService.OnContentReceived -= UpdateContent;
//AIService.OnContentReceiveFinished -= UpdateFinished;
//AIService.OnStatusChangeReceived -= UpdateStatus;
ChatGptService.OnTextContentAvailable -= UpdateTextContentForVoice;
}

View File

@ -373,10 +373,8 @@
<script>
function openContactForm(emailAddress) {
console.log(emailAddress);
if (emailAddress) {
DotNet.invokeMethodAsync('BLAIzor', 'OpenEmailForm2', emailAddress)
if (emailAddress && sessionId) {
DotNet.invokeMethodAsync('BLAIzor', 'OpenEmailForm2', emailAddress, sessionId)
}
}
</script>
@ -481,12 +479,6 @@
StateHasChanged();
}
public Preview()
{
myHome = this; // Set the static reference to the current instance
}
protected override async Task OnParametersSetAsync()
{
SessionId = _scopedContentService.SessionId;
@ -557,33 +549,67 @@
}
}
// else
// {
// UpdateContent(SessionId, HtmlContent.ToString(), currentMenuItem);
// }
UserInput = string.Empty;
_initVoicePending = true;
}
private async void UpdateContent(string receivedSessionId, string content, MenuItem menuItem)
{
if (receivedSessionId == SessionId) // Only accept messages meant for this tab
if (receivedSessionId != SessionId) return;
try
{
HtmlContent.Clear();
HtmlContent.Append(content);
//TODO SAVE TO DB
if (menuItem != null)
await InvokeAsync(StateHasChanged);
}
catch (Exception ex) { await _logger.ErrorAsync("UpdateContent failed", ex.Message); }
}
public async override Task DisplayMenuContent(string input, bool forceUnmodified)
{
welcomeStage = false;
if (!string.IsNullOrEmpty(UserInput))
{
HtmlContent.Clear();
var menu = await GetMenuList(SiteId);
var menuList = await GetMenuItems(SiteId);
var menuItem = CompareMenuItemNames(input, menuList);
if (menuItem == null)
{
await ChatGptService.ProcessContentRequest(SessionId, input, SiteId, (int)SiteInfo.TemplateId!, ContentCollectionName, menu, forceUnmodified);
}
else
{
currentMenuItem = menuItem;
// IsContentSaved = string.IsNullOrEmpty(currentMenuItem.StoredHtml) ? false : true;
// _logger.InfoAsync($"Preview - UpdateContent: {IsContentSaved}");
displayOptions = true;
StateHasChanged();
if (!string.IsNullOrEmpty(menuItem.StoredHtml))
{
HtmlContent.Clear();
HtmlContent.Append(menuItem.StoredHtml);
if (currentMenuItem.ContentItemId != null)
{
var content = await _contentEditorService.GetContentItemByIdAsync((int)currentMenuItem.ContentItemId);
if (content != null)
{
string removedNumbers = TextHelper.ReplaceNumbersAndSpecialCharacters(content.Content, _scopedContentService.SelectedLanguage);
Console.WriteLine(removedNumbers);
UpdateTextContentForVoice(SessionId, removedNumbers);
}
}
displayOptions = true;
IsContentSaved = true;
}
else
{
await ChatGptService.ProcessContentRequest(SessionId, menuItem, SiteId, (int)SiteInfo.TemplateId!, ContentCollectionName, menu, forceUnmodified);
}
}
//InvokeAsync(StateHasChanged); // Ensures UI updates dynamically
await InvokeAsync(() =>
{
StateHasChanged();
});
var result = await jsRuntime.InvokeAsync<object>("getDivContent", "currentContent");
_scopedContentService.CurrentDOM = JsonSerializer.Serialize(result);
//_scopedContentService.CurrentDOM = await jsRuntime.InvokeAsync<string>("getDivContent", "currentContent");
UserInput = string.Empty;
}
}
@ -600,13 +626,13 @@
private async void UpdateFinished(string receivedSessionId)
{
if (receivedSessionId == SessionId) // Only accept messages meant for this tab
if (receivedSessionId != SessionId) return;
try
{
Console.WriteLine("Content update finished");
var result = await jsRuntime.InvokeAsync<object>("getDivContent", "currentContent");
_scopedContentService.CurrentDOM = JsonSerializer.Serialize(result);
Console.Write(_scopedContentService.CurrentDOM);
}
catch (Exception ex) { await _logger.ErrorAsync("UpdateFinished failed", ex.Message); }
}
private async Task ContentChangedInForm()
@ -618,17 +644,16 @@
private async void UpdateStatus(string receivedSessionId, string content)
{
if (receivedSessionId == SessionId) // Only accept messages meant for this tab
if (receivedSessionId != SessionId) return;
try
{
StatusContent = content;
//InvokeAsync(StateHasChanged); // Ensures UI updates dynamically
await InvokeAsync(() =>
{
StateHasChanged();
});
await InvokeAsync(StateHasChanged);
}
catch (Exception ex) { await _logger.ErrorAsync("UpdateStatus failed", ex.Message); }
}
public async Task Enter(KeyboardEventArgs e)
{
if (e.Code == "Enter" || e.Code == "NumpadEnter")

View File

@ -237,9 +237,13 @@
}
protected override void OnInitialized()
protected override async Task OnAfterRenderAsync(bool firstRender)
{
_instance = this;
if (firstRender)
{
_dotNetRef = DotNetObjectReference.Create(this);
await JS.InvokeVoidAsync("initWizardAudio", _dotNetRef);
}
}
private void NextStep()
@ -483,19 +487,22 @@
// STT Hook
private static CreateSiteWizard? _instance;
private DotNetObjectReference<CreateSiteWizard>? _dotNetRef;
[JSInvokable]
public static async Task SendAudioToServer(List<byte> audioData)
public async Task SendAudioToServer(List<byte> audioData)
{
if (_instance is null) return;
var result = await _instance.WhisperService.TranscribeAsync(audioData.ToArray());
var result = await WhisperService.TranscribeAsync(audioData.ToArray());
if (!string.IsNullOrWhiteSpace(result))
{
_instance.Steps[_instance.CurrentStep].Answer = result;
_instance.StateHasChanged();
Steps[CurrentStep].Answer = result;
StateHasChanged();
}
}
public void Dispose()
{
_dotNetRef?.Dispose();
}
}

View File

@ -21,30 +21,26 @@ var builder = WebApplication.CreateBuilder(args);
var configuration = builder.Configuration;
builder.WebHost.UseWebRoot("wwwroot");
// Add services to the container.
var connectionString = builder.Configuration.GetConnectionString("DefaultConnection");
// Add DbContext (scoped, for pages and services)
builder.Services.AddDbContext<ApplicationDbContext>(options =>
options.UseSqlServer(builder.Configuration.GetConnectionString("DefaultConnection")));
options.UseSqlServer(connectionString));
// Add DbContextFactory (for background services that need their own scope)
builder.Services.AddDbContextFactory<ApplicationDbContext>(options =>
options.UseSqlServer(connectionString), ServiceLifetime.Scoped);
// Add Identity services
builder.Services.AddDefaultIdentity<IdentityUser>(options => options.SignIn.RequireConfirmedAccount = true)
.AddEntityFrameworkStores<ApplicationDbContext>();
//builder.Services.AddBlazorServerOptions(options =>
//{
// options.MaxBufferSize = 50 * 1024 * 1024; // Set to 50 MB
//});
builder.Services.Configure<KestrelServerOptions>(options =>
{
options.Limits.MaxRequestBodySize = 50 * 1024 * 1024;
});
var env = builder.Environment; // This is IWebHostEnvironment
if (env.IsProduction())
{
// do production-specific setup
}
var env = builder.Environment;
// Add services to the container.
builder.Services.AddRazorComponents()
@ -52,16 +48,6 @@ builder.Services.AddRazorComponents()
builder.Services.AddRadzenComponents();
var connectionString = builder.Configuration.GetConnectionString("DefaultConnection");
// Required for scoped injection (used in pages/services)
builder.Services.AddDbContext<ApplicationDbContext>(options =>
options.UseSqlServer(connectionString));
// Required for factory-based logging or background usage
builder.Services.AddDbContextFactory<ApplicationDbContext>(options =>
options.UseSqlServer(connectionString), ServiceLifetime.Scoped);
builder.Services.AddHttpClient();
builder.Services.AddScoped<ISimpleLogger, SimpleLogger>();
builder.Services.AddScoped<AIService>();
@ -91,14 +77,14 @@ builder.Services.AddScoped<CssInjectorService>();
builder.Services.AddScoped<LocalVectorSearchService>();
builder.Services.AddScoped<WebsiteContentLoaderService>();
builder.Services.AddScoped<CacheService>();
builder.Services.AddSingleton<CreateSiteWizard>();
builder.Services.AddScoped<CreateSiteWizard>();
builder.Services.AddScoped<WhisperTranscriptionService>();
builder.Services.AddScoped<IBrightDataService, BrightDataService>();
builder.Services.AddHttpClient<ReplicateService>(client =>
{
client.DefaultRequestHeaders.Authorization =
new AuthenticationHeaderValue("Bearer", "r8_MUApXYIE5mRjxqy20tsGLehWBJkCzNj0Cwvrh");
new AuthenticationHeaderValue("Bearer", configuration["Replicate:ApiKey"]);
});
builder.Services.AddHostedService<TempFileCleanupService>();

View File

@ -33,7 +33,7 @@ namespace BLAIzor.Services
}
}
public event Action OnBrandNameChanged;
public event Action? OnBrandNameChanged;
public int SelectedSiteId { get; set; } = 1;
public WebsiteContentModel WebsiteContentModel { get; set; }

View File

@ -5,5 +5,8 @@
"Microsoft.AspNetCore": "Information",
"BLAIzor": "Information"
}
},
"Replicate": {
"ApiKey": "r8_MUApXYIE5mRjxqy20tsGLehWBJkCzNj0Cwvrh"
}
}

View File

@ -1,5 +1,10 @@
let mediaRecorder;
let recordedChunks = [];
let wizardDotNetRef = null;
window.initWizardAudio = (dotnetRef) => {
wizardDotNetRef = dotnetRef;
};
window.startRecording = async () => {
recordedChunks = [];
@ -18,8 +23,9 @@ window.startRecording = async () => {
const arrayBuffer = await blob.arrayBuffer();
const byteArray = new Uint8Array(arrayBuffer);
// Send to Blazor server
DotNet.invokeMethodAsync('BLAIzor', 'SendAudioToServer', Array.from(byteArray));
if (wizardDotNetRef) {
wizardDotNetRef.invokeMethodAsync('SendAudioToServer', Array.from(byteArray));
}
};
mediaRecorder.start();