diff --git a/PanoramicData.Blazor.Test/Components/PDChatTests.Agents.cs b/PanoramicData.Blazor.Test/Components/PDChatTests.Agents.cs
index 095de232..2b8b210f 100644
--- a/PanoramicData.Blazor.Test/Components/PDChatTests.Agents.cs
+++ b/PanoramicData.Blazor.Test/Components/PDChatTests.Agents.cs
@@ -24,7 +24,7 @@ public void The_agent_picker_is_hidden_with_fewer_than_two_agents(int count)
var component = RenderChat(new FakeChatService { Agents = [.. new[] { _merlin, _alice }.Take(count)] });
component.FindAll(".pdchat-agent-picker").Should().BeEmpty();
- component.FindAll(".chat-input-accessories").Should().BeEmpty("there is nothing else to show there");
+ component.Find(".chat-input-accessories").QuerySelectorAll("select").Should().BeEmpty();
}
/// Verifies that no picker is shown when the service does not list agents at all.
@@ -38,7 +38,7 @@ public void The_agent_picker_is_shown_in_the_input_area_with_two_agents()
{
var component = RenderChat(new FakeChatService { Agents = [_merlin, _alice] });
- var select = component.Find(".chat-input-container .chat-input-accessories .pdchat-agent-picker select");
+ var select = component.Find(".chat-input-accessories .pdchat-agent-picker select");
select.GetAttribute("title").Should().Be("Who you are talking to");
var options = select.QuerySelectorAll("option");
options.Select(option => option.TextContent).Should().Equal("Merlin", "Alice");
diff --git a/PanoramicData.Blazor.Test/Components/PDChatTests.Fakes.cs b/PanoramicData.Blazor.Test/Components/PDChatTests.Fakes.cs
index 319da5a7..3df5c103 100644
--- a/PanoramicData.Blazor.Test/Components/PDChatTests.Fakes.cs
+++ b/PanoramicData.Blazor.Test/Components/PDChatTests.Fakes.cs
@@ -56,6 +56,7 @@ private sealed partial class FakeChatService : IChatService
public PDChatButtonPosition ToastAnchor { get; set; } = PDChatButtonPosition.BottomRight;
public PDChatVoiceEndpoints? VoiceEndpoints { get; set; }
public IReadOnlyList? Agents { get; set; }
+ public IReadOnlyList? Models { get; set; }
public IReadOnlyList Messages => Store;
public bool SupportsConversations { get; init; }
diff --git a/PanoramicData.Blazor.Test/Components/PDChatTests.Models.cs b/PanoramicData.Blazor.Test/Components/PDChatTests.Models.cs
new file mode 100644
index 00000000..ac6b0868
--- /dev/null
+++ b/PanoramicData.Blazor.Test/Components/PDChatTests.Models.cs
@@ -0,0 +1,75 @@
+using AwesomeAssertions;
+using Bunit;
+using Microsoft.AspNetCore.Components;
+using PanoramicData.Blazor.Interfaces;
+using PanoramicData.Blazor.Models;
+
+namespace PanoramicData.Blazor.Test;
+
+///
+/// The model picker: offered in the input toolbar only when the service lists two or more models, and choosing one sets
+/// .
+///
+public partial class PDChatTests
+{
+ private static readonly PDChatModelOption _quick = new("quick", "Quick", "Small and fast");
+ private static readonly PDChatModelOption _thorough = new("thorough", "Thorough");
+
+ /// Verifies that no model picker is shown with no models, or with only one to choose.
+ [Theory]
+ [InlineData(0)]
+ [InlineData(1)]
+ public void The_model_picker_is_hidden_with_fewer_than_two_models(int count)
+ => RenderChat(new FakeChatService { Models = [.. new[] { _quick, _thorough }.Take(count)] })
+ .FindAll(".pdchat-model-picker").Should().BeEmpty();
+
+ /// Verifies that two or more models are offered in the toolbar, labelled, the first selected by default.
+ [Fact]
+ public void The_model_picker_is_shown_in_the_toolbar_with_two_models()
+ {
+ var picker = RenderChat(new FakeChatService { Models = [_quick, _thorough] }).Find(".chat-input-accessories .pdchat-model-picker");
+
+ picker.QuerySelector(".pdchat-toolbar-label")!.TextContent.Should().Be("Model");
+ var options = picker.QuerySelectorAll("option");
+ options.Select(option => option.TextContent).Should().Equal("Quick", "Thorough");
+ options[0].GetAttribute("title").Should().Be("Small and fast");
+ options[0].HasAttribute("selected").Should().BeTrue();
+ }
+
+ /// Verifies that choosing a model sets the service's selected model, and an unknown one is ignored.
+ [Fact]
+ public async Task Choosing_a_model_sets_the_selected_model()
+ {
+ var service = new FakeChatService { Models = [_quick, _thorough] };
+ var component = RenderChat(service);
+
+ await component.Find(".pdchat-model-picker select").ChangeAsync(new ChangeEventArgs { Value = "mallory" });
+ ((IChatService)service).SelectedModelId.Should().BeNull();
+
+ await component.Find(".pdchat-model-picker select").ChangeAsync(new ChangeEventArgs { Value = "thorough" });
+ ((IChatService)service).SelectedModelId.Should().Be("thorough");
+ component.Find(".pdchat-model-picker option[value=thorough]").HasAttribute("selected").Should().BeTrue();
+ }
+
+ /// Verifies that the agent and model pickers are separate, labelled choices in the same toolbar.
+ [Fact]
+ public void The_agent_and_model_pickers_are_both_labelled()
+ {
+ var toolbar = RenderChat(new FakeChatService { Agents = [_merlin, _alice], Models = [_quick, _thorough] })
+ .Find(".chat-input-accessories");
+
+ toolbar.QuerySelectorAll(".pdchat-toolbar-label").Select(label => label.TextContent).Should().Equal("Agent", "Model");
+ }
+
+ /// Verifies that the demo service offers models, agents and simulated voice.
+ [Fact]
+ public void The_demo_service_offers_the_whole_toolbar()
+ {
+ using var service = new PanoramicData.Blazor.Services.DumbChatService();
+ IChatService chat = service;
+
+ chat.Models.Should().HaveCountGreaterThan(1);
+ chat.Agents.Should().HaveCountGreaterThan(1);
+ chat.VoiceEndpoints.Should().Be(PDChatVoiceEndpoints.Simulated);
+ }
+}
diff --git a/PanoramicData.Blazor.Test/Components/PDChatTests.Service.cs b/PanoramicData.Blazor.Test/Components/PDChatTests.Service.cs
index bce1bfa5..6851f21d 100644
--- a/PanoramicData.Blazor.Test/Components/PDChatTests.Service.cs
+++ b/PanoramicData.Blazor.Test/Components/PDChatTests.Service.cs
@@ -21,10 +21,10 @@ public async Task Mute_toggles_the_service_and_raises_the_event()
var service = new FakeChatService();
var component = RenderChat(service, p => p.Add(x => x.OnMuteToggled, () => toggles++));
- await HeaderButton(component, "Mute").ClickAsync(new MouseEventArgs());
+ await InputToolbarButton(component, "Notification sounds: on").ClickAsync(new MouseEventArgs());
service.IsMuted.Should().BeTrue();
- HeaderButton(component, "Unmute").TextContent.Should().Contain("π");
+ InputToolbarButton(component, "Notification sounds: off").TextContent.Should().Contain("π");
toggles.Should().Be(1);
}
@@ -37,7 +37,7 @@ public async Task A_mute_change_from_the_service_is_reflected()
await component.InvokeAsync(() => service.AnnounceMute(true));
- component.WaitForAssertion(() => HeaderButton(component, "Unmute").Should().NotBeNull(), Patience);
+ component.WaitForAssertion(() => InputToolbarButton(component, "Notification sounds: off").Should().NotBeNull(), Patience);
}
/// Verifies that live-status and configuration announcements re-render the chat.
diff --git a/PanoramicData.Blazor.Test/Components/PDChatTests.Voice.cs b/PanoramicData.Blazor.Test/Components/PDChatTests.Voice.cs
index 220e5870..00e09b0c 100644
--- a/PanoramicData.Blazor.Test/Components/PDChatTests.Voice.cs
+++ b/PanoramicData.Blazor.Test/Components/PDChatTests.Voice.cs
@@ -31,7 +31,7 @@ public void Voice_Mode_is_offered_but_off_by_default()
var component = RenderChat(VoiceService());
var toggle = component.Find(".pdchat-voice-toggle");
- toggle.TextContent.Should().Contain("Voice");
+ toggle.GetAttribute("aria-label").Should().Be("Voice: speak instead of typing");
toggle.GetAttribute("title").Should().Be("Voice: speak instead of typing");
toggle.GetAttribute("aria-pressed").Should().Be("false");
component.FindAll(".pdchat-voice-status").Should().BeEmpty();
@@ -45,7 +45,7 @@ public void The_Voice_control_is_in_the_input_area()
var component = RenderChat(VoiceService());
component.FindAll(".pdchat-header .pdchat-voice-toggle").Should().BeEmpty();
- component.FindAll(".chat-input-container .chat-input-accessories .pdchat-voice-toggle").Should().ContainSingle();
+ component.FindAll(".chat-input-accessories .pdchat-voice-toggle").Should().ContainSingle();
}
///
@@ -176,7 +176,7 @@ public async Task Dictation_is_never_auto_sent_while_the_text_box_has_focus()
service.Sent.Should().BeEmpty();
component.Find(".pdchat-voice-status").TextContent.Trim().Should().Be("Listening. You are editing, so press Send when ready.");
- await component.Find(".chat-input-container > button").ClickAsync(new MouseEventArgs());
+ await component.Find(".chat-input-accessories .chat-send").ClickAsync(new MouseEventArgs());
service.Sent.Should().ContainSingle().Which.Message.Should().Be("Is it done?");
module.Invocations["pause"].Should().ContainSingle().Which.Arguments[0].Should().Be(true);
@@ -317,10 +317,128 @@ public async Task A_refused_microphone_is_explained()
component.Find(".pdchat-voice-status").TextContent.Should().Contain("microphone could not be opened");
}
- private static FakeChatService VoiceService(TimeSpan? autoSendDelay = null)
+ /// Verifies that answers are not read aloud by default: dictation is sent and the microphone keeps listening.
+ [Fact]
+ public async Task Without_read_aloud_dictation_is_sent_and_nothing_is_spoken()
+ {
+ var module = SetUpVoiceModule();
+ var service = VoiceService(readAloud: false);
+ var component = await RenderListeningAsync(service);
+ component.Find(".pdchat-read-aloud-toggle").GetAttribute("aria-pressed").Should().Be("false");
+
+ await AskAsync(component, service, "Is it done?");
+ await component.InvokeAsync(() => service.Receive(Message("Yes.")));
+
+ module.Invocations["speak"].Should().BeEmpty();
+ module.Invocations["pause"].Should().BeEmpty("the microphone only pauses while an answer is to be spoken");
+ component.Instance.VoiceState.Should().Be(PDChatVoiceState.Listening);
+ }
+
+ /// Verifies that read-aloud works without the microphone: the answer to a typed message is spoken.
+ [Fact]
+ public async Task With_read_aloud_and_no_microphone_a_typed_question_is_answered_aloud()
+ {
+ var module = SetUpVoiceModule();
+ var service = VoiceService(readAloud: false);
+ var component = RenderChat(service);
+ await component.Find(".pdchat-read-aloud-toggle").ClickAsync(new());
+ ((IChatService)service).IsReadAloudEnabled.Should().BeTrue();
+
+ await component.Find("textarea").InputAsync(new Microsoft.AspNetCore.Components.ChangeEventArgs { Value = "Is it done?" });
+ await component.Find(".chat-input-accessories .chat-send").ClickAsync(new MouseEventArgs());
+ await component.InvokeAsync(() => service.Receive(Message("Yes.")));
+
+ module.Invocations["speak"].Should().ContainSingle().Which.Arguments[1].Should().Be("Yes.");
+ module.Invocations["start"].Should().BeEmpty("reading aloud never opens the microphone");
+ component.Instance.VoiceState.Should().Be(PDChatVoiceState.Off);
+ }
+
+ /// Verifies that turning read-aloud off mid-answer stops speaking and the microphone listens again.
+ [Fact]
+ public async Task Turning_read_aloud_off_mid_answer_stops_it_and_listening_resumes()
+ {
+ var module = SetUpVoiceModule();
+ var service = VoiceService();
+ var component = await RenderListeningAsync(service);
+ await AskAsync(component, service, "Is it done?");
+ await component.InvokeAsync(() => service.Receive(Message("Yes.")));
+ component.Instance.VoiceState.Should().Be(PDChatVoiceState.Speaking);
+
+ await component.Find(".pdchat-read-aloud-toggle").ClickAsync(new());
+
+ module.Invocations["stopSpeaking"].Should().ContainSingle();
+ module.Invocations["pause"].Select(call => call.Arguments[0]).Should().Equal(true, false);
+ component.Instance.VoiceState.Should().Be(PDChatVoiceState.Listening);
+ }
+
+ /// Verifies that read-aloud and notification sounds are separate choices, so all four combinations exist.
+ [Theory]
+ [InlineData(false, false)]
+ [InlineData(false, true)]
+ [InlineData(true, false)]
+ [InlineData(true, true)]
+ public async Task Read_aloud_and_notification_sounds_are_independent(bool muted, bool readAloud)
+ {
+ var service = VoiceService(readAloud: false);
+ var component = RenderChat(service);
+
+ if (muted)
+ {
+ await component.Find(".pdchat-mute-toggle").ClickAsync(new());
+ }
+
+ if (readAloud)
+ {
+ await component.Find(".pdchat-read-aloud-toggle").ClickAsync(new());
+ }
+
+ service.IsMuted.Should().Be(muted);
+ ((IChatService)service).IsReadAloudEnabled.Should().Be(readAloud);
+ component.Find(".pdchat-mute-toggle").TextContent.Should().Be(muted ? "π" : "π");
+ component.Find(".pdchat-read-aloud-toggle").GetAttribute("aria-pressed").Should().Be(readAloud ? "true" : "false");
+ }
+
+ /// Verifies that the microphone control is an icon alone, named for screen readers by its label.
+ [Fact]
+ public void The_microphone_control_is_an_icon_only()
+ {
+ var toggle = RenderChat(VoiceService()).Find(".pdchat-voice-toggle");
+
+ toggle.TextContent.Trim().Should().Be("ποΈ");
+ toggle.GetAttribute("aria-label").Should().Be("Voice: speak instead of typing");
+ }
+
+ /// Verifies that the toolbar sits above the input, leaving the text box and Send the whole row.
+ [Fact]
+ public void The_toolbar_sits_above_a_full_width_input_row()
+ {
+ var component = RenderChat(VoiceService());
+
+ var toolbar = component.Find(".chat-input-accessories");
+ toolbar.NextElementSibling!.ClassList.Should().Contain("chat-input-container");
+ component.Find(".chat-input-container").Children.Select(child => child.LocalName).Should().Equal("textarea");
+ toolbar.LastElementChild!.ClassList.Should().Contain("chat-send");
+ component.FindAll(".pdchat-header .pdchat-mute-toggle, .pdchat-header-btn[title^='Notification']").Should().BeEmpty();
+ toolbar.QuerySelector(".pdchat-mute-toggle").Should().NotBeNull();
+ }
+
+ /// Verifies that notification sounds can still be switched where typing is not permitted.
+ [Fact]
+ public void Notification_sounds_stay_reachable_where_typing_is_not_permitted()
+ {
+ var service = VoiceService();
+ ((IChatService)service).IsInputPermitted = false;
+ var component = RenderChat(service);
+
+ component.FindAll(".chat-input-accessories .pdchat-mute-toggle").Should().ContainSingle();
+ component.FindAll(".pdchat-voice-toggle, .pdchat-read-aloud-toggle").Should().BeEmpty();
+ }
+
+ private static FakeChatService VoiceService(TimeSpan? autoSendDelay = null, bool readAloud = true)
{
var service = new FakeChatService { VoiceEndpoints = _voiceEndpoints };
((IChatService)service).VoiceAutoSendDelay = autoSendDelay ?? TimeSpan.Zero;
+ ((IChatService)service).IsReadAloudEnabled = readAloud;
return service;
}
@@ -365,17 +483,6 @@ public async Task Simulated_Voice_Mode_uses_its_own_module()
component.Instance.VoiceState.Should().Be(PDChatVoiceState.Listening);
}
- /// Verifies that the demo service shows Voice Mode and the agent picker with no speech service behind it.
- [Fact]
- public void The_demo_service_offers_simulated_Voice_Mode_and_agents()
- {
- using var service = new PanoramicData.Blazor.Services.DumbChatService();
- IChatService chat = service;
-
- chat.VoiceEndpoints.Should().Be(PDChatVoiceEndpoints.Simulated);
- chat.Agents.Should().HaveCountGreaterThan(1);
- }
-
private BunitJSModuleInterop SetUpVoiceModule()
{
var module = JSInterop.SetupModule(VoiceModulePath);
diff --git a/PanoramicData.Blazor.Test/Components/PDChatTests.cs b/PanoramicData.Blazor.Test/Components/PDChatTests.cs
index 8df52689..9c96dbc2 100644
--- a/PanoramicData.Blazor.Test/Components/PDChatTests.cs
+++ b/PanoramicData.Blazor.Test/Components/PDChatTests.cs
@@ -89,6 +89,10 @@ private static IElement HeaderButton(IRenderedComponent
where TComponent : IComponent
=> component.Find($".pdchat-header-btn[title='{title}']");
+ private static IElement InputToolbarButton(IRenderedComponent component, string title)
+ where TComponent : IComponent
+ => component.Find($".chat-input-accessories .pdchat-toolbar-btn[title='{title}']");
+
private static ChatMessage Message(string text, MessageType type = MessageType.Normal) => new()
{
Id = Guid.NewGuid(),
diff --git a/PanoramicData.Blazor.Test/Components/PDMessagesTests.ChatInput.cs b/PanoramicData.Blazor.Test/Components/PDMessagesTests.ChatInput.cs
index 98e180a4..e55b54ab 100644
--- a/PanoramicData.Blazor.Test/Components/PDMessagesTests.ChatInput.cs
+++ b/PanoramicData.Blazor.Test/Components/PDMessagesTests.ChatInput.cs
@@ -68,19 +68,22 @@ public async Task SendAsync_is_the_same_as_pressing_send()
events.Should().Equal("input:spoken", "input:spoken", "send");
}
- /// Verifies that host controls are shown between the text box and Send, and not at all when absent.
+ /// Verifies that host controls and Send share a toolbar above a full-width text box.
[Fact]
- public void Input_accessories_sit_beside_send()
+ public void Input_accessories_sit_in_a_toolbar_above_the_input()
{
- RenderLive([]).FindAll(".chat-input-accessories").Should().BeEmpty();
+ RenderLive([]).Find(".chat-input-accessories").Children.Select(child => child.ClassName).Should().Equal("btn btn-sm btn-secondary chat-send");
var component = Render(parameters => parameters
.Add(p => p.IsLive, true)
.Add(p => p.InputAccessories, builder => builder.AddMarkupContent(0, "")));
- var row = component.Find(".chat-input-container");
- row.Children.Select(child => child.LocalName).Should().Equal("textarea", "div", "button");
- row.QuerySelector(".chat-input-accessories .extra").Should().NotBeNull();
+ var toolbar = component.Find(".chat-input-accessories");
+ toolbar.GetAttribute("role").Should().Be("toolbar");
+ toolbar.QuerySelector(".extra").Should().NotBeNull();
+ toolbar.NextElementSibling!.ClassList.Should().Contain("chat-input-container");
+ component.Find(".chat-input-container").Children.Select(child => child.LocalName).Should().Equal("textarea");
+ toolbar.LastElementChild!.ClassList.Should().Contain("chat-send");
}
/// Verifies that the text box takes focus when shown, unless that is turned off.
diff --git a/PanoramicData.Blazor/Interfaces/ChatServiceDefaultState.cs b/PanoramicData.Blazor/Interfaces/ChatServiceDefaultState.cs
index 70da9c6e..e2544d62 100644
--- a/PanoramicData.Blazor/Interfaces/ChatServiceDefaultState.cs
+++ b/PanoramicData.Blazor/Interfaces/ChatServiceDefaultState.cs
@@ -50,6 +50,10 @@ internal sealed class ChatServiceDefaultState
public string? SelectedAgentId { get; set; }
+ public string? SelectedModelId { get; set; }
+
+ public bool IsReadAloudEnabled { get; set; }
+
///
/// Gets or sets the handlers subscribed to the default .
/// They are kept so that subscribing and unsubscribing behave as they would on any event, but a service that
diff --git a/PanoramicData.Blazor/Interfaces/IChatService.cs b/PanoramicData.Blazor/Interfaces/IChatService.cs
index cbaa41c2..80fe70c0 100644
--- a/PanoramicData.Blazor/Interfaces/IChatService.cs
+++ b/PanoramicData.Blazor/Interfaces/IChatService.cs
@@ -121,6 +121,32 @@ TimeSpan VoiceAutoSendDelay
set => ChatServiceDefaultState.For(this).SelectedAgentId = value;
}
+ ///
+ /// Gets or sets whether the answer to each message the user sends is read aloud. False by default. Independent of
+ /// both the microphone and , which governs notification sounds. Needs .
+ ///
+ bool IsReadAloudEnabled
+ {
+ get => ChatServiceDefaultState.For(this).IsReadAloudEnabled;
+ set => ChatServiceDefaultState.For(this).IsReadAloudEnabled = value;
+ }
+
+ ///
+ /// Gets the models the user may choose to answer, or null (the default) when the host offers no choice.
+ /// A picker is shown in the input toolbar only when there are two or more.
+ ///
+ IReadOnlyList? Models => null;
+
+ ///
+ /// Gets or sets the of the model that answers, or null (the default) for the
+ /// host's default. The chat sets this when the user picks a model; a host that needs to react implements the setter.
+ ///
+ string? SelectedModelId
+ {
+ get => ChatServiceDefaultState.For(this).SelectedModelId;
+ set => ChatServiceDefaultState.For(this).SelectedModelId = value;
+ }
+
///
/// Gets or sets whether the chat should auto-restore when new messages arrive.
///
diff --git a/PanoramicData.Blazor/Models/PDChatModelOption.cs b/PanoramicData.Blazor/Models/PDChatModelOption.cs
new file mode 100644
index 00000000..63827b8e
--- /dev/null
+++ b/PanoramicData.Blazor/Models/PDChatModelOption.cs
@@ -0,0 +1,10 @@
+namespace PanoramicData.Blazor.Models;
+
+///
+/// One of the models a host offers in 's input toolbar, for the user to choose
+/// which model answers.
+///
+/// The identifier the host recognises, stored in .
+/// The name shown in the picker.
+/// An optional description, shown as the option's tooltip.
+public sealed record PDChatModelOption(string Id, string Name, string? Description = null);
diff --git a/PanoramicData.Blazor/PDChat.Agents.cs b/PanoramicData.Blazor/PDChat.Agents.cs
deleted file mode 100644
index bb40aeaf..00000000
--- a/PanoramicData.Blazor/PDChat.Agents.cs
+++ /dev/null
@@ -1,25 +0,0 @@
-namespace PanoramicData.Blazor;
-
-///
-/// PDChat: the agent picker in the input area, offered when the chat service lists two or more
-/// .
-///
-public partial class PDChat
-{
- private bool IsAgentPickerOffered => ChatService.Agents is { Count: > 1 };
-
- // Null or unknown means the host's default, which the picker shows as its first agent.
- private PDChatAgentOption? SelectedAgent => ChatService.Agents?.FirstOrDefault(agent => agent.Id == ChatService.SelectedAgentId)
- ?? (ChatService.Agents is { Count: > 0 } agents ? agents[0] : null);
-
- private RenderFragment? InputAccessories => IsVoiceModeOffered || IsAgentPickerOffered ? InputAccessoriesContent : null;
-
- private void OnAgentSelected(ChangeEventArgs args)
- {
- var id = args.Value?.ToString();
- if (ChatService.Agents?.Any(agent => agent.Id == id) == true)
- {
- ChatService.SelectedAgentId = id;
- }
- }
-}
diff --git a/PanoramicData.Blazor/PDChat.Toolbar.cs b/PanoramicData.Blazor/PDChat.Toolbar.cs
new file mode 100644
index 00000000..9e961086
--- /dev/null
+++ b/PanoramicData.Blazor/PDChat.Toolbar.cs
@@ -0,0 +1,39 @@
+namespace PanoramicData.Blazor;
+
+///
+/// PDChat: the thin toolbar above the text box, holding the agent and model pickers (each offered only when the chat
+/// service lists two or more), notifications and Voice Mode.
+///
+public partial class PDChat
+{
+ private bool IsAgentPickerOffered => ChatService.Agents is { Count: > 1 };
+
+ private bool IsModelPickerOffered => ChatService.Models is { Count: > 1 };
+
+ // Null or unknown means the host's default, which each picker shows as its first option.
+ private PDChatAgentOption? SelectedAgent => ChatService.Agents?.FirstOrDefault(agent => agent.Id == ChatService.SelectedAgentId)
+ ?? (ChatService.Agents is { Count: > 0 } agents ? agents[0] : null);
+
+ private PDChatModelOption? SelectedModel => ChatService.Models?.FirstOrDefault(model => model.Id == ChatService.SelectedModelId)
+ ?? (ChatService.Models is { Count: > 0 } models ? models[0] : null);
+
+ private string MuteButtonTitle => _isMuted ? "Notification sounds: off" : "Notification sounds: on";
+
+ private void OnAgentSelected(ChangeEventArgs args)
+ {
+ var id = args.Value?.ToString();
+ if (ChatService.Agents?.Any(agent => agent.Id == id) == true)
+ {
+ ChatService.SelectedAgentId = id;
+ }
+ }
+
+ private void OnModelSelected(ChangeEventArgs args)
+ {
+ var id = args.Value?.ToString();
+ if (ChatService.Models?.Any(model => model.Id == id) == true)
+ {
+ ChatService.SelectedModelId = id;
+ }
+ }
+}
diff --git a/PanoramicData.Blazor/PDChat.Voice.cs b/PanoramicData.Blazor/PDChat.Voice.cs
index 106411db..3fbb1014 100644
--- a/PanoramicData.Blazor/PDChat.Voice.cs
+++ b/PanoramicData.Blazor/PDChat.Voice.cs
@@ -22,14 +22,14 @@ public enum PDChatVoiceState
}
///
-/// Voice Mode for : the user dictates into the text box, and hears the answer. Off by default, and
-/// offered only when the chat service supplies .
+/// Voice for : two independent choices, both off by default and offered only when the chat service
+/// supplies . Dictation (the microphone) types into the text box; read-aloud
+/// () speaks the answer to each message the user sends.
///
///
/// Recognised words are appended to the text box, where they can be edited. When the speaker pauses and the box does
/// not have focus, the text is sent after as if Send had been pressed;
-/// while the user is editing, they press Send themselves. The first finished reply to a message sent while Voice Mode
-/// is listening is read aloud.
+/// while the user is editing, they press Send themselves. While an answer is read aloud the microphone sends nothing.
///
public partial class PDChat
{
@@ -54,10 +54,16 @@ public partial class PDChat
private bool IsVoiceModeOn => VoiceState != PDChatVoiceState.Off;
+ private bool IsReadAloudOn => IsVoiceModeOffered && ChatService.IsReadAloudEnabled;
+
private string VoiceButtonTitle => IsVoiceModeOn
? "Voice: stop listening"
: "Voice: speak instead of typing";
+ private string ReadAloudButtonTitle => IsReadAloudOn
+ ? "Read answers aloud: on"
+ : "Read answers aloud: off";
+
private string VoiceStatusCssClass => $"pdchat-voice-status pdchat-voice-{VoiceState.ToString().ToLowerInvariant()}" + (VoiceError is null ? string.Empty : " pdchat-voice-error");
private string VoiceStatusText => VoiceError ?? VoiceState switch
@@ -67,7 +73,7 @@ public partial class PDChat
? "Listening. You are editing, so press Send when ready."
: "Listening. Ask your question, then pause.",
PDChatVoiceState.Thinking => "Thinkingβ¦",
- PDChatVoiceState.Speaking => "Speaking. Turn Voice off to stop.",
+ PDChatVoiceState.Speaking => "Reading the answer aloud.",
_ => string.Empty,
};
@@ -88,9 +94,8 @@ private async Task ToggleVoiceModeAsync()
VoiceState = PDChatVoiceState.Starting;
try
{
- _voiceModule ??= await JSRuntime.InvokeAsync("import", endpoints.ModulePath ?? _voiceModulePath);
- _voiceReference ??= DotNetObjectReference.Create(this);
- await _voiceModule.InvokeVoidAsync("start", endpoints.ListenUrl, _voiceReference);
+ var module = await GetVoiceModuleAsync(endpoints);
+ await module.InvokeVoidAsync("start", endpoints.ListenUrl, _voiceReference);
VoiceState = PDChatVoiceState.Listening;
}
catch (JSException)
@@ -100,6 +105,41 @@ private async Task ToggleVoiceModeAsync()
}
}
+ // Loaded on first use, so the page asks for nothing until the user turns a voice control on.
+ private async Task GetVoiceModuleAsync(PDChatVoiceEndpoints endpoints)
+ {
+ _voiceModule ??= await JSRuntime.InvokeAsync("import", endpoints.ModulePath ?? _voiceModulePath);
+ _voiceReference ??= DotNetObjectReference.Create(this);
+ return _voiceModule;
+ }
+
+ private async Task ToggleReadAloudAsync()
+ {
+ ChatService.IsReadAloudEnabled = !ChatService.IsReadAloudEnabled;
+ if (ChatService.IsReadAloudEnabled)
+ {
+ return;
+ }
+
+ // Turned off mid-answer: stop talking, and give the microphone back if it was waiting.
+ _isAwaitingSpokenAnswer = false;
+ if (_voiceModule is not null)
+ {
+ await _voiceModule.InvokeVoidAsync("stopSpeaking");
+ }
+
+ if (VoiceState is PDChatVoiceState.Thinking or PDChatVoiceState.Speaking)
+ {
+ await ResumeListeningAsync();
+ }
+ }
+
+ private async Task ResumeListeningAsync()
+ {
+ VoiceState = PDChatVoiceState.Listening;
+ await (_voiceModule?.InvokeVoidAsync("pause", false) ?? ValueTask.CompletedTask);
+ }
+
private async Task StopVoiceModeAsync()
{
CancelAutoSend();
@@ -232,52 +272,65 @@ await InvokeAsync(async () =>
}
///
- /// Marks a message about to be sent while listening as a spoken exchange: the microphone pauses and the reply is read aloud.
+ /// Called as a message is about to be sent. The dictation turn is over; with read-aloud on, the answer is awaited,
+ /// and a listening microphone pauses until it has been spoken.
///
private async Task BeginSpokenExchangeAsync()
{
- if (VoiceState != PDChatVoiceState.Listening)
+ CancelAutoSend();
+ _hasDictatedSinceTurn = false;
+ _isAwaitingSpokenAnswer = IsReadAloudOn;
+ if (!_isAwaitingSpokenAnswer || VoiceState != PDChatVoiceState.Listening)
{
return;
}
- CancelAutoSend();
- _hasDictatedSinceTurn = false;
VoiceState = PDChatVoiceState.Thinking;
- _isAwaitingSpokenAnswer = true;
// Half duplex: while Merlin thinks and speaks, the microphone sends nothing, so it never hears itself.
await (_voiceModule?.InvokeVoidAsync("pause", true) ?? ValueTask.CompletedTask);
}
- /// Reads the first finished reply to a spoken question aloud, then listens again.
+ /// Reads the first finished reply to a sent message aloud, when read-aloud is on.
private async Task SpeakAnswerIfAwaitedAsync(ChatMessage message)
{
if (!_isAwaitingSpokenAnswer || message.Sender.IsUser || message.Type == MessageType.Typing
- || ChatService.VoiceEndpoints is not { } endpoints || _voiceModule is null)
+ || ChatService.VoiceEndpoints is not { } endpoints)
{
return;
}
_isAwaitingSpokenAnswer = false;
- VoiceState = PDChatVoiceState.Speaking;
- await InvokeAsync(StateHasChanged);
+ if (VoiceState == PDChatVoiceState.Thinking)
+ {
+ VoiceState = PDChatVoiceState.Speaking;
+ await InvokeAsync(StateHasChanged);
+ }
- // The module calls OnVoiceSpoken when the last of the audio has played.
- await _voiceModule.InvokeVoidAsync("speak", endpoints.SpeakUrl, ToSpeakableText(message), _voiceReference);
+ try
+ {
+ var module = await GetVoiceModuleAsync(endpoints);
+
+ // The module calls OnVoiceSpoken when the last of the audio has played.
+ await module.InvokeVoidAsync("speak", endpoints.SpeakUrl, ToSpeakableText(message), _voiceReference);
+ }
+ catch (JSException)
+ {
+ VoiceError = "The answer could not be read aloud.";
+ await OnVoiceSpoken();
+ }
}
- /// Called by the voice module when an answer has finished playing; listening resumes.
+ /// Called by the voice module when an answer has finished playing; a paused microphone listens again.
[JSInvokable]
public async Task OnVoiceSpoken()
{
- if (VoiceState != PDChatVoiceState.Speaking)
+ if (VoiceState is not (PDChatVoiceState.Speaking or PDChatVoiceState.Thinking))
{
return;
}
- VoiceState = PDChatVoiceState.Listening;
- await (_voiceModule?.InvokeVoidAsync("pause", false) ?? ValueTask.CompletedTask);
+ await ResumeListeningAsync();
await InvokeAsync(StateHasChanged);
}
diff --git a/PanoramicData.Blazor/PDChat.razor b/PanoramicData.Blazor/PDChat.razor
index 55244cee..d9fd3674 100644
--- a/PanoramicData.Blazor/PDChat.razor
+++ b/PanoramicData.Blazor/PDChat.razor
@@ -84,9 +84,6 @@
ποΈ
}
-
@* Show dock to side button for corner modes *@
@if (ChatService.DockMode is PDChatDockMode.BottomRight or PDChatDockMode.TopRight
or PDChatDockMode.BottomLeft or PDChatDockMode.TopLeft)
@@ -291,34 +288,56 @@
@code {
- private RenderFragment InputAccessoriesContent => @
+ private RenderFragment InputAccessories => @
@if (IsAgentPickerOffered)
{
-
+
+
+ }
+ @if (IsModelPickerOffered)
+ {
+
}
+
+
@if (IsVoiceModeOffered)
{
+
+ aria-label="@VoiceButtonTitle"
+ aria-pressed="@(IsVoiceModeOn ? "true" : "false")">ποΈ
}
;
}
\ No newline at end of file
diff --git a/PanoramicData.Blazor/PDChat.razor.css b/PanoramicData.Blazor/PDChat.razor.css
index 79c33057..187b0f43 100644
--- a/PanoramicData.Blazor/PDChat.razor.css
+++ b/PanoramicData.Blazor/PDChat.razor.css
@@ -543,26 +543,66 @@ body:has(.pdchat-container.dock-fullscreen) {
outline-offset: 1px;
}
-/* Input-area controls: the Voice switch carries a word as well as an icon, so it says what it does. */
-.pdchat-voice-toggle {
- white-space: nowrap;
+/* Input toolbar: icon-only toggles (each titled) and compact pickers. */
+.pdchat-toolbar-btn {
+ padding: 1px 3px;
+ line-height: 1.4;
+ font-size: 0.85rem;
+ background: none;
+ border: 1px solid transparent;
+ border-radius: 4px;
+ cursor: pointer;
+ opacity: 0.6;
+}
+
+.pdchat-toolbar-btn:hover,
+.pdchat-toolbar-btn:focus-visible {
+ opacity: 1;
+ border-color: var(--pd-chat-border, #dee2e6);
+}
+
+.pdchat-mute-toggle[aria-pressed="true"],
+.pdchat-toolbar-btn.active {
+ opacity: 1;
}
.pdchat-voice-toggle.active {
- color: var(--pd-chat-bg, #fff);
background: #dc3545;
border-color: #dc3545;
}
-.pdchat-agent-picker {
+.pdchat-toolbar-spacer {
+ flex: 1;
+}
+
+.pdchat-toolbar-picker {
display: flex;
+ flex-shrink: 0;
align-items: center;
gap: 4px;
- min-width: 0;
+ margin: 0;
}
-.pdchat-agent-picker select {
- min-width: 0;
+.pdchat-toolbar-label {
+ color: var(--pd-chat-muted-color, #6c757d);
+}
+
+/* A corner-docked chat is too narrow for the words; each select keeps its tooltip and accessible name. */
+@container (max-width: 420px) {
+ .pdchat-toolbar-label {
+ display: none;
+ }
+}
+
+.pdchat-toolbar-picker select {
+ field-sizing: content;
+ max-width: 10rem;
+ padding: 0 4px;
+ font-size: inherit;
+ color: var(--pd-chat-color, #212529);
+ background: var(--pd-chat-bg, #fff);
+ border: 1px solid var(--pd-chat-border, #ccc);
+ border-radius: 4px;
}
.pdchat-agent-icon {
diff --git a/PanoramicData.Blazor/PDMessages.razor b/PanoramicData.Blazor/PDMessages.razor
index 030ca229..72f99e8e 100644
--- a/PanoramicData.Blazor/PDMessages.razor
+++ b/PanoramicData.Blazor/PDMessages.razor
@@ -17,6 +17,17 @@
}
}
+ @if (InputAccessories is not null || IsInputPermitted)
+ {
+ @* Every control sits in this thin bar, so the text box below has the whole width. *@
+
@@ -30,11 +41,6 @@
placeholder="Type a message..."
rows="3"
disabled="@(!IsLive)">
- @if (InputAccessories is not null)
- {
-
@InputAccessories
- }
-
}
else if (!string.IsNullOrWhiteSpace(InputDisabledMessage))
diff --git a/PanoramicData.Blazor/PDMessages.razor.cs b/PanoramicData.Blazor/PDMessages.razor.cs
index 3450cb2b..9880d6ee 100644
--- a/PanoramicData.Blazor/PDMessages.razor.cs
+++ b/PanoramicData.Blazor/PDMessages.razor.cs
@@ -100,7 +100,8 @@ public partial class PDMessages : IChatInput
internal ElementReference InputRef { get; set; }
///
- /// Gets or sets optional controls shown in the input row beside the Send button, such as a voice or agent control.
+ /// Gets or sets optional controls shown in the thin toolbar above the text box, before Send. The toolbar is shown
+ /// even where input is not permitted, so a control such as notification sounds stays reachable.
///
[Parameter] public RenderFragment? InputAccessories { get; set; }
diff --git a/PanoramicData.Blazor/PDMessages.razor.css b/PanoramicData.Blazor/PDMessages.razor.css
index eb5d65e5..74da111e 100644
--- a/PanoramicData.Blazor/PDMessages.razor.css
+++ b/PanoramicData.Blazor/PDMessages.razor.css
@@ -113,15 +113,35 @@
flex-shrink: 0;
}
-/* Host-supplied controls (voice, agent) stacked beside Send. */
+/* Host-supplied controls in a thin toolbar above the input, which keeps the full width. */
.chat-input-accessories {
+ container-type: inline-size;
display: flex;
- flex-direction: column;
- align-items: stretch;
+ flex-wrap: wrap;
+ align-items: center;
gap: 4px;
flex-shrink: 0;
min-width: 0;
- max-width: 40%;
+ margin-top: auto;
+ padding: 2px 6px;
+ font-size: 0.8rem;
+ background: var(--pd-chat-secondary-bg, #f5f7fa);
+ border-top: 1px solid var(--pd-chat-border, #dee2e6);
+}
+
+/* Send ends the bar, after any host controls. */
+.chat-input-accessories .chat-send {
+ margin-left: auto;
+ padding: 0 8px;
+ flex-shrink: 0;
+}
+
+.chat-input-accessories + .chat-input-container,
+.chat-input-accessories + .chat-input-disabled,
+.pdchat-window.fullscreen .chat-input-accessories + .chat-input-container {
+ margin-top: 0;
+ padding-top: 0;
+ border-top: none;
}
/* Message shown in place of the input when input is not permitted (IsInputPermitted = false).
diff --git a/PanoramicData.Blazor/Services/DumbChatService.Voice.cs b/PanoramicData.Blazor/Services/DumbChatService.Voice.cs
index 50116c49..90d34e78 100644
--- a/PanoramicData.Blazor/Services/DumbChatService.Voice.cs
+++ b/PanoramicData.Blazor/Services/DumbChatService.Voice.cs
@@ -1,17 +1,25 @@
namespace PanoramicData.Blazor.Services;
///
-/// The Voice Mode and agent picker of , so a demo shows both without a speech service.
+/// The voice controls and the agent and model pickers of , so a demo shows the whole input
+/// toolbar without a speech service.
///
public partial class DumbChatService
{
- /// Gets or sets Voice Mode's endpoints; simulated by default, and null hides Voice Mode.
+ /// Gets or sets the voice endpoints; simulated by default, and null hides the voice controls.
public PDChatVoiceEndpoints? VoiceEndpoints { get; set; } = PDChatVoiceEndpoints.Simulated;
- /// Gets the agents offered in the input area.
+ /// Gets the agents offered in the input toolbar.
public IReadOnlyList? Agents { get; } =
[
new("dumbbot", "DumbBot", "Answers by keyword, and not very well."),
- new("parrot", "Parrot", "Exactly as helpful as DumbBot, with more feathers."),
+ new("pedant", "Pedant", "Exactly as helpful as DumbBot, but would like to correct your grammar first."),
+ ];
+
+ /// Gets the models offered in the input toolbar. The demo answers the same whichever is chosen.
+ public IReadOnlyList? Models { get; } =
+ [
+ new("dumb-mini", "Dumb Mini", "Small and quick."),
+ new("dumb-max", "Dumb Max", "Large and thorough. Equally dumb."),
];
}