diff --git a/MeetingAssistant.Tests/TaskbarIconTests.cs b/MeetingAssistant.Tests/TaskbarIconTests.cs index 873a7e4..9416f8f 100644 --- a/MeetingAssistant.Tests/TaskbarIconTests.cs +++ b/MeetingAssistant.Tests/TaskbarIconTests.cs @@ -11,15 +11,20 @@ public sealed class TaskbarIconTests { var menu = MeetingTaskbarMenuBuilder.Build( Status(), - [Profile("default"), Profile("english")]); + [Profile("default", "Ctrl+Alt+M"), Profile("english", "Ctrl+Alt+E")]); Assert.Equal(RecordingProcessState.Idle, menu.State); Assert.Contains(menu.Items, item => - item.Action == MeetingTaskbarAction.StartRecording && - item.ProfileName == "default"); + item.Action == MeetingTaskbarAction.EditRules && + item.Text == "Edit rules and identities"); Assert.Contains(menu.Items, item => item.Action == MeetingTaskbarAction.StartRecording && - item.ProfileName == "english"); + item.ProfileName == "default" && + item.Text == "Start meeting recording (default)\tCtrl+Alt+M"); + Assert.Contains(menu.Items, item => + item.Action == MeetingTaskbarAction.StartRecording && + item.ProfileName == "english" && + item.Text == "Start meeting recording (english)\tCtrl+Alt+E"); Assert.DoesNotContain(menu.Items, item => item.Action == MeetingTaskbarAction.StopRecording); Assert.DoesNotContain(menu.Items, item => item.Action == MeetingTaskbarAction.AbortRecording); } @@ -29,17 +34,19 @@ public sealed class TaskbarIconTests { var menu = MeetingTaskbarMenuBuilder.Build( Status(isRecording: true, state: RecordingProcessState.Recording, profile: "default"), - [Profile("default"), Profile("english"), Profile("french")]); + [Profile("default", "Ctrl+Alt+M"), Profile("english", "Ctrl+Alt+E"), Profile("french", "Ctrl+Alt+F")]); Assert.Equal(RecordingProcessState.Recording, menu.State); Assert.Contains(menu.Items, item => item.Action == MeetingTaskbarAction.StopRecording); Assert.Contains(menu.Items, item => item.Action == MeetingTaskbarAction.AbortRecording); Assert.Contains(menu.Items, item => item.Action == MeetingTaskbarAction.SwitchProfile && - item.ProfileName == "english"); + item.ProfileName == "english" && + item.Text == "Switch to english\tCtrl+Alt+E"); Assert.Contains(menu.Items, item => item.Action == MeetingTaskbarAction.SwitchProfile && - item.ProfileName == "french"); + item.ProfileName == "french" && + item.Text == "Switch to french\tCtrl+Alt+F"); Assert.DoesNotContain(menu.Items, item => item.Action == MeetingTaskbarAction.SwitchProfile && item.ProfileName == "default"); @@ -74,9 +81,15 @@ public sealed class TaskbarIconTests Assert.Equal(RecordingProcessState.Recording, menu.State); } - private static LaunchProfile Profile(string name) + private static LaunchProfile Profile(string name, string hotkey = "") { - return new LaunchProfile(name, new MeetingAssistantOptions()); + return new LaunchProfile(name, new MeetingAssistantOptions + { + Hotkey = + { + Toggle = hotkey + } + }); } private static RecordingStatus Status( diff --git a/MeetingAssistant.Tests/WorkflowRulesEditorTests.cs b/MeetingAssistant.Tests/WorkflowRulesEditorTests.cs new file mode 100644 index 0000000..11d935b --- /dev/null +++ b/MeetingAssistant.Tests/WorkflowRulesEditorTests.cs @@ -0,0 +1,492 @@ +using MeetingAssistant.Workflow; +using MeetingAssistant.Speakers; +using Microsoft.EntityFrameworkCore; +using Microsoft.Extensions.Logging.Abstractions; +using System.Text.Json; + +namespace MeetingAssistant.Tests; + +public sealed class WorkflowRulesEditorTests +{ + [Fact] + public void EditorOptionsInheritSummarizerDefaultsAndOverrideConfiguredValues() + { + var defaults = new AgentOptions + { + Endpoint = "https://summary.example", + Key = "summary-key", + KeyEnv = "SUMMARY_KEY", + Model = "summary-model", + EnableThinking = true, + ReasoningEffort = ReasoningEffortOption.High, + ReconnectionAttempts = 7, + ReconnectionDelay = TimeSpan.FromSeconds(7), + ContextWindowTokens = 1000, + MaxOutputTokens = 100, + EnableCompaction = true, + CompactionRemainingRatio = 0.2, + ResponsesCompactPath = "responses/compact" + }; + var editor = new WorkflowRulesEditorOptions + { + Model = "editor-model", + EnableThinking = false, + MaxOutputTokens = 50 + }; + + var effective = editor.ToEffectiveAgentOptions(defaults); + + Assert.Equal("https://summary.example", effective.Endpoint); + Assert.Equal("summary-key", effective.Key); + Assert.Equal("SUMMARY_KEY", effective.KeyEnv); + Assert.Equal("editor-model", effective.Model); + Assert.False(effective.EnableThinking); + Assert.Equal(ReasoningEffortOption.High, effective.ReasoningEffort); + Assert.Equal(7, effective.ReconnectionAttempts); + Assert.Equal(TimeSpan.FromSeconds(7), effective.ReconnectionDelay); + Assert.Equal(1000, effective.ContextWindowTokens); + Assert.Equal(50, effective.MaxOutputTokens); + Assert.True(effective.EnableCompaction); + Assert.Equal(0.2, effective.CompactionRemainingRatio); + Assert.Equal("responses/compact", effective.ResponsesCompactPath); + } + + [Fact] + public async Task RulesEditorToolsReadWriteAndSearchOnlyConfiguredRulesFile() + { + var root = Path.Combine(Path.GetTempPath(), "meeting-assistant-tests", Guid.NewGuid().ToString("N")); + Directory.CreateDirectory(root); + var rulesPath = Path.Combine(root, "rules.yaml"); + var unrelatedPath = Path.Combine(root, "other.yaml"); + await File.WriteAllTextAsync(unrelatedPath, "rules:\n- name: do-not-find\n"); + var tools = new WorkflowRulesEditorTools(new MeetingAssistantOptions + { + Automation = new AutomationOptions { RulesPath = rulesPath } + }); + + var writeResult = await tools.WriteRules(""" + rules: + - name: add-me + on: + - created: {} + steps: + - uses: add_attendee + value: Manuel + """); + var readResult = await tools.ReadRules(); + var searchResult = await tools.Search("add-me"); + + Assert.Equal(rulesPath, writeResult); + Assert.Contains("name: add-me", readResult); + Assert.Contains("rules.yaml:", searchResult); + Assert.DoesNotContain("other.yaml", searchResult); + } + + [Fact] + public async Task RulesEditorToolsRefuseInvalidYamlWithoutOverwritingFile() + { + var root = Path.Combine(Path.GetTempPath(), "meeting-assistant-tests", Guid.NewGuid().ToString("N")); + Directory.CreateDirectory(root); + var rulesPath = Path.Combine(root, "rules.yaml"); + await File.WriteAllTextAsync(rulesPath, "rules: []"); + var tools = new WorkflowRulesEditorTools(new MeetingAssistantOptions + { + Automation = new AutomationOptions { RulesPath = rulesPath } + }); + + var result = await tools.WriteRules("rules:\n- name: ["); + + Assert.StartsWith("Refused: workflow rules YAML is invalid.", result); + Assert.Equal("rules: []", await File.ReadAllTextAsync(rulesPath)); + } + + [Fact] + public async Task InstructionBuilderIncludesConfiguredRulesPathAndWorkflowDocs() + { + var root = Path.Combine(Path.GetTempPath(), "meeting-assistant-tests", Guid.NewGuid().ToString("N")); + var rulesPath = Path.Combine(root, "rules.yaml"); + var builder = new WorkflowRulesEditorInstructionBuilder( + NullLogger.Instance); + + var instructions = await builder.BuildAsync(new MeetingAssistantOptions + { + Automation = new AutomationOptions { RulesPath = rulesPath } + }, CancellationToken.None); + + Assert.Contains(rulesPath, instructions); + Assert.Contains("Meeting Workflow Engine", instructions); + Assert.Contains("Workflow rules reference documentation", instructions); + Assert.Contains("speaker identity database", instructions); + } + + [Fact] + public async Task RulesEditorToolsCrudAndSearchSpeakerIdentities() + { + await using var fixture = await WorkflowRulesEditorIdentityFixture.CreateAsync(); + var tools = fixture.CreateTools(); + + var createResult = await tools.CreateIdentity( + "Sabrina", + ["Sabi"], + ["Guest-01"]); + using var created = JsonDocument.Parse(createResult); + var identityId = created.RootElement + .GetProperty("identity") + .GetProperty("id") + .GetInt32(); + + var searchResult = await tools.SearchIdentities("sabi"); + var readResult = await tools.ReadIdentity(identityId); + var updateResult = await tools.UpdateIdentity( + identityId, + "Sabrina M.", + ["Sabrina"], + ["Guest-02"]); + var deleteResult = await tools.DeleteIdentity(identityId); + + Assert.Contains("\"displayName\": \"Sabrina\"", searchResult); + Assert.Contains("\"canonicalName\": \"Sabrina\"", readResult); + Assert.Contains("\"canonicalName\": \"Sabrina M.\"", updateResult); + Assert.Equal($"Deleted identity {identityId}.", deleteResult); + Assert.Empty(await fixture.Context.SpeakerIdentities.ToListAsync()); + } + + [Fact] + public async Task RulesEditorToolsMergeSpeakerIdentitiesIntoTarget() + { + await using var fixture = await WorkflowRulesEditorIdentityFixture.CreateAsync(); + var target = await fixture.AddIdentityAsync("Sabrina", aliases: ["Sabi"], sample: [1]); + var source = await fixture.AddIdentityAsync("Sabine", aliases: ["Guest Name"], candidates: ["Guest-01"], sample: [2, 3]); + var tools = fixture.CreateTools(); + + var result = await tools.MergeIdentities(target.Id, source.Id); + + Assert.Contains("\"canonicalName\": \"Sabrina\"", result); + var saved = await fixture.LoadIdentityAsync(target.Id); + Assert.Equal(["Guest Name", "Guest-01", "Sabi", "Sabine"], saved.Aliases.Select(alias => alias.Name).Order()); + var snippets = saved.Snippets.Select(sample => sample.WavBytes).OrderBy(bytes => bytes.Length).ToArray(); + Assert.Equal([1], snippets[0]); + Assert.Equal([2, 3], snippets[1]); + Assert.False(await fixture.Context.SpeakerIdentities.AnyAsync(identity => identity.Id == source.Id)); + } + + [Fact] + public async Task RulesEditorToolsListReadQueueAndDeleteSpeakerSamples() + { + await using var fixture = await WorkflowRulesEditorIdentityFixture.CreateAsync(); + var identity = await fixture.AddIdentityAsync("Sabrina", sample: [4, 5, 6]); + var sampleId = identity.Snippets.Single().Id; + var queue = new CapturingSamplePlaybackQueue(); + var tools = fixture.CreateTools(queue); + + var listResult = await tools.ListIdentitySamples(identity.Id); + var readResult = await tools.ReadIdentitySample(sampleId); + var playResult = await tools.QueuePlayIdentitySample(sampleId); + var deleteResult = await tools.DeleteIdentitySample(sampleId); + + Assert.Contains($"\"id\": {sampleId}", listResult); + Assert.Contains(Convert.ToBase64String([4, 5, 6]), readResult); + Assert.Equal($"Queued sample {sampleId}.", playResult); + var queued = queue.Queued.Single(); + Assert.Equal(sampleId, queued.SampleId); + Assert.Equal([4, 5, 6], queued.WavBytes); + Assert.Equal($"Deleted sample {sampleId}.", deleteResult); + Assert.Empty(await fixture.Context.SpeakerSnippets.ToListAsync()); + } + + [Fact] + public async Task ViewModelShowsUserMessageThinkingStateAndFinalAgentMessage() + { + var pipeline = new BlockingRulesEditorPipeline("Updated rules."); + var viewModel = new WorkflowRulesEditorChatViewModel(pipeline) + { + Draft = "Rename ABS Daily" + }; + + var sendTask = viewModel.SendAsync(); + await pipeline.Started.Task.WaitAsync(TimeSpan.FromSeconds(2)); + + Assert.True(viewModel.IsThinking); + Assert.Equal("", viewModel.Draft); + Assert.Equal( + [new WorkflowRulesEditorChatMessage(WorkflowRulesEditorChatRole.User, "Rename ABS Daily")], + viewModel.Messages.ToArray()); + + pipeline.Release.SetResult(); + await sendTask.WaitAsync(TimeSpan.FromSeconds(2)); + + Assert.False(viewModel.IsThinking); + Assert.Equal(2, viewModel.Messages.Count); + Assert.Equal(WorkflowRulesEditorChatRole.Agent, viewModel.Messages[1].Role); + Assert.Equal("Updated rules.", viewModel.Messages[1].Content); + } + + [Theory] + [InlineData(0, 500, 0, true)] + [InlineData(0, 500, 400, true)] + [InlineData(492, 500, 1000, true)] + [InlineData(300, 500, 1000, false)] + public void ScrollPolicyAutoScrollsOnlyWhenAlreadyNearBottom( + double verticalOffset, + double viewportHeight, + double contentHeight, + bool expected) + { + Assert.Equal( + expected, + WorkflowRulesEditorScrollPolicy.ShouldAutoScroll( + verticalOffset, + viewportHeight, + contentHeight)); + } + + [Fact] + public void ScrollPolicyAutoScrollsWhenPreviousContentFitBeforeNewCardOverflows() + { + Assert.True(WorkflowRulesEditorScrollPolicy.ShouldAutoScroll( + verticalOffset: 0, + viewportHeight: 500, + previousContentHeight: 500)); + } + + [Fact] + public void ScrollPolicyComputesNonNegativeBottomOffset() + { + Assert.Equal(500, WorkflowRulesEditorScrollPolicy.GetBottomOffset(500, 1000)); + Assert.Equal(0, WorkflowRulesEditorScrollPolicy.GetBottomOffset(500, 400)); + } + + [Fact] + public void MarkdownParserRecognizesRequestedInlineStyles() + { + var inlines = WorkflowRulesEditorMarkdown.ParseInline( + "Normal *italic* **bold** and `code`."); + + Assert.Equal( + [ + new WorkflowRulesEditorMarkdownInline("Normal ", WorkflowRulesEditorMarkdownInlineStyle.Normal), + new WorkflowRulesEditorMarkdownInline("italic", WorkflowRulesEditorMarkdownInlineStyle.Italic), + new WorkflowRulesEditorMarkdownInline(" ", WorkflowRulesEditorMarkdownInlineStyle.Normal), + new WorkflowRulesEditorMarkdownInline("bold", WorkflowRulesEditorMarkdownInlineStyle.Bold), + new WorkflowRulesEditorMarkdownInline(" and ", WorkflowRulesEditorMarkdownInlineStyle.Normal), + new WorkflowRulesEditorMarkdownInline("code", WorkflowRulesEditorMarkdownInlineStyle.Code), + new WorkflowRulesEditorMarkdownInline(".", WorkflowRulesEditorMarkdownInlineStyle.Normal) + ], + inlines); + } + + [Fact] + public void MarkdownParserPreservesParagraphLineBreaks() + { + var blocks = WorkflowRulesEditorMarkdown.Parse(""" + First line + Second line with `code` + """); + + var paragraph = Assert.IsType(Assert.Single(blocks)); + Assert.Contains(paragraph.Inlines, inline => + inline.Style == WorkflowRulesEditorMarkdownInlineStyle.Normal && + inline.Text.Contains('\n')); + Assert.Contains(paragraph.Inlines, inline => + inline.Style == WorkflowRulesEditorMarkdownInlineStyle.Code && + inline.Text == "code"); + } + + [Fact] + public void MarkdownParserRecognizesPipeTables() + { + var blocks = WorkflowRulesEditorMarkdown.Parse(""" + Current identities: + + | ID | Name | Samples | + |----|------|---------| + | 1 | Manuel | 2 | + | 2 | Sabrina | 1 | + """); + + Assert.Collection( + blocks, + block => Assert.IsType(block), + block => + { + var table = Assert.IsType(block); + Assert.Equal(["ID", "Name", "Samples"], table.Header); + Assert.Equal(["1", "Manuel", "2"], table.Rows[0]); + Assert.Equal(["2", "Sabrina", "1"], table.Rows[1]); + }); + } + + [Fact] + public void MarkdownParserRecognizesFencedCodeBlocks() + { + var blocks = WorkflowRulesEditorMarkdown.Parse(""" + Before + + ```yaml + rules: + - name: example + ``` + + After **bold** + """); + + Assert.Collection( + blocks, + block => Assert.IsType(block), + block => + { + var code = Assert.IsType(block); + Assert.Equal("rules:" + Environment.NewLine + "- name: example", code.Text); + }, + block => + { + var paragraph = Assert.IsType(block); + Assert.Contains(paragraph.Inlines, inline => inline.Style == WorkflowRulesEditorMarkdownInlineStyle.Bold); + }); + } + + private sealed class BlockingRulesEditorPipeline : IWorkflowRulesEditorChatPipeline + { + private readonly string response; + + public BlockingRulesEditorPipeline(string response) + { + this.response = response; + } + + public TaskCompletionSource Started { get; } = new(TaskCreationOptions.RunContinuationsAsynchronously); + + public TaskCompletionSource Release { get; } = new(TaskCreationOptions.RunContinuationsAsynchronously); + + public async Task SendAsync( + IReadOnlyList conversation, + string userMessage, + CancellationToken cancellationToken) + { + Started.SetResult(); + await Release.Task.WaitAsync(cancellationToken); + return new WorkflowRulesEditorChatResult( + response, + conversation + .Append(new WorkflowRulesEditorChatMessage(WorkflowRulesEditorChatRole.User, userMessage)) + .Append(new WorkflowRulesEditorChatMessage(WorkflowRulesEditorChatRole.Agent, response)) + .ToList()); + } + } + + private sealed class WorkflowRulesEditorIdentityFixture : IAsyncDisposable + { + private readonly string tempDirectory; + private readonly string dbPath; + + private WorkflowRulesEditorIdentityFixture( + string tempDirectory, + string dbPath, + SpeakerIdentityDbContext context) + { + this.tempDirectory = tempDirectory; + this.dbPath = dbPath; + Context = context; + } + + public SpeakerIdentityDbContext Context { get; } + + public static async Task CreateAsync() + { + var tempDirectory = Path.Combine(Path.GetTempPath(), "meeting-assistant-tests", Guid.NewGuid().ToString("N")); + Directory.CreateDirectory(tempDirectory); + var dbPath = Path.Combine(tempDirectory, "speaker-identities.db"); + var context = new SpeakerIdentityDbContext(new DbContextOptionsBuilder() + .UseSqlite($"Data Source={dbPath};Pooling=False") + .Options); + await SpeakerIdentitySchema.EnsureCreatedOrUpdatedAsync(context, CancellationToken.None); + return new WorkflowRulesEditorIdentityFixture(tempDirectory, dbPath, context); + } + + public WorkflowRulesEditorTools CreateTools( + IWorkflowRulesEditorSamplePlaybackQueue? samplePlaybackQueue = null) + { + return new WorkflowRulesEditorTools( + new MeetingAssistantOptions(), + new TestSpeakerIdentityDbContextFactory(dbPath), + samplePlaybackQueue); + } + + public async Task AddIdentityAsync( + string canonicalName, + IReadOnlyList? aliases = null, + IReadOnlyList? candidates = null, + byte[]? sample = null) + { + var now = DateTimeOffset.UtcNow; + var identity = new SpeakerIdentity + { + CanonicalName = canonicalName, + CreatedAt = now, + UpdatedAt = now, + Aliases = aliases?.Select(alias => new SpeakerAlias { Name = alias }).ToList() ?? [], + CandidateNames = candidates?.Select(candidate => new SpeakerCandidateName { Name = candidate }).ToList() ?? [], + Snippets = sample is null + ? [] + : + [ + new SpeakerSnippet + { + WavBytes = sample, + CreatedAt = now + } + ] + }; + Context.SpeakerIdentities.Add(identity); + await Context.SaveChangesAsync(); + return identity; + } + + public Task LoadIdentityAsync(int id) + { + Context.ChangeTracker.Clear(); + return Context.SpeakerIdentities + .Include(identity => identity.Aliases) + .Include(identity => identity.CandidateNames) + .Include(identity => identity.Snippets) + .SingleAsync(identity => identity.Id == id); + } + + public async ValueTask DisposeAsync() + { + await Context.DisposeAsync(); + if (Directory.Exists(tempDirectory)) + { + Directory.Delete(tempDirectory, recursive: true); + } + } + } + + private sealed class TestSpeakerIdentityDbContextFactory : IDbContextFactory + { + private readonly string dbPath; + + public TestSpeakerIdentityDbContextFactory(string dbPath) + { + this.dbPath = dbPath; + } + + public SpeakerIdentityDbContext CreateDbContext() + { + return new SpeakerIdentityDbContext(new DbContextOptionsBuilder() + .UseSqlite($"Data Source={dbPath};Pooling=False") + .Options); + } + } + + private sealed class CapturingSamplePlaybackQueue : IWorkflowRulesEditorSamplePlaybackQueue + { + public List<(int SampleId, byte[] WavBytes)> Queued { get; } = []; + + public Task QueueAsync(int sampleId, byte[] wavBytes, CancellationToken cancellationToken = default) + { + Queued.Add((sampleId, wavBytes)); + return Task.FromResult($"Queued sample {sampleId}."); + } + } +} diff --git a/MeetingAssistant/Assets/meeting-assistant.ico b/MeetingAssistant/Assets/meeting-assistant.ico new file mode 100644 index 0000000..c3cc398 Binary files /dev/null and b/MeetingAssistant/Assets/meeting-assistant.ico differ diff --git a/MeetingAssistant/MeetingAssistant.csproj b/MeetingAssistant/MeetingAssistant.csproj index 3675acb..89dc795 100644 --- a/MeetingAssistant/MeetingAssistant.csproj +++ b/MeetingAssistant/MeetingAssistant.csproj @@ -7,8 +7,9 @@ true - - true + + WinExe + Assets\meeting-assistant.ico @@ -29,12 +30,32 @@ - + + + + + + + + + + + + + + + + + + + + - + + diff --git a/MeetingAssistant/MeetingAssistantOptions.cs b/MeetingAssistant/MeetingAssistantOptions.cs index 9466b1c..3fa8056 100644 --- a/MeetingAssistant/MeetingAssistantOptions.cs +++ b/MeetingAssistant/MeetingAssistantOptions.cs @@ -22,6 +22,8 @@ public sealed class MeetingAssistantOptions public AgentOptions Agent { get; set; } = new(); + public WorkflowRulesEditorOptions WorkflowRulesEditor { get; set; } = new(); + public ApiOptions Api { get; set; } = new(); } @@ -333,6 +335,60 @@ public sealed class AgentOptions public string? InitialPrompt { get; set; } } +public sealed class WorkflowRulesEditorOptions +{ + public string? Endpoint { get; set; } + + public string? Key { get; set; } + + public string? KeyEnv { get; set; } + + public string? Model { get; set; } + + public bool? EnableThinking { get; set; } + + public ReasoningEffortOption? ReasoningEffort { get; set; } + + public int? ReconnectionAttempts { get; set; } + + public TimeSpan? ReconnectionDelay { get; set; } + + public int? ContextWindowTokens { get; set; } + + public int? MaxOutputTokens { get; set; } + + public bool? EnableCompaction { get; set; } + + public double? CompactionRemainingRatio { get; set; } + + public string? ResponsesCompactPath { get; set; } + + public string? InitialPrompt { get; set; } + + public AgentOptions ToEffectiveAgentOptions(AgentOptions defaults) + { + return new AgentOptions + { + Endpoint = string.IsNullOrWhiteSpace(Endpoint) ? defaults.Endpoint : Endpoint, + Key = string.IsNullOrWhiteSpace(Key) ? defaults.Key : Key, + KeyEnv = string.IsNullOrWhiteSpace(KeyEnv) ? defaults.KeyEnv : KeyEnv!, + Model = string.IsNullOrWhiteSpace(Model) ? defaults.Model : Model!, + EnableThinking = EnableThinking ?? defaults.EnableThinking, + ReasoningEffort = ReasoningEffort ?? defaults.ReasoningEffort, + ReconnectionAttempts = ReconnectionAttempts ?? defaults.ReconnectionAttempts, + ReconnectionDelay = ReconnectionDelay ?? defaults.ReconnectionDelay, + ContextWindowTokens = ContextWindowTokens ?? defaults.ContextWindowTokens, + MaxOutputTokens = MaxOutputTokens ?? defaults.MaxOutputTokens, + EnableCompaction = EnableCompaction ?? defaults.EnableCompaction, + CompactionRemainingRatio = CompactionRemainingRatio ?? defaults.CompactionRemainingRatio, + ResponsesCompactPath = string.IsNullOrWhiteSpace(ResponsesCompactPath) + ? defaults.ResponsesCompactPath + : ResponsesCompactPath!, + InitialPrompt = string.IsNullOrWhiteSpace(InitialPrompt) ? null : InitialPrompt + }; + } +} + public sealed class ApiOptions { public string PublicBaseUrl { get; set; } = "http://localhost:5090"; diff --git a/MeetingAssistant/Program.cs b/MeetingAssistant/Program.cs index c0b9c93..86b3b62 100644 --- a/MeetingAssistant/Program.cs +++ b/MeetingAssistant/Program.cs @@ -61,6 +61,15 @@ builder.Services.AddSingleton(); builder.Services.AddSingleton(); builder.Services.AddSingleton(); +builder.Services.AddSingleton(); +builder.Services.AddSingleton(); +builder.Services.AddSingleton(); +builder.Services.AddTransient(); +#if WINDOWS +builder.Services.AddSingleton(); +#else +builder.Services.AddSingleton(); +#endif builder.Services.AddSingleton(); builder.Services.AddSingleton(); builder.Services.AddSingleton(); @@ -81,7 +90,7 @@ builder.Services.AddHostedService(); builder.Services.AddHostedService(); #if WINDOWS builder.Services.AddHostedService(); -builder.Services.AddHostedService(); +builder.Services.AddHostedService(); #endif var app = builder.Build(); @@ -213,6 +222,12 @@ static async Task MergeSpeakerIdentitiesAsync( } app.MapPost("/diagnostics/workflow/reload", (IConfiguration configuration) => Results.Ok(ReloadWorkflowConfiguration(configuration))); +app.MapPost("/diagnostics/workflow/rules-editor/show", ( + IWorkflowRulesEditorWindowService rulesEditorWindow) => +{ + rulesEditorWindow.Show(); + return Results.Accepted(); +}); static WorkflowConfigurationReloadResponse ReloadWorkflowConfiguration(IConfiguration configuration) { diff --git a/MeetingAssistant/Speakers/SpeakerIdentityMergeService.cs b/MeetingAssistant/Speakers/SpeakerIdentityMergeService.cs index fab3c09..9500f64 100644 --- a/MeetingAssistant/Speakers/SpeakerIdentityMergeService.cs +++ b/MeetingAssistant/Speakers/SpeakerIdentityMergeService.cs @@ -110,7 +110,10 @@ public sealed class SpeakerIdentityMergeService : ISpeakerIdentityMergeService var targetName = target.GetDisplayName() ?? $"identity-{target.Id}"; var sourceName = source.GetDisplayName() ?? $"identity-{source.Id}"; - MergeIdentities(target, source); + SpeakerIdentityMerger.MergeInto( + target, + source, + options.MaxSnippetsPerSpeaker); await SpeakerIdentityTranscriptAudit.AppendMergedAsync( target.References, targetName, @@ -161,60 +164,4 @@ public sealed class SpeakerIdentityMergeService : ISpeakerIdentityMergeService .FirstOrDefault(snippet => excludedSnippet is null || snippet.Id != excludedSnippet.Id); } - private void MergeIdentities(SpeakerIdentity target, SpeakerIdentity source) - { - AddAlias(target, source.CanonicalName); - foreach (var alias in source.Aliases) - { - AddAlias(target, alias.Name); - } - - foreach (var candidate in source.CandidateNames) - { - AddAlias(target, candidate.Name); - } - - target.UpdatedAt = DateTimeOffset.UtcNow; - foreach (var reference in source.References) - { - SpeakerIdentityReferences.AddIfMissing(target, reference); - } - - var retainedSnippets = target.Snippets - .Concat(source.Snippets) - .OrderBy(snippet => StableSnippetKey(snippet.WavBytes)) - .Take(Math.Max(1, options.MaxSnippetsPerSpeaker)) - .Select(snippet => new SpeakerSnippet - { - WavBytes = snippet.WavBytes, - CreatedAt = snippet.CreatedAt - }) - .ToList(); - target.Snippets.Clear(); - target.Snippets.AddRange(retainedSnippets); - } - - private static void AddAlias(SpeakerIdentity identity, string? alias) - { - if (string.IsNullOrWhiteSpace(alias) || - string.Equals(identity.CanonicalName, alias, StringComparison.OrdinalIgnoreCase) || - identity.Aliases.Any(existing => string.Equals(existing.Name, alias, StringComparison.OrdinalIgnoreCase))) - { - return; - } - - identity.Aliases.Add(new SpeakerAlias { Name = alias.Trim() }); - } - - private static int StableSnippetKey(byte[] snippet) - { - var hash = new HashCode(); - foreach (var value in snippet) - { - hash.Add(value); - } - - return hash.ToHashCode(); - } - } diff --git a/MeetingAssistant/Speakers/SpeakerIdentityMerger.cs b/MeetingAssistant/Speakers/SpeakerIdentityMerger.cs new file mode 100644 index 0000000..640055c --- /dev/null +++ b/MeetingAssistant/Speakers/SpeakerIdentityMerger.cs @@ -0,0 +1,63 @@ +namespace MeetingAssistant.Speakers; + +internal static class SpeakerIdentityMerger +{ + public static void MergeInto( + SpeakerIdentity target, + SpeakerIdentity source, + int maxSnippets) + { + AddAlias(target, source.CanonicalName); + foreach (var alias in source.Aliases) + { + AddAlias(target, alias.Name); + } + + foreach (var candidate in source.CandidateNames) + { + AddAlias(target, candidate.Name); + } + + foreach (var reference in source.References) + { + SpeakerIdentityReferences.AddIfMissing(target, reference); + } + + var retainedSnippets = target.Snippets + .Concat(source.Snippets) + .OrderBy(snippet => StableSnippetKey(snippet.WavBytes)) + .Take(Math.Max(1, maxSnippets)) + .Select(snippet => new SpeakerSnippet + { + WavBytes = snippet.WavBytes, + CreatedAt = snippet.CreatedAt + }) + .ToList(); + target.Snippets.Clear(); + target.Snippets.AddRange(retainedSnippets); + target.UpdatedAt = DateTimeOffset.UtcNow; + } + + private static void AddAlias(SpeakerIdentity identity, string? alias) + { + if (string.IsNullOrWhiteSpace(alias) || + string.Equals(identity.CanonicalName, alias, StringComparison.OrdinalIgnoreCase) || + identity.Aliases.Any(existing => string.Equals(existing.Name, alias, StringComparison.OrdinalIgnoreCase))) + { + return; + } + + identity.Aliases.Add(new SpeakerAlias { Name = alias.Trim() }); + } + + private static int StableSnippetKey(byte[] snippet) + { + var hash = new HashCode(); + foreach (var value in snippet) + { + hash.Add(value); + } + + return hash.ToHashCode(); + } +} diff --git a/MeetingAssistant/Taskbar/MeetingTaskbarMenu.cs b/MeetingAssistant/Taskbar/MeetingTaskbarMenu.cs index 781760d..850a115 100644 --- a/MeetingAssistant/Taskbar/MeetingTaskbarMenu.cs +++ b/MeetingAssistant/Taskbar/MeetingTaskbarMenu.cs @@ -5,6 +5,7 @@ namespace MeetingAssistant.Taskbar; public enum MeetingTaskbarAction { + EditRules, StartRecording, StopRecording, AbortRecording, @@ -27,7 +28,10 @@ public static class MeetingTaskbarMenuBuilder RecordingStatus status, IReadOnlyList launchProfiles) { - var items = new List(); + var items = new List + { + new("Edit rules and identities", MeetingTaskbarAction.EditRules) + }; if (status.IsRecording) { @@ -41,7 +45,7 @@ public static class MeetingTaskbarMenuBuilder foreach (var profile in launchProfiles.Where(profile => !IsActiveProfile(profile, status))) { items.Add(new MeetingTaskbarMenuItem( - $"Switch to {profile.Name}", + AppendHotkey($"Switch to {profile.Name}", profile.Options.Hotkey.Toggle), MeetingTaskbarAction.SwitchProfile, profile.Name)); } @@ -51,7 +55,7 @@ public static class MeetingTaskbarMenuBuilder foreach (var profile in launchProfiles) { items.Add(new MeetingTaskbarMenuItem( - $"Start meeting recording ({profile.Name})", + AppendHotkey($"Start meeting recording ({profile.Name})", profile.Options.Hotkey.Toggle), MeetingTaskbarAction.StartRecording, profile.Name)); } @@ -84,4 +88,11 @@ public static class MeetingTaskbarMenuBuilder status.LaunchProfile, StringComparison.OrdinalIgnoreCase); } + + private static string AppendHotkey(string text, string hotkey) + { + return string.IsNullOrWhiteSpace(hotkey) + ? text + : $"{text}\t{hotkey.Trim()}"; + } } diff --git a/MeetingAssistant/Taskbar/UnoTaskbarIconService.Windows.cs b/MeetingAssistant/Taskbar/UnoTaskbarIconService.Windows.cs new file mode 100644 index 0000000..bae775a --- /dev/null +++ b/MeetingAssistant/Taskbar/UnoTaskbarIconService.Windows.cs @@ -0,0 +1,292 @@ +#if WINDOWS +using System.Drawing; +using System.Runtime.InteropServices; +using H.NotifyIcon.Core; +using MeetingAssistant.LaunchProfiles; +using MeetingAssistant.Recording; +using MeetingAssistant.Workflow; + +namespace MeetingAssistant.Taskbar; + +public sealed class UnoTaskbarIconService : IHostedService, IDisposable +{ + private static readonly Guid TrayIconId = Guid.Parse("91F3D8B7-E61F-4E73-98F5-29A665991C67"); + + private readonly MeetingRecordingCoordinator coordinator; + private readonly ILaunchProfileOptionsProvider launchProfiles; + private readonly IWorkflowRulesEditorWindowService rulesEditorWindow; + private readonly ILogger logger; + private readonly object sync = new(); + private CancellationTokenSource? refreshCancellation; + private Task? refreshTask; + private TrayIconWithContextMenu? trayIcon; + private Icon? currentIcon; + private RecordingProcessState? currentState; + private string? currentProfilesSignature; + private string? currentMenuSignature; + + public UnoTaskbarIconService( + MeetingRecordingCoordinator coordinator, + ILaunchProfileOptionsProvider launchProfiles, + IWorkflowRulesEditorWindowService rulesEditorWindow, + ILogger logger) + { + this.coordinator = coordinator; + this.launchProfiles = launchProfiles; + this.rulesEditorWindow = rulesEditorWindow; + this.logger = logger; + } + + public Task StartAsync(CancellationToken cancellationToken) + { + if (!OperatingSystem.IsWindows()) + { + logger.LogInformation("Taskbar icon is only supported on Windows"); + return Task.CompletedTask; + } + + var menu = BuildMenu(); + currentMenuSignature = BuildMenuSignature(menu); + currentIcon = CreateIcon(menu.State); + currentState = menu.State; + trayIcon = new TrayIconWithContextMenu(TrayIconId) + { + Icon = currentIcon.Handle, + ToolTip = menu.Tooltip, + ContextMenu = BuildContextMenu(menu) + }; + trayIcon.Created += (_, _) => logger.LogInformation( + "Taskbar icon native create event received: isCreated={IsCreated}", + trayIcon.IsCreated); + trayIcon.Removed += (_, _) => logger.LogInformation("Taskbar icon native remove event received"); + trayIcon.Create(); + trayIcon.Show(); + logger.LogInformation( + "Taskbar icon created through H.NotifyIcon: isCreated={IsCreated}, state={State}, tooltip={Tooltip}", + trayIcon.IsCreated, + menu.State, + menu.Tooltip); + + refreshCancellation = CancellationTokenSource.CreateLinkedTokenSource(cancellationToken); + refreshTask = Task.Run(() => RefreshLoopAsync(refreshCancellation.Token), CancellationToken.None); + return Task.CompletedTask; + } + + public async Task StopAsync(CancellationToken cancellationToken) + { + refreshCancellation?.Cancel(); + if (refreshTask is not null) + { + try + { + await refreshTask.WaitAsync(cancellationToken); + } + catch (OperationCanceledException) + { + } + } + + DisposeTrayIcon(); + } + + public void Dispose() + { + refreshCancellation?.Cancel(); + refreshCancellation?.Dispose(); + DisposeTrayIcon(); + } + + private async Task RefreshLoopAsync(CancellationToken cancellationToken) + { + using var timer = new PeriodicTimer(TimeSpan.FromSeconds(1)); + while (await timer.WaitForNextTickAsync(cancellationToken)) + { + try + { + RefreshVisualState(); + } + catch (Exception exception) + { + logger.LogError(exception, "Taskbar icon refresh failed"); + } + } + } + + private void RefreshVisualState() + { + lock (sync) + { + if (trayIcon is null) + { + return; + } + + var menu = BuildMenu(); + var menuSignature = BuildMenuSignature(menu); + if (currentState != menu.State) + { + var previousIcon = currentIcon; + var nextIcon = CreateIcon(menu.State); + if (!trayIcon.UpdateIcon(nextIcon.Handle)) + { + logger.LogWarning("Taskbar icon update returned false for state {State}", menu.State); + } + + currentIcon = nextIcon; + currentState = menu.State; + previousIcon?.Dispose(); + logger.LogInformation("Taskbar icon state changed to {State}", menu.State); + } + + trayIcon.UpdateToolTip(menu.Tooltip); + if (!string.Equals(currentMenuSignature, menuSignature, StringComparison.Ordinal)) + { + trayIcon.ContextMenu = BuildContextMenu(menu); + currentMenuSignature = menuSignature; + logger.LogInformation("Taskbar menu changed: {MenuItems}", menuSignature); + } + } + } + + private void DisposeTrayIcon() + { + lock (sync) + { + trayIcon?.Dispose(); + trayIcon = null; + currentIcon?.Dispose(); + currentIcon = null; + currentState = null; + currentMenuSignature = null; + } + } + + private MeetingTaskbarMenu BuildMenu() + { + var profiles = launchProfiles.GetProfiles(); + var profilesSignature = string.Join( + ", ", + profiles.Select(profile => $"{profile.Name}:{profile.Options.Hotkey.Toggle}")); + if (!string.Equals(currentProfilesSignature, profilesSignature, StringComparison.Ordinal)) + { + currentProfilesSignature = profilesSignature; + logger.LogInformation("Taskbar menu launch profiles: {LaunchProfiles}", profilesSignature); + } + + return MeetingTaskbarMenuBuilder.Build( + coordinator.CurrentStatus, + profiles); + } + + private PopupMenu BuildContextMenu(MeetingTaskbarMenu menu) + { + var popupMenu = new PopupMenu(); + for (var index = 0; index < menu.Items.Count; index++) + { + if (index == 1) + { + popupMenu.Items.Add(new PopupMenuSeparator()); + } + + var menuItem = menu.Items[index]; + popupMenu.Items.Add(new PopupMenuItem( + menuItem.Text, + (_, _) => _ = ExecuteMenuItemAsync(menuItem))); + } + + return popupMenu; + } + + private async Task ExecuteMenuItemAsync(MeetingTaskbarMenuItem item) + { + try + { + logger.LogInformation( + "Taskbar menu action {Action} selected for launch profile {LaunchProfile}", + item.Action, + item.ProfileName); + + switch (item.Action) + { + case MeetingTaskbarAction.EditRules: + rulesEditorWindow.Show(); + break; + case MeetingTaskbarAction.StartRecording: + await coordinator.StartAsync(item.ProfileName, CancellationToken.None); + break; + case MeetingTaskbarAction.StopRecording: + await coordinator.StopAsync(CancellationToken.None); + break; + case MeetingTaskbarAction.AbortRecording: + await coordinator.AbortAsync(CancellationToken.None); + break; + case MeetingTaskbarAction.SwitchProfile: + await coordinator.ToggleAsync(item.ProfileName, CancellationToken.None); + break; + } + + RefreshVisualState(); + logger.LogInformation( + "Taskbar menu action {Action} completed for launch profile {LaunchProfile}", + item.Action, + item.ProfileName); + } + catch (Exception exception) + { + logger.LogError( + exception, + "Taskbar menu action {Action} failed for launch profile {LaunchProfile}", + item.Action, + item.ProfileName); + } + } + + private static string BuildMenuSignature(MeetingTaskbarMenu menu) + { + return string.Join( + "|", + menu.Items.Select(item => $"{item.Action}:{item.ProfileName}:{item.Text}")); + } + + private static Icon CreateIcon(RecordingProcessState state) + { + var (color, text) = state switch + { + RecordingProcessState.Recording => (Color.FromArgb(220, 38, 38), "R"), + RecordingProcessState.Summarizing => (Color.FromArgb(37, 99, 235), "S"), + _ => (Color.FromArgb(75, 85, 99), "I") + }; + + using var bitmap = new Bitmap(16, 16); + using (var graphics = Graphics.FromImage(bitmap)) + { + graphics.Clear(Color.Transparent); + graphics.SmoothingMode = System.Drawing.Drawing2D.SmoothingMode.AntiAlias; + using var brush = new SolidBrush(color); + graphics.FillEllipse(brush, 1, 1, 14, 14); + using var font = new Font(FontFamily.GenericSansSerif, 8, FontStyle.Bold, GraphicsUnit.Pixel); + using var textBrush = new SolidBrush(Color.White); + var size = graphics.MeasureString(text, font); + graphics.DrawString( + text, + font, + textBrush, + (16 - size.Width) / 2, + (16 - size.Height) / 2); + } + + var handle = bitmap.GetHicon(); + try + { + return (Icon)Icon.FromHandle(handle).Clone(); + } + finally + { + DestroyIcon(handle); + } + } + + [DllImport("user32.dll", SetLastError = true)] + private static extern bool DestroyIcon(IntPtr hIcon); +} +#endif diff --git a/MeetingAssistant/Taskbar/WindowsTaskbarIconService.cs b/MeetingAssistant/Taskbar/WindowsTaskbarIconService.cs deleted file mode 100644 index 7de7e70..0000000 --- a/MeetingAssistant/Taskbar/WindowsTaskbarIconService.cs +++ /dev/null @@ -1,243 +0,0 @@ -using System.Drawing; -using System.Runtime.InteropServices; -using System.Windows.Forms; -using MeetingAssistant.LaunchProfiles; -using MeetingAssistant.Recording; - -namespace MeetingAssistant.Taskbar; - -public sealed class WindowsTaskbarIconService : IHostedService, IDisposable -{ - private readonly MeetingRecordingCoordinator coordinator; - private readonly ILaunchProfileOptionsProvider launchProfiles; - private readonly ILogger logger; - private readonly TaskCompletionSource started = new(TaskCreationOptions.RunContinuationsAsynchronously); - private Thread? uiThread; - private SynchronizationContext? uiContext; - private NotifyIcon? notifyIcon; - private ContextMenuStrip? contextMenu; - private System.Windows.Forms.Timer? refreshTimer; - private Icon? currentIcon; - private RecordingProcessState? currentState; - - public WindowsTaskbarIconService( - MeetingRecordingCoordinator coordinator, - ILaunchProfileOptionsProvider launchProfiles, - ILogger logger) - { - this.coordinator = coordinator; - this.launchProfiles = launchProfiles; - this.logger = logger; - } - - public Task StartAsync(CancellationToken cancellationToken) - { - if (!OperatingSystem.IsWindows()) - { - logger.LogInformation("Taskbar icon is only supported on Windows"); - return Task.CompletedTask; - } - - uiThread = new Thread(RunMessageLoop) - { - IsBackground = true, - Name = "Meeting Assistant Taskbar Icon" - }; - uiThread.SetApartmentState(ApartmentState.STA); - uiThread.Start(); - - return started.Task.WaitAsync(cancellationToken); - } - - public async Task StopAsync(CancellationToken cancellationToken) - { - var context = uiContext; - if (context is not null) - { - context.Post(_ => Application.ExitThread(), null); - } - - if (uiThread is not null) - { - while (uiThread.IsAlive && !cancellationToken.IsCancellationRequested) - { - await Task.Delay(50, cancellationToken); - } - } - } - - public void Dispose() - { - currentIcon?.Dispose(); - contextMenu?.Dispose(); - notifyIcon?.Dispose(); - refreshTimer?.Dispose(); - } - - private void RunMessageLoop() - { - try - { - Application.EnableVisualStyles(); - Application.SetCompatibleTextRenderingDefault(false); - uiContext = SynchronizationContext.Current ?? new WindowsFormsSynchronizationContext(); - SynchronizationContext.SetSynchronizationContext(uiContext); - - contextMenu = new ContextMenuStrip(); - contextMenu.Opening += (_, _) => RebuildMenu(); - notifyIcon = new NotifyIcon - { - ContextMenuStrip = contextMenu, - Visible = true - }; - refreshTimer = new System.Windows.Forms.Timer - { - Interval = 1000 - }; - refreshTimer.Tick += (_, _) => RefreshVisualState(); - refreshTimer.Start(); - RefreshVisualState(); - started.TrySetResult(); - - Application.Run(); - } - catch (Exception exception) - { - started.TrySetException(exception); - logger.LogError(exception, "Taskbar icon service failed"); - } - finally - { - refreshTimer?.Stop(); - if (notifyIcon is not null) - { - notifyIcon.Visible = false; - } - - Dispose(); - } - } - - private void RefreshVisualState() - { - if (notifyIcon is null) - { - return; - } - - var menu = BuildMenu(); - if (currentState != menu.State) - { - var nextIcon = CreateIcon(menu.State); - notifyIcon.Icon = nextIcon; - currentIcon?.Dispose(); - currentIcon = nextIcon; - currentState = menu.State; - } - - notifyIcon.Text = TruncateTooltip(menu.Tooltip); - } - - private void RebuildMenu() - { - if (contextMenu is null) - { - return; - } - - var menu = BuildMenu(); - contextMenu.Items.Clear(); - foreach (var menuItem in menu.Items) - { - var item = new ToolStripMenuItem(menuItem.Text) - { - Tag = menuItem - }; - item.Click += (_, _) => _ = ExecuteMenuItemAsync(menuItem); - contextMenu.Items.Add(item); - } - } - - private MeetingTaskbarMenu BuildMenu() - { - return MeetingTaskbarMenuBuilder.Build( - coordinator.CurrentStatus, - launchProfiles.GetProfiles()); - } - - private async Task ExecuteMenuItemAsync(MeetingTaskbarMenuItem item) - { - try - { - switch (item.Action) - { - case MeetingTaskbarAction.StartRecording: - await coordinator.StartAsync(item.ProfileName, CancellationToken.None); - break; - case MeetingTaskbarAction.StopRecording: - await coordinator.StopAsync(CancellationToken.None); - break; - case MeetingTaskbarAction.AbortRecording: - await coordinator.AbortAsync(CancellationToken.None); - break; - case MeetingTaskbarAction.SwitchProfile: - await coordinator.ToggleAsync(item.ProfileName, CancellationToken.None); - break; - } - } - catch (Exception exception) - { - logger.LogError( - exception, - "Taskbar menu action {Action} failed for launch profile {LaunchProfile}", - item.Action, - item.ProfileName); - } - } - - private static string TruncateTooltip(string tooltip) - { - return tooltip.Length <= 63 ? tooltip : tooltip[..63]; - } - - private static Icon CreateIcon(RecordingProcessState state) - { - var (color, text) = state switch - { - RecordingProcessState.Recording => (Color.FromArgb(220, 38, 38), "R"), - RecordingProcessState.Summarizing => (Color.FromArgb(37, 99, 235), "S"), - _ => (Color.FromArgb(75, 85, 99), "I") - }; - - using var bitmap = new Bitmap(16, 16); - using (var graphics = Graphics.FromImage(bitmap)) - { - graphics.Clear(Color.Transparent); - graphics.SmoothingMode = System.Drawing.Drawing2D.SmoothingMode.AntiAlias; - using var brush = new SolidBrush(color); - graphics.FillEllipse(brush, 1, 1, 14, 14); - using var font = new Font(FontFamily.GenericSansSerif, 8, FontStyle.Bold, GraphicsUnit.Pixel); - using var textBrush = new SolidBrush(Color.White); - var size = graphics.MeasureString(text, font); - graphics.DrawString( - text, - font, - textBrush, - (16 - size.Width) / 2, - (16 - size.Height) / 2); - } - - var handle = bitmap.GetHicon(); - try - { - return (Icon)Icon.FromHandle(handle).Clone(); - } - finally - { - DestroyIcon(handle); - } - } - - [DllImport("user32.dll", SetLastError = true)] - private static extern bool DestroyIcon(IntPtr hIcon); -} diff --git a/MeetingAssistant/Workflow/FileMeetingWorkflowRulesProvider.cs b/MeetingAssistant/Workflow/FileMeetingWorkflowRulesProvider.cs index 8dcf683..dfd135c 100644 --- a/MeetingAssistant/Workflow/FileMeetingWorkflowRulesProvider.cs +++ b/MeetingAssistant/Workflow/FileMeetingWorkflowRulesProvider.cs @@ -32,7 +32,12 @@ public sealed class FileMeetingWorkflowRulesProvider : IMeetingWorkflowRulesProv return []; } - var path = ResolvePath(options.Automation.RulesPath); + var path = WorkflowRulesPathResolver.Resolve(options.Automation.RulesPath); + if (path is null) + { + return []; + } + if (!File.Exists(path)) { logger.LogDebug("Meeting workflow rules file {RulesPath} does not exist", path); @@ -49,12 +54,4 @@ public sealed class FileMeetingWorkflowRulesProvider : IMeetingWorkflowRulesProv ?? new MeetingWorkflowRulesFile(); return rulesFile.Rules; } - - private static string ResolvePath(string configuredPath) - { - var expanded = Environment.ExpandEnvironmentVariables(configuredPath); - return Path.IsPathRooted(expanded) - ? Path.GetFullPath(expanded) - : Path.GetFullPath(expanded); - } } diff --git a/MeetingAssistant/Workflow/IWorkflowRulesEditorWindowService.cs b/MeetingAssistant/Workflow/IWorkflowRulesEditorWindowService.cs new file mode 100644 index 0000000..db3b0b8 --- /dev/null +++ b/MeetingAssistant/Workflow/IWorkflowRulesEditorWindowService.cs @@ -0,0 +1,21 @@ +namespace MeetingAssistant.Workflow; + +public interface IWorkflowRulesEditorWindowService +{ + void Show(); +} + +public sealed class NoopWorkflowRulesEditorWindowService : IWorkflowRulesEditorWindowService +{ + private readonly ILogger logger; + + public NoopWorkflowRulesEditorWindowService(ILogger logger) + { + this.logger = logger; + } + + public void Show() + { + logger.LogInformation("Workflow rules editor UI is only available on Windows"); + } +} diff --git a/MeetingAssistant/Workflow/MewUiWorkflowRulesEditorWindowService.Windows.cs b/MeetingAssistant/Workflow/MewUiWorkflowRulesEditorWindowService.Windows.cs new file mode 100644 index 0000000..1b78241 --- /dev/null +++ b/MeetingAssistant/Workflow/MewUiWorkflowRulesEditorWindowService.Windows.cs @@ -0,0 +1,251 @@ +#if WINDOWS +using Aprillz.MewUI; +using Aprillz.MewUI.Controls; + +namespace MeetingAssistant.Workflow; + +public sealed class MewUiWorkflowRulesEditorWindowService : IWorkflowRulesEditorWindowService +{ + private readonly IServiceProvider services; + private readonly ILogger logger; + private readonly object sync = new(); + private bool isRunning; + + public MewUiWorkflowRulesEditorWindowService( + IServiceProvider services, + ILogger logger) + { + this.services = services; + this.logger = logger; + } + + public void Show() + { + lock (sync) + { + if (isRunning) + { + logger.LogInformation("Workflow rules editor UI is already running"); + return; + } + + isRunning = true; + } + + logger.LogInformation("Starting workflow rules editor UI thread"); + var thread = new Thread(RunWindow) + { + IsBackground = true, + Name = "Meeting Assistant Rules Editor" + }; + thread.SetApartmentState(ApartmentState.STA); + thread.Start(); + } + + private void RunWindow() + { + try + { + logger.LogInformation("Workflow rules editor UI thread started"); + var viewModel = services.GetRequiredService(); + var window = new WorkflowRulesEditorMewWindow(viewModel); + window.Run(); + logger.LogInformation("Workflow rules editor UI window closed"); + } + catch (Exception exception) + { + logger.LogError(exception, "Workflow rules editor UI failed"); + } + finally + { + lock (sync) + { + isRunning = false; + } + } + } +} + +internal sealed class WorkflowRulesEditorMewWindow +{ + private readonly WorkflowRulesEditorChatViewModel viewModel; + private readonly StackPanel conversationPanel = new(); + private readonly ScrollViewer conversationScroll = new(); + private readonly MultiLineTextBox input = new(); + private readonly Button sendButton = new(); + private readonly EventHandler changedHandler; + + public WorkflowRulesEditorMewWindow(WorkflowRulesEditorChatViewModel viewModel) + { + this.viewModel = viewModel; + changedHandler = (_, _) => RequestRender(); + this.viewModel.Changed += changedHandler; + } + + public void Run() + { + ConfigureTheme(); + + conversationPanel + .Vertical() + .Spacing(8); + input + .Height(92) + .Wrap(true) + .Placeholder("Ask to make a rule or to list identities") + .OnTextChanged(text => viewModel.Draft = text) + .OnKeyDown(args => + { + if (args.Key == Key.Enter && !args.ShiftKey) + { + args.Handled = true; + _ = SendAsync(); + } + }); + sendButton + .Content("Send") + .Width(84) + .OnClick(() => _ = SendAsync()); + + var window = new Window() + .Title("Edit rules and identities") + .Resizable(680, 760, 520, 460) + .Padding(0) + .OnLoaded(Render) + .OnClosed(() => viewModel.Changed -= changedHandler) + .Content( + new DockPanel() + .LastChildFill() + .Padding(12) + .Spacing(10) + .Children( + new DockPanel() + .LastChildFill() + .Spacing(8) + .DockBottom() + .Children( + sendButton.DockRight(), + input), + conversationScroll + .AutoVerticalScroll() + .NoHorizontalScroll() + .Content(conversationPanel))); + var icon = LoadWindowIcon(); + if (icon is not null) + { + window.Icon = icon; + } + + Application.Create() + .UseTheme(ThemeVariant.Dark) + .UseWin32() + .UseDirect2D() + .Run(window); + } + + private static IconSource? LoadWindowIcon() + { + var iconPath = Path.Combine(AppContext.BaseDirectory, "Assets", "meeting-assistant.ico"); + return File.Exists(iconPath) + ? IconSource.FromFile(iconPath) + : null; + } + + private static void ConfigureTheme() + { + ThemeManager.DefaultLightSeed = ThemeSeed.DefaultLight with + { + ButtonFace = Color.FromRgb(245, 245, 245) + }; + ThemeManager.DefaultDarkSeed = ThemeSeed.DefaultDark with + { + ButtonFace = Color.FromRgb(42, 46, 54) + }; + } + + private async Task SendAsync() + { + var sendTask = viewModel.SendAsync(); + input.Text = viewModel.Draft; + await sendTask; + } + + private void RequestRender() + { + var dispatcher = Application.Current?.Dispatcher; + if (dispatcher is null) + { + return; + } + + dispatcher.BeginInvoke(Render); + } + + private void Render() + { + var shouldAutoScroll = WorkflowRulesEditorScrollPolicy.ShouldAutoScroll( + conversationScroll.VerticalOffset, + conversationScroll.ViewportHeight, + conversationPanel.ActualHeight); + + conversationPanel.Clear(); + + foreach (var message in viewModel.Messages) + { + conversationPanel.Add(CreateMessageCard(message)); + } + + if (viewModel.IsThinking) + { + conversationPanel.Add(new TextBlock() + .Text("Thinking...") + .Foreground(Color.FromRgb(156, 166, 181)) + .Margin(4, 2, 4, 2)); + } + + sendButton.IsEnabled = !viewModel.IsThinking; + + if (shouldAutoScroll) + { + QueueScrollConversationToBottom(); + } + } + + private void QueueScrollConversationToBottom() + { + var dispatcher = Application.Current?.Dispatcher; + if (dispatcher is null) + { + return; + } + + dispatcher.BeginInvoke(DispatcherPriority.Idle, () => + { + ScrollConversationToBottom(); + dispatcher.BeginInvoke(DispatcherPriority.Idle, ScrollConversationToBottom); + }); + } + + private void ScrollConversationToBottom() + { + conversationScroll.SetScrollOffsets( + conversationScroll.HorizontalOffset, + WorkflowRulesEditorScrollPolicy.GetBottomOffset( + conversationScroll.ViewportHeight, + conversationPanel.ActualHeight)); + } + + private static Element CreateMessageCard(WorkflowRulesEditorChatMessage message) + { + var isUser = message.Role == WorkflowRulesEditorChatRole.User; + return new Border() + .Padding(10) + .Margin(isUser ? new Thickness(42, 0, 4, 0) : new Thickness(0, 0, 42, 0)) + .CornerRadius(8) + .BorderThickness(1) + .BorderBrush(isUser ? Color.FromRgb(68, 95, 132) : Color.FromRgb(69, 75, 86)) + .Background(isUser ? Color.FromRgb(26, 48, 78) : Color.FromRgb(35, 39, 46)) + .Child(WorkflowRulesEditorMarkdownRenderer.CreateContent(message.Content)); + } +} +#endif diff --git a/MeetingAssistant/Workflow/WorkflowRulesEditorChatModels.cs b/MeetingAssistant/Workflow/WorkflowRulesEditorChatModels.cs new file mode 100644 index 0000000..4d57eed --- /dev/null +++ b/MeetingAssistant/Workflow/WorkflowRulesEditorChatModels.cs @@ -0,0 +1,23 @@ +namespace MeetingAssistant.Workflow; + +public enum WorkflowRulesEditorChatRole +{ + User, + Agent +} + +public sealed record WorkflowRulesEditorChatMessage( + WorkflowRulesEditorChatRole Role, + string Content); + +public sealed record WorkflowRulesEditorChatResult( + string Response, + IReadOnlyList Conversation); + +public interface IWorkflowRulesEditorChatPipeline +{ + Task SendAsync( + IReadOnlyList conversation, + string userMessage, + CancellationToken cancellationToken); +} diff --git a/MeetingAssistant/Workflow/WorkflowRulesEditorChatPipeline.cs b/MeetingAssistant/Workflow/WorkflowRulesEditorChatPipeline.cs new file mode 100644 index 0000000..c91d888 --- /dev/null +++ b/MeetingAssistant/Workflow/WorkflowRulesEditorChatPipeline.cs @@ -0,0 +1,239 @@ +using MeetingAssistant.Summary; +using MeetingAssistant.Speakers; +using Microsoft.Extensions.AI; +using Microsoft.EntityFrameworkCore; +using Microsoft.Extensions.Options; + +namespace MeetingAssistant.Workflow; + +#pragma warning disable MAAI001 +public sealed class WorkflowRulesEditorChatPipeline : IWorkflowRulesEditorChatPipeline +{ + private readonly MeetingAssistantOptions options; + private readonly ILoggerFactory loggerFactory; + private readonly ILogger logger; + private readonly IWorkflowRulesEditorInstructionBuilder instructionBuilder; + private readonly IDbContextFactory speakerIdentityDbContextFactory; + private readonly IWorkflowRulesEditorSamplePlaybackQueue samplePlaybackQueue; + + public WorkflowRulesEditorChatPipeline( + IOptions options, + ILoggerFactory loggerFactory, + ILogger logger, + IWorkflowRulesEditorInstructionBuilder instructionBuilder, + IDbContextFactory speakerIdentityDbContextFactory, + IWorkflowRulesEditorSamplePlaybackQueue samplePlaybackQueue) + { + this.options = options.Value; + this.loggerFactory = loggerFactory; + this.logger = logger; + this.instructionBuilder = instructionBuilder; + this.speakerIdentityDbContextFactory = speakerIdentityDbContextFactory; + this.samplePlaybackQueue = samplePlaybackQueue; + } + + public async Task SendAsync( + IReadOnlyList conversation, + string userMessage, + CancellationToken cancellationToken) + { + if (string.IsNullOrWhiteSpace(userMessage)) + { + return new WorkflowRulesEditorChatResult("", conversation); + } + + var agentOptions = options.WorkflowRulesEditor.ToEffectiveAgentOptions(options.Agent); + var key = ResolveApiKey(agentOptions, "workflow rules editor"); + var tools = new WorkflowRulesEditorTools( + options, + speakerIdentityDbContextFactory, + samplePlaybackQueue); + var messages = conversation + .Select(ToChatMessage) + .Append(new ChatMessage(ChatRole.User, userMessage.Trim())) + .ToList(); + var instructions = await instructionBuilder.BuildAsync(options, cancellationToken); + + using var compactionSummaryClient = new LiteLlmResponsesChatClient( + new Uri(agentOptions.Endpoint), + key, + agentOptions.Model, + agentOptions.EnableThinking, + ToReasoningEffortValue(agentOptions.ReasoningEffort), + agentOptions.ReconnectionAttempts, + agentOptions.ReconnectionDelay, + compactionOptions: null, + logger, + firstRequestIsUser: false); + var compactionOptions = CreateCompactionOptions(agentOptions, compactionSummaryClient); + using var chatClient = new LiteLlmResponsesChatClient( + new Uri(agentOptions.Endpoint), + key, + agentOptions.Model, + agentOptions.EnableThinking, + ToReasoningEffortValue(agentOptions.ReasoningEffort), + agentOptions.ReconnectionAttempts, + agentOptions.ReconnectionDelay, + compactionOptions, + logger, + firstRequestIsUser: true); + var functionClient = chatClient + .AsBuilder() + .UseFunctionInvocation(loggerFactory) + .Build(); + + var response = await functionClient.GetResponseAsync( + messages, + CreateChatOptions(agentOptions, tools, instructions), + cancellationToken); + var responseText = string.IsNullOrWhiteSpace(response.Text) + ? "(No response text returned.)" + : response.Text.Trim(); + var nextConversation = conversation + .Append(new WorkflowRulesEditorChatMessage(WorkflowRulesEditorChatRole.User, userMessage.Trim())) + .Append(new WorkflowRulesEditorChatMessage(WorkflowRulesEditorChatRole.Agent, responseText)) + .ToList(); + return new WorkflowRulesEditorChatResult(responseText, nextConversation); + } + + private static ChatMessage ToChatMessage(WorkflowRulesEditorChatMessage message) + { + return new ChatMessage( + message.Role == WorkflowRulesEditorChatRole.Agent ? ChatRole.Assistant : ChatRole.User, + message.Content); + } + + private static ChatOptions CreateChatOptions( + AgentOptions options, + WorkflowRulesEditorTools tools, + string instructions) + { + return new ChatOptions + { + ModelId = options.Model, + Instructions = instructions, + MaxOutputTokens = options.MaxOutputTokens, + AllowMultipleToolCalls = true, + ToolMode = ChatToolMode.Auto, + Tools = + [ + AIFunctionFactory.Create( + tools.ReadRules, + "read_rules", + "Read the configured workflow rules YAML file. With no line arguments, read the whole file. With from and to, read that clamped inclusive 1-based line range."), + AIFunctionFactory.Create( + tools.WriteRules, + "write_rules", + "Overwrite the configured workflow rules YAML file after validating that it parses as a workflow rules document."), + AIFunctionFactory.Create( + tools.Search, + "search", + "Search the configured workflow rules YAML file using ripgrep-style syntax. The search is scoped to this single file."), + AIFunctionFactory.Create( + tools.SearchIdentities, + "search_identities", + "Search or list speaker identities. Optional query matches canonical names, aliases, and candidate names. Returns JSON summaries."), + AIFunctionFactory.Create( + tools.ReadIdentity, + "read_identity", + "Read one speaker identity by numeric identity id, including aliases, candidate names, references, and sample metadata."), + AIFunctionFactory.Create( + tools.CreateIdentity, + "create_identity", + "Create a speaker identity with optional canonicalName, aliases, and candidateNames. Returns the created identity JSON."), + AIFunctionFactory.Create( + tools.UpdateIdentity, + "update_identity", + "Replace a speaker identity's canonicalName, aliases, and candidateNames by numeric identity id. Omitted aliases/candidateNames become empty."), + AIFunctionFactory.Create( + tools.DeleteIdentity, + "delete_identity", + "Delete a speaker identity and all linked aliases, candidate names, references, and samples by numeric identity id."), + AIFunctionFactory.Create( + tools.MergeIdentities, + "merge_identities", + "Merge sourceIdentityId into targetIdentityId, preserving target canonical name, adding source names as aliases, moving references and samples, then deleting the source."), + AIFunctionFactory.Create( + tools.ListIdentitySamples, + "list_identity_samples", + "List audio samples for a speaker identity by numeric identity id. Returns sample ids and metadata, not audio bytes."), + AIFunctionFactory.Create( + tools.ReadIdentitySample, + "read_identity_sample", + "Read one audio sample by numeric sample id. Returns metadata and base64 WAV bytes."), + AIFunctionFactory.Create( + tools.DeleteIdentitySample, + "delete_identity_sample", + "Delete one audio sample by numeric sample id."), + AIFunctionFactory.Create( + tools.QueuePlayIdentitySample, + "queue_play_identity_sample", + "Queue one audio sample by numeric sample id for local playback. This is asynchronous and does not block until playback finishes.") + ], + Reasoning = options.EnableThinking + ? new ReasoningOptions { Effort = ToReasoningEffort(options.ReasoningEffort) } + : null + }; + } + + private static LiteLlmResponsesCompactionOptions CreateCompactionOptions( + AgentOptions options, + IChatClient? summaryClient) + { + return new LiteLlmResponsesCompactionOptions + { + Enabled = options.EnableCompaction, + ContextWindowTokens = options.ContextWindowTokens, + MaxOutputTokens = options.MaxOutputTokens, + RemainingRatio = options.CompactionRemainingRatio, + CompactPath = options.ResponsesCompactPath, + FallbackStrategy = OpenAiMeetingSummaryAgentPipeline.CreateFallbackCompactionStrategyForTests( + options.ContextWindowTokens, + options.MaxOutputTokens, + options.CompactionRemainingRatio, + summaryClient) + }; + } + + private static string ResolveApiKey(AgentOptions options, string agentName) + { + if (!string.IsNullOrWhiteSpace(options.Key)) + { + return options.Key; + } + + var value = Environment.GetEnvironmentVariable(options.KeyEnv); + if (!string.IsNullOrWhiteSpace(value)) + { + return value; + } + + throw new InvalidOperationException( + $"No {agentName} API key configured. Set MeetingAssistant:WorkflowRulesEditor:Key, MeetingAssistant:Agent:Key, or environment variable '{options.KeyEnv}'."); + } + + private static string ToReasoningEffortValue(ReasoningEffortOption effort) + { + return effort switch + { + ReasoningEffortOption.None => "none", + ReasoningEffortOption.Low => "low", + ReasoningEffortOption.High => "high", + ReasoningEffortOption.ExtraHigh => "xhigh", + _ => "medium" + }; + } + + private static ReasoningEffort ToReasoningEffort(ReasoningEffortOption effort) + { + return effort switch + { + ReasoningEffortOption.None => ReasoningEffort.None, + ReasoningEffortOption.Low => ReasoningEffort.Low, + ReasoningEffortOption.High => ReasoningEffort.High, + ReasoningEffortOption.ExtraHigh => ReasoningEffort.ExtraHigh, + _ => ReasoningEffort.Medium + }; + } +} +#pragma warning restore MAAI001 diff --git a/MeetingAssistant/Workflow/WorkflowRulesEditorChatViewModel.cs b/MeetingAssistant/Workflow/WorkflowRulesEditorChatViewModel.cs new file mode 100644 index 0000000..9882c43 --- /dev/null +++ b/MeetingAssistant/Workflow/WorkflowRulesEditorChatViewModel.cs @@ -0,0 +1,65 @@ +using System.Collections.ObjectModel; + +namespace MeetingAssistant.Workflow; + +public sealed class WorkflowRulesEditorChatViewModel +{ + private readonly IWorkflowRulesEditorChatPipeline pipeline; + + public WorkflowRulesEditorChatViewModel(IWorkflowRulesEditorChatPipeline pipeline) + { + this.pipeline = pipeline; + } + + public ObservableCollection Messages { get; } = []; + + public string Draft { get; set; } = ""; + + public bool IsThinking { get; private set; } + + public event EventHandler? Changed; + + public async Task SendAsync(CancellationToken cancellationToken = default) + { + var prompt = Draft.Trim(); + if (string.IsNullOrWhiteSpace(prompt) || IsThinking) + { + return; + } + + Draft = ""; + var priorConversation = Messages.ToList(); + Messages.Add(new WorkflowRulesEditorChatMessage(WorkflowRulesEditorChatRole.User, prompt)); + IsThinking = true; + OnChanged(); + + try + { + var result = await pipeline.SendAsync( + priorConversation, + prompt, + cancellationToken); + Messages.Clear(); + foreach (var message in result.Conversation) + { + Messages.Add(message); + } + } + catch (Exception exception) when (exception is not OperationCanceledException) + { + Messages.Add(new WorkflowRulesEditorChatMessage( + WorkflowRulesEditorChatRole.Agent, + $"Rules editor failed: {exception.Message}")); + } + finally + { + IsThinking = false; + OnChanged(); + } + } + + private void OnChanged() + { + Changed?.Invoke(this, EventArgs.Empty); + } +} diff --git a/MeetingAssistant/Workflow/WorkflowRulesEditorInstructionBuilder.cs b/MeetingAssistant/Workflow/WorkflowRulesEditorInstructionBuilder.cs new file mode 100644 index 0000000..1600e2d --- /dev/null +++ b/MeetingAssistant/Workflow/WorkflowRulesEditorInstructionBuilder.cs @@ -0,0 +1,80 @@ +using Microsoft.Extensions.Options; + +namespace MeetingAssistant.Workflow; + +public interface IWorkflowRulesEditorInstructionBuilder +{ + Task BuildAsync(MeetingAssistantOptions options, CancellationToken cancellationToken); +} + +public sealed class WorkflowRulesEditorInstructionBuilder : IWorkflowRulesEditorInstructionBuilder +{ + private const string DefaultPrompt = """ + You are the Meeting Assistant workflow rules and identities editor. + + Your purpose is to help edit the configured local workflow rules YAML file and manage the local speaker identity database for Meeting Assistant. + Use the read_rules, search, and write_rules tools for workflow rules. Do not ask for or modify unrelated files. + Read the existing rules before making changes unless the user explicitly asks to replace the whole file. + Preserve valid YAML, keep personal/local rules in the configured ignored rules file, and keep changes minimal. + When writing rules, prefer existing rule style and names. + Use search_identities and read_identity before changing identities unless the user gives an exact identity id. + Prefer updating identities by id, not by guessed name. If a user asks about names, search first and confirm ambiguity in your final response. + For identity merges, merge the duplicate/source identity into the identity that should remain as target. + Use list_identity_samples before playing, reading, or deleting samples. Use queue_play_identity_sample to let the user hear a sample; do not claim you heard it yourself. + Use read_identity_sample only when the raw base64 WAV is actually useful; prefer list_identity_samples for ordinary inspection. + Delete identities or samples only when the user clearly asked for deletion or cleanup. + Explain the final change briefly after the tools finish. + """; + + private readonly ILogger logger; + + public WorkflowRulesEditorInstructionBuilder(ILogger logger) + { + this.logger = logger; + } + + public async Task BuildAsync(MeetingAssistantOptions options, CancellationToken cancellationToken) + { + var editorOptions = options.WorkflowRulesEditor.ToEffectiveAgentOptions(options.Agent); + var configuredPrompt = string.IsNullOrWhiteSpace(editorOptions.InitialPrompt) + ? DefaultPrompt + : editorOptions.InitialPrompt!; + var rulesPath = WorkflowRulesPathResolver.Resolve(options.Automation.RulesPath) ?? ""; + var docs = await ReadWorkflowDocsAsync(cancellationToken); + + return configuredPrompt.Trim() + Environment.NewLine + Environment.NewLine + + $"Configured workflow rules file: {rulesPath}" + Environment.NewLine + Environment.NewLine + + "Speaker identity tools can search/list/read/create/update/delete/merge identities, list/read/delete identity samples, and queue a sample for local playback." + Environment.NewLine + Environment.NewLine + + "Workflow rules reference documentation:" + Environment.NewLine + + "```markdown" + Environment.NewLine + + docs.Trim() + Environment.NewLine + + "```"; + } + + private async Task ReadWorkflowDocsAsync(CancellationToken cancellationToken) + { + foreach (var path in CandidateDocumentationPaths()) + { + if (File.Exists(path)) + { + return await File.ReadAllTextAsync(path, cancellationToken); + } + } + + logger.LogWarning("Workflow rules editor could not find docs/meeting-workflow-engine.md for its prompt"); + return "Workflow documentation file docs/meeting-workflow-engine.md was not available at runtime."; + } + + private static IEnumerable CandidateDocumentationPaths() + { + var baseDirectory = AppContext.BaseDirectory; + yield return Path.Combine(baseDirectory, "docs", "meeting-workflow-engine.md"); + + var current = new DirectoryInfo(baseDirectory); + for (var i = 0; i < 8 && current is not null; i++) + { + yield return Path.Combine(current.FullName, "docs", "meeting-workflow-engine.md"); + current = current.Parent; + } + } +} diff --git a/MeetingAssistant/Workflow/WorkflowRulesEditorMarkdown.cs b/MeetingAssistant/Workflow/WorkflowRulesEditorMarkdown.cs new file mode 100644 index 0000000..3afa00a --- /dev/null +++ b/MeetingAssistant/Workflow/WorkflowRulesEditorMarkdown.cs @@ -0,0 +1,258 @@ +namespace MeetingAssistant.Workflow; + +internal enum WorkflowRulesEditorMarkdownInlineStyle +{ + Normal, + Italic, + Bold, + Code +} + +internal abstract record WorkflowRulesEditorMarkdownBlock; + +internal sealed record WorkflowRulesEditorMarkdownParagraph( + IReadOnlyList Inlines) : WorkflowRulesEditorMarkdownBlock; + +internal sealed record WorkflowRulesEditorMarkdownCodeBlock(string Text) : WorkflowRulesEditorMarkdownBlock; + +internal sealed record WorkflowRulesEditorMarkdownTable( + IReadOnlyList Header, + IReadOnlyList> Rows) : WorkflowRulesEditorMarkdownBlock; + +internal sealed record WorkflowRulesEditorMarkdownInline( + string Text, + WorkflowRulesEditorMarkdownInlineStyle Style); + +internal static class WorkflowRulesEditorMarkdown +{ + public static IReadOnlyList Parse(string text) + { + if (string.IsNullOrEmpty(text)) + { + return []; + } + + var blocks = new List(); + var paragraph = new List(); + var code = new List(); + var inCodeBlock = false; + var lines = ReadLines(text).ToArray(); + + for (var index = 0; index < lines.Length; index++) + { + var line = lines[index]; + if (line.TrimStart().StartsWith("```", StringComparison.Ordinal)) + { + if (inCodeBlock) + { + blocks.Add(new WorkflowRulesEditorMarkdownCodeBlock(string.Join(Environment.NewLine, code))); + code.Clear(); + inCodeBlock = false; + } + else + { + FlushParagraph(blocks, paragraph); + inCodeBlock = true; + } + + continue; + } + + if (inCodeBlock) + { + code.Add(line); + continue; + } + + if (IsTableStart(lines, index)) + { + FlushParagraph(blocks, paragraph); + var header = ParseTableRow(lines[index]); + index += 2; + var rows = new List>(); + while (index < lines.Length && IsTableRow(lines[index])) + { + rows.Add(ParseTableRow(lines[index])); + index++; + } + + blocks.Add(new WorkflowRulesEditorMarkdownTable(header, rows)); + index--; + continue; + } + + if (string.IsNullOrWhiteSpace(line)) + { + FlushParagraph(blocks, paragraph); + } + else + { + paragraph.Add(line); + } + } + + if (inCodeBlock) + { + blocks.Add(new WorkflowRulesEditorMarkdownCodeBlock(string.Join(Environment.NewLine, code))); + } + + FlushParagraph(blocks, paragraph); + return blocks; + } + + public static IReadOnlyList ParseInline(string text) + { + var result = new List(); + var index = 0; + while (index < text.Length) + { + var next = FindNextMarker(text, index); + if (next < 0) + { + AddInline(result, text[index..], WorkflowRulesEditorMarkdownInlineStyle.Normal); + break; + } + + if (next > index) + { + AddInline(result, text[index..next], WorkflowRulesEditorMarkdownInlineStyle.Normal); + } + + if (text[next] == '`') + { + var end = text.IndexOf('`', next + 1); + if (end > next) + { + AddInline(result, text[(next + 1)..end], WorkflowRulesEditorMarkdownInlineStyle.Code); + index = end + 1; + continue; + } + } + else if (text.AsSpan(next).StartsWith("**", StringComparison.Ordinal)) + { + var end = text.IndexOf("**", next + 2, StringComparison.Ordinal); + if (end > next) + { + AddInline(result, text[(next + 2)..end], WorkflowRulesEditorMarkdownInlineStyle.Bold); + index = end + 2; + continue; + } + } + else if (text[next] == '*') + { + var end = text.IndexOf('*', next + 1); + if (end > next) + { + AddInline(result, text[(next + 1)..end], WorkflowRulesEditorMarkdownInlineStyle.Italic); + index = end + 1; + continue; + } + } + + AddInline(result, text[next].ToString(), WorkflowRulesEditorMarkdownInlineStyle.Normal); + index = next + 1; + } + + return result; + } + + private static void FlushParagraph( + List blocks, + List paragraph) + { + if (paragraph.Count == 0) + { + return; + } + + blocks.Add(new WorkflowRulesEditorMarkdownParagraph(ParseInline(string.Join('\n', paragraph)))); + paragraph.Clear(); + } + + private static bool IsTableStart(IReadOnlyList lines, int index) + { + return index + 1 < lines.Count && + IsTableRow(lines[index]) && + IsTableSeparator(lines[index + 1]); + } + + private static bool IsTableRow(string line) + { + return !string.IsNullOrWhiteSpace(line) && line.Contains('|', StringComparison.Ordinal); + } + + private static bool IsTableSeparator(string line) + { + var cells = ParseTableRow(line); + return cells.Count > 0 && + cells.All(cell => + { + var trimmed = cell.Trim(); + return trimmed.Contains('-', StringComparison.Ordinal) && + trimmed.All(character => character is '-' or ':' or ' '); + }); + } + + private static IReadOnlyList ParseTableRow(string line) + { + var trimmed = line.Trim(); + if (trimmed.StartsWith('|')) + { + trimmed = trimmed[1..]; + } + + if (trimmed.EndsWith('|')) + { + trimmed = trimmed[..^1]; + } + + return trimmed + .Split('|') + .Select(cell => cell.Trim()) + .ToArray(); + } + + private static void AddInline( + List result, + string text, + WorkflowRulesEditorMarkdownInlineStyle style) + { + if (text.Length == 0) + { + return; + } + + if (result.Count > 0 && result[^1].Style == style) + { + result[^1] = result[^1] with { Text = result[^1].Text + text }; + return; + } + + result.Add(new WorkflowRulesEditorMarkdownInline(text, style)); + } + + private static int FindNextMarker(string text, int start) + { + var code = text.IndexOf('`', start); + var bold = text.IndexOf("**", start, StringComparison.Ordinal); + var italic = text.IndexOf('*', start); + if (italic >= 0 && italic + 1 < text.Length && text[italic + 1] == '*') + { + italic = text.IndexOf('*', italic + 2); + } + + return new[] { code, bold, italic } + .Where(index => index >= 0) + .DefaultIfEmpty(-1) + .Min(); + } + + private static IEnumerable ReadLines(string text) + { + using var reader = new StringReader(text); + while (reader.ReadLine() is { } line) + { + yield return line; + } + } +} diff --git a/MeetingAssistant/Workflow/WorkflowRulesEditorMarkdownRenderer.Windows.cs b/MeetingAssistant/Workflow/WorkflowRulesEditorMarkdownRenderer.Windows.cs new file mode 100644 index 0000000..d602fb1 --- /dev/null +++ b/MeetingAssistant/Workflow/WorkflowRulesEditorMarkdownRenderer.Windows.cs @@ -0,0 +1,233 @@ +#if WINDOWS +using Aprillz.MewUI; +using Aprillz.MewUI.Controls; + +namespace MeetingAssistant.Workflow; + +internal static class WorkflowRulesEditorMarkdownRenderer +{ + public static UIElement CreateContent(string content) + { + var panel = new StackPanel() + .Vertical() + .Spacing(7); + foreach (var block in WorkflowRulesEditorMarkdown.Parse(content)) + { + panel.Add(block switch + { + WorkflowRulesEditorMarkdownCodeBlock codeBlock => CreateCodeBlock(codeBlock), + WorkflowRulesEditorMarkdownTable table => CreateTable(table), + WorkflowRulesEditorMarkdownParagraph paragraph => CreateParagraph(paragraph), + _ => new TextBlock() + }); + } + + return panel; + } + + private static UIElement CreateParagraph(WorkflowRulesEditorMarkdownParagraph paragraph) + { + var lines = SplitInlineLines(paragraph.Inlines); + if (lines.Count == 1) + { + return CreateParagraphLine(lines[0]); + } + + var panel = new StackPanel() + .Vertical() + .Spacing(3); + foreach (var line in lines) + { + panel.Add(CreateParagraphLine(line)); + } + + return panel; + } + + private static UIElement CreateParagraphLine(IReadOnlyList inlines) + { + var linePanel = new WrapPanel() + .Spacing(1); + foreach (var inline in inlines) + { + foreach (var element in CreateInlineElements(inline)) + { + linePanel.Add(element); + } + } + + return linePanel; + } + + private static IEnumerable CreateInlineElements(WorkflowRulesEditorMarkdownInline inline) + { + if (inline.Style == WorkflowRulesEditorMarkdownInlineStyle.Code) + { + yield return CreateInlineCode(inline.Text); + yield break; + } + + foreach (var token in SplitInlineText(inline.Text)) + { + var text = new TextBlock() + .Text(token) + .TextWrapping(TextWrapping.Wrap) + .Foreground(Color.FromRgb(230, 235, 243)); + if (inline.Style == WorkflowRulesEditorMarkdownInlineStyle.Bold) + { + text.FontWeight(FontWeight.Bold); + } + else if (inline.Style == WorkflowRulesEditorMarkdownInlineStyle.Italic) + { + text.FontFamily("Segoe UI Italic"); + } + + yield return text; + } + } + + private static IReadOnlyList> SplitInlineLines( + IReadOnlyList inlines) + { + var lines = new List>(); + var current = new List(); + foreach (var inline in inlines) + { + var parts = inline.Text.Split('\n'); + for (var index = 0; index < parts.Length; index++) + { + if (parts[index].Length > 0) + { + current.Add(inline with { Text = parts[index] }); + } + + if (index < parts.Length - 1) + { + lines.Add(current); + current = []; + } + } + } + + lines.Add(current); + return lines; + } + + private static UIElement CreateInlineCode(string text) + { + return new Border() + .Padding(4, 1, 4, 2) + .Margin(1, 0, 1, 0) + .CornerRadius(4) + .BorderThickness(1) + .BorderBrush(Color.FromRgb(86, 94, 108)) + .Background(Color.FromRgb(24, 28, 35)) + .Child(new TextBlock() + .Text(text) + .FontFamily("Consolas") + .Foreground(Color.FromRgb(237, 241, 247))); + } + + private static UIElement CreateTable(WorkflowRulesEditorMarkdownTable table) + { + var rows = table.Rows + .Select(row => new MarkdownTableRow(PadCells(row, table.Header.Count))) + .ToArray(); + var gridView = new GridView() + .HeaderHeight(30) + .RowHeight(30) + .CellPadding(new Thickness(8, 4, 8, 4)) + .ShowGridLines(true) + .ZebraStriping(true); + + for (var index = 0; index < table.Header.Count; index++) + { + var columnIndex = index; + gridView.AddColumn( + table.Header[index], + GetMarkdownTableColumnWidth(table, index), + _ => new TextBlock() + .TextWrapping(TextWrapping.NoWrap) + .Foreground(Color.FromRgb(230, 235, 243)), + (element, row, _, _) => ((TextBlock)element).Text(row.GetCell(columnIndex)), + minWidth: 64, + resizable: true); + } + + return gridView + .ItemsSource(rows) + .MinWidth(Math.Min(900, table.Header.Count * 120)) + .Height(Math.Min(420, 30 + Math.Max(1, rows.Length) * 30)); + } + + private static UIElement CreateCodeBlock(WorkflowRulesEditorMarkdownCodeBlock codeBlock) + { + return new Border() + .Padding(9) + .CornerRadius(6) + .BorderThickness(1) + .BorderBrush(Color.FromRgb(86, 94, 108)) + .Background(Color.FromRgb(20, 24, 31)) + .Child(new TextBlock() + .Text(codeBlock.Text) + .TextWrapping(TextWrapping.Wrap) + .FontFamily("Consolas") + .Foreground(Color.FromRgb(237, 241, 247))); + } + + private static IEnumerable SplitInlineText(string text) + { + var start = 0; + while (start < text.Length) + { + var end = start; + var isWhitespace = char.IsWhiteSpace(text[start]); + while (end < text.Length && char.IsWhiteSpace(text[end]) == isWhitespace) + { + end++; + } + + var token = text[start..end]; + if (token.Length > 0) + { + yield return token; + } + + start = end; + } + } + + private static IReadOnlyList PadCells(IReadOnlyList cells, int count) + { + if (cells.Count >= count) + { + return cells; + } + + return cells + .Concat(Enumerable.Repeat("", count - cells.Count)) + .ToArray(); + } + + private static double GetMarkdownTableColumnWidth( + WorkflowRulesEditorMarkdownTable table, + int index) + { + var maxLength = new[] { table.Header[index].Length } + .Concat(table.Rows.Select(row => index < row.Count ? row[index].Length : 0)) + .DefaultIfEmpty(0) + .Max(); + return Math.Clamp(maxLength * 8 + 28, 72, 260); + } + + private sealed record MarkdownTableRow(IReadOnlyList Cells) + { + public string GetCell(int index) + { + return index >= 0 && index < Cells.Count + ? Cells[index] + : ""; + } + } +} +#endif diff --git a/MeetingAssistant/Workflow/WorkflowRulesEditorSamplePlaybackQueue.cs b/MeetingAssistant/Workflow/WorkflowRulesEditorSamplePlaybackQueue.cs new file mode 100644 index 0000000..c024ac2 --- /dev/null +++ b/MeetingAssistant/Workflow/WorkflowRulesEditorSamplePlaybackQueue.cs @@ -0,0 +1,100 @@ +using System.Threading.Channels; +using NAudio.Wave; + +namespace MeetingAssistant.Workflow; + +public interface IWorkflowRulesEditorSamplePlaybackQueue +{ + Task QueueAsync(int sampleId, byte[] wavBytes, CancellationToken cancellationToken = default); +} + +public sealed class WorkflowRulesEditorSamplePlaybackQueue : IWorkflowRulesEditorSamplePlaybackQueue, IDisposable +{ + private readonly Channel channel = Channel.CreateUnbounded(); + private readonly ILogger logger; + private readonly CancellationTokenSource cancellation = new(); + private readonly Task playbackTask; + + public WorkflowRulesEditorSamplePlaybackQueue(ILogger logger) + { + this.logger = logger; + playbackTask = Task.Run(PlayQueuedSamplesAsync); + } + + public async Task QueueAsync( + int sampleId, + byte[] wavBytes, + CancellationToken cancellationToken = default) + { + if (wavBytes.Length == 0) + { + return $"Sample {sampleId} is empty and was not queued."; + } + + await channel.Writer.WriteAsync(new QueuedSample(sampleId, wavBytes), cancellationToken); + return $"Queued sample {sampleId} for playback."; + } + + public void Dispose() + { + cancellation.Cancel(); + channel.Writer.TryComplete(); + try + { + playbackTask.Wait(TimeSpan.FromSeconds(2)); + } + catch + { + } + + cancellation.Dispose(); + } + + private async Task PlayQueuedSamplesAsync() + { + try + { + await foreach (var sample in channel.Reader.ReadAllAsync(cancellation.Token)) + { + try + { + await PlayAsync(sample, cancellation.Token); + } + catch (OperationCanceledException) when (cancellation.IsCancellationRequested) + { + break; + } + catch (Exception exception) + { + logger.LogWarning( + exception, + "Could not play queued speaker identity sample {SampleId}", + sample.SampleId); + } + } + } + catch (OperationCanceledException) when (cancellation.IsCancellationRequested) + { + } + } + + private static async Task PlayAsync(QueuedSample sample, CancellationToken cancellationToken) + { + await using var stream = new MemoryStream(sample.WavBytes, writable: false); + using var reader = new WaveFileReader(stream); + using var output = new WaveOutEvent(); + var completed = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); + output.PlaybackStopped += (_, _) => completed.TrySetResult(); + output.Init(reader); + output.Play(); + + await using var registration = cancellationToken.Register(() => + { + output.Stop(); + completed.TrySetCanceled(cancellationToken); + }); + await completed.Task; + } + + private sealed record QueuedSample(int SampleId, byte[] WavBytes); +} diff --git a/MeetingAssistant/Workflow/WorkflowRulesEditorScrollPolicy.cs b/MeetingAssistant/Workflow/WorkflowRulesEditorScrollPolicy.cs new file mode 100644 index 0000000..cc9ff7d --- /dev/null +++ b/MeetingAssistant/Workflow/WorkflowRulesEditorScrollPolicy.cs @@ -0,0 +1,31 @@ +namespace MeetingAssistant.Workflow; + +internal static class WorkflowRulesEditorScrollPolicy +{ + private const double BottomTolerance = 8; + + public static bool ShouldAutoScroll( + double verticalOffset, + double viewportHeight, + double previousContentHeight) + { + if (viewportHeight <= 0 || previousContentHeight <= 0) + { + return true; + } + + if (previousContentHeight <= viewportHeight) + { + return true; + } + + return verticalOffset + viewportHeight >= previousContentHeight - BottomTolerance; + } + + public static double GetBottomOffset( + double viewportHeight, + double contentHeight) + { + return Math.Max(0, contentHeight - viewportHeight); + } +} diff --git a/MeetingAssistant/Workflow/WorkflowRulesEditorTools.cs b/MeetingAssistant/Workflow/WorkflowRulesEditorTools.cs new file mode 100644 index 0000000..e0128f1 --- /dev/null +++ b/MeetingAssistant/Workflow/WorkflowRulesEditorTools.cs @@ -0,0 +1,610 @@ +using System.Diagnostics; +using System.Text.Json; +using System.Text.RegularExpressions; +using MeetingAssistant.Speakers; +using Microsoft.EntityFrameworkCore; +using YamlDotNet.Core; +using YamlDotNet.Serialization; +using YamlDotNet.Serialization.NamingConventions; + +namespace MeetingAssistant.Workflow; + +public sealed class WorkflowRulesEditorTools +{ + private static readonly IDeserializer YamlDeserializer = new DeserializerBuilder() + .WithNamingConvention(UnderscoredNamingConvention.Instance) + .IgnoreUnmatchedProperties() + .Build(); + private static readonly JsonSerializerOptions JsonOptions = new(JsonSerializerDefaults.Web) + { + WriteIndented = true + }; + + private readonly string? rulesPath; + private readonly SpeakerIdentificationOptions speakerOptions; + private readonly IDbContextFactory? dbContextFactory; + private readonly IWorkflowRulesEditorSamplePlaybackQueue? samplePlaybackQueue; + + public WorkflowRulesEditorTools( + MeetingAssistantOptions options, + IDbContextFactory? dbContextFactory = null, + IWorkflowRulesEditorSamplePlaybackQueue? samplePlaybackQueue = null) + { + rulesPath = WorkflowRulesPathResolver.Resolve(options.Automation.RulesPath); + speakerOptions = options.SpeakerIdentification; + this.dbContextFactory = dbContextFactory; + this.samplePlaybackQueue = samplePlaybackQueue; + } + + public async Task ReadRules(int? from = null, int? to = null) + { + if (rulesPath is null) + { + return "Workflow rules file is not configured."; + } + + if (!File.Exists(rulesPath)) + { + return ""; + } + + return ReadLines(await File.ReadAllTextAsync(rulesPath), from, to); + } + + public async Task WriteRules(string yaml) + { + if (rulesPath is null) + { + return "Refused: workflow rules file is not configured."; + } + + if (yaml is null) + { + return "Refused: yaml must not be null."; + } + + var validation = ValidateYaml(yaml); + if (validation is not null) + { + return validation; + } + + Directory.CreateDirectory(Path.GetDirectoryName(rulesPath)!); + await File.WriteAllTextAsync(rulesPath, yaml); + return rulesPath; + } + + public async Task Search(string keywords) + { + if (rulesPath is null || string.IsNullOrWhiteSpace(keywords) || !File.Exists(rulesPath)) + { + return ""; + } + + var rgResult = await RunRipgrepAsync(rulesPath, keywords); + return rgResult is not null + ? FormatRipgrepJson(rgResult) + : SearchWithRegexFallback(rulesPath, keywords); + } + + public async Task SearchIdentities(string? query = null, int limit = 25) + { + var context = await CreatePreparedIdentityContextAsync(CancellationToken.None); + if (context is null) + { + return "Speaker identity database is not configured."; + } + + await using (context) + { + var identities = await LoadIdentities(context) + .OrderBy(identity => identity.CanonicalName ?? "") + .ThenBy(identity => identity.Id) + .ToListAsync(); + if (!string.IsNullOrWhiteSpace(query)) + { + var needle = query.Trim(); + identities = identities + .Where(identity => IdentityMatches(identity, needle)) + .ToList(); + } + + return ToJson(identities + .Take(Math.Clamp(limit, 1, 100)) + .Select(ToIdentitySummary) + .ToList()); + } + } + + public async Task ReadIdentity(int identityId) + { + var context = await CreatePreparedIdentityContextAsync(CancellationToken.None); + if (context is null) + { + return "Speaker identity database is not configured."; + } + + await using (context) + { + var identity = await LoadIdentities(context) + .SingleOrDefaultAsync(identity => identity.Id == identityId); + return identity is null + ? $"Identity {identityId} was not found." + : ToJson(ToIdentityDetail(identity)); + } + } + + public async Task CreateIdentity( + string? canonicalName = null, + string[]? aliases = null, + string[]? candidateNames = null) + { + var context = await CreatePreparedIdentityContextAsync(CancellationToken.None); + if (context is null) + { + return "Speaker identity database is not configured."; + } + + await using (context) + { + var now = DateTimeOffset.UtcNow; + var identity = new SpeakerIdentity + { + CanonicalName = NormalizeNullable(canonicalName), + CreatedAt = now, + UpdatedAt = now, + Aliases = NormalizeNames(aliases) + .Select(name => new SpeakerAlias { Name = name }) + .ToList(), + CandidateNames = NormalizeNames(candidateNames) + .Select(name => new SpeakerCandidateName { Name = name }) + .ToList() + }; + context.SpeakerIdentities.Add(identity); + await context.SaveChangesAsync(); + return ToJson(ToIdentityDetail(identity)); + } + } + + public async Task UpdateIdentity( + int identityId, + string? canonicalName = null, + string[]? aliases = null, + string[]? candidateNames = null) + { + var context = await CreatePreparedIdentityContextAsync(CancellationToken.None); + if (context is null) + { + return "Speaker identity database is not configured."; + } + + await using (context) + { + var identity = await LoadIdentities(context) + .SingleOrDefaultAsync(identity => identity.Id == identityId); + if (identity is null) + { + return $"Identity {identityId} was not found."; + } + + identity.CanonicalName = NormalizeNullable(canonicalName); + identity.Aliases.Clear(); + identity.Aliases.AddRange(NormalizeNames(aliases) + .Select(name => new SpeakerAlias { Name = name })); + identity.CandidateNames.Clear(); + identity.CandidateNames.AddRange(NormalizeNames(candidateNames) + .Select(name => new SpeakerCandidateName { Name = name })); + identity.UpdatedAt = DateTimeOffset.UtcNow; + await context.SaveChangesAsync(); + return ToJson(ToIdentityDetail(identity)); + } + } + + public async Task DeleteIdentity(int identityId) + { + var context = await CreatePreparedIdentityContextAsync(CancellationToken.None); + if (context is null) + { + return "Speaker identity database is not configured."; + } + + await using (context) + { + var identity = await LoadIdentities(context) + .SingleOrDefaultAsync(identity => identity.Id == identityId); + if (identity is null) + { + return $"Identity {identityId} was not found."; + } + + context.SpeakerIdentities.Remove(identity); + await context.SaveChangesAsync(); + return $"Deleted identity {identityId}."; + } + } + + public async Task MergeIdentities(int targetIdentityId, int sourceIdentityId) + { + if (targetIdentityId == sourceIdentityId) + { + return "Refused: target and source identity are the same."; + } + + var context = await CreatePreparedIdentityContextAsync(CancellationToken.None); + if (context is null) + { + return "Speaker identity database is not configured."; + } + + await using (context) + { + var target = await LoadIdentities(context).SingleOrDefaultAsync(identity => identity.Id == targetIdentityId); + var source = await LoadIdentities(context).SingleOrDefaultAsync(identity => identity.Id == sourceIdentityId); + if (target is null || source is null) + { + return $"Could not find target {targetIdentityId} or source {sourceIdentityId}."; + } + + SpeakerIdentityMerger.MergeInto( + target, + source, + speakerOptions.MaxSnippetsPerSpeaker); + context.SpeakerIdentities.Remove(source); + await context.SaveChangesAsync(); + return ToJson(ToIdentityDetail(target)); + } + } + + public async Task ListIdentitySamples(int identityId) + { + var context = await CreatePreparedIdentityContextAsync(CancellationToken.None); + if (context is null) + { + return "Speaker identity database is not configured."; + } + + await using (context) + { + var samples = await context.SpeakerSnippets + .Where(sample => sample.SpeakerIdentityId == identityId) + .ToListAsync(); + return ToJson(samples + .OrderBy(sample => sample.CreatedAt) + .Select(ToIdentitySampleSummary) + .ToList()); + } + } + + public async Task ReadIdentitySample(int sampleId) + { + var context = await CreatePreparedIdentityContextAsync(CancellationToken.None); + if (context is null) + { + return "Speaker identity database is not configured."; + } + + await using (context) + { + var sample = await context.SpeakerSnippets.SingleOrDefaultAsync(sample => sample.Id == sampleId); + return sample is null + ? $"Sample {sampleId} was not found." + : ToJson(new IdentitySampleDetail( + sample.Id, + sample.SpeakerIdentityId, + sample.CreatedAt, + sample.WavBytes.Length, + Convert.ToBase64String(sample.WavBytes))); + } + } + + public async Task DeleteIdentitySample(int sampleId) + { + var context = await CreatePreparedIdentityContextAsync(CancellationToken.None); + if (context is null) + { + return "Speaker identity database is not configured."; + } + + await using (context) + { + var sample = await context.SpeakerSnippets.SingleOrDefaultAsync(sample => sample.Id == sampleId); + if (sample is null) + { + return $"Sample {sampleId} was not found."; + } + + context.SpeakerSnippets.Remove(sample); + await context.SaveChangesAsync(); + return $"Deleted sample {sampleId}."; + } + } + + public async Task QueuePlayIdentitySample(int sampleId) + { + if (samplePlaybackQueue is null) + { + return "Sample playback queue is not configured."; + } + + var context = await CreatePreparedIdentityContextAsync(CancellationToken.None); + if (context is null) + { + return "Speaker identity database is not configured."; + } + + await using (context) + { + var sample = await context.SpeakerSnippets.SingleOrDefaultAsync(sample => sample.Id == sampleId); + return sample is null + ? $"Sample {sampleId} was not found." + : await samplePlaybackQueue.QueueAsync(sample.Id, sample.WavBytes); + } + } + + private static string? ValidateYaml(string yaml) + { + if (string.IsNullOrWhiteSpace(yaml)) + { + return null; + } + + try + { + _ = YamlDeserializer.Deserialize(yaml); + return null; + } + catch (YamlException exception) + { + return $"Refused: workflow rules YAML is invalid. {exception.Message}"; + } + } + + private static async Task RunRipgrepAsync(string rulesPath, string keywords) + { + try + { + var startInfo = new ProcessStartInfo + { + FileName = "rg", + WorkingDirectory = Path.GetDirectoryName(rulesPath)!, + RedirectStandardOutput = true, + RedirectStandardError = true, + UseShellExecute = false + }; + startInfo.ArgumentList.Add("--json"); + startInfo.ArgumentList.Add("--line-number"); + startInfo.ArgumentList.Add("--color"); + startInfo.ArgumentList.Add("never"); + startInfo.ArgumentList.Add("--"); + startInfo.ArgumentList.Add(keywords); + startInfo.ArgumentList.Add(Path.GetFileName(rulesPath)); + + using var process = Process.Start(startInfo); + if (process is null) + { + return null; + } + + var output = await process.StandardOutput.ReadToEndAsync(); + await process.WaitForExitAsync(); + return process.ExitCode is 0 or 1 ? output : null; + } + catch + { + return null; + } + } + + private static string FormatRipgrepJson(string output) + { + var matches = new List(); + using var reader = new StringReader(output); + string? line; + while ((line = reader.ReadLine()) is not null) + { + using var document = JsonDocument.Parse(line); + var root = document.RootElement; + if (!root.TryGetProperty("type", out var type) || + type.GetString() != "match" || + !root.TryGetProperty("data", out var data)) + { + continue; + } + + var path = data.GetProperty("path").GetProperty("text").GetString() ?? ""; + var lineNumber = data.GetProperty("line_number").GetInt32(); + var text = data.GetProperty("lines").GetProperty("text").GetString() ?? ""; + matches.Add($"{ToToolPath(path)}:{lineNumber} {text.TrimEnd('\r', '\n')}"); + } + + return string.Join('\n', matches); + } + + private static string SearchWithRegexFallback(string rulesPath, string keywords) + { + try + { + var regex = new Regex(keywords, RegexOptions.IgnoreCase); + var matches = new List(); + var lines = File.ReadAllLines(rulesPath); + for (var index = 0; index < lines.Length; index++) + { + if (regex.IsMatch(lines[index])) + { + matches.Add($"{Path.GetFileName(rulesPath)}:{index + 1} {lines[index]}"); + } + } + + return string.Join('\n', matches); + } + catch + { + return ""; + } + } + + private static string ReadLines(string content, int? from = null, int? to = null) + { + if (!from.HasValue && !to.HasValue) + { + return content; + } + + var lines = content + .Replace("\r\n", "\n", StringComparison.Ordinal) + .Replace('\r', '\n') + .Split('\n') + .ToList(); + if (lines.Count == 0) + { + return ""; + } + + var start = Math.Clamp((from ?? 1) - 1, 0, lines.Count - 1); + var end = Math.Clamp((to ?? lines.Count) - 1, 0, lines.Count - 1); + if (end < start) + { + return ""; + } + + return string.Join('\n', lines.GetRange(start, end - start + 1)); + } + + private static string ToToolPath(string path) + { + return path.Replace(Path.DirectorySeparatorChar.ToString(), "/", StringComparison.Ordinal) + .Replace(Path.AltDirectorySeparatorChar.ToString(), "/", StringComparison.Ordinal); + } + + private async Task CreateIdentityContextAsync(CancellationToken cancellationToken) + { + return dbContextFactory is null + ? null + : await dbContextFactory.CreateDbContextAsync(cancellationToken); + } + + private async Task CreatePreparedIdentityContextAsync(CancellationToken cancellationToken) + { + var context = await CreateIdentityContextAsync(cancellationToken); + if (context is null) + { + return null; + } + + await SpeakerIdentitySchema.EnsureCreatedOrUpdatedAsync(context, cancellationToken); + return context; + } + + private static IQueryable LoadIdentities(SpeakerIdentityDbContext context) + { + return context.SpeakerIdentities + .Include(identity => identity.Aliases) + .Include(identity => identity.CandidateNames) + .Include(identity => identity.Snippets) + .Include(identity => identity.References); + } + + private static bool IdentityMatches(SpeakerIdentity identity, string query) + { + return new[] { identity.CanonicalName } + .Concat(identity.Aliases.Select(alias => alias.Name)) + .Concat(identity.CandidateNames.Select(candidate => candidate.Name)) + .Where(value => !string.IsNullOrWhiteSpace(value)) + .Any(value => value!.Contains(query, StringComparison.OrdinalIgnoreCase)); + } + + private static IdentitySummary ToIdentitySummary(SpeakerIdentity identity) + { + return new IdentitySummary( + identity.Id, + identity.CanonicalName, + identity.GetDisplayName(), + identity.Aliases.Select(alias => alias.Name).Order(StringComparer.OrdinalIgnoreCase).ToArray(), + identity.CandidateNames.Select(candidate => candidate.Name).Order(StringComparer.OrdinalIgnoreCase).ToArray(), + identity.Snippets.Count, + identity.References.Count, + identity.UpdatedAt); + } + + private static IdentityDetail ToIdentityDetail(SpeakerIdentity identity) + { + return new IdentityDetail( + ToIdentitySummary(identity), + identity.References + .OrderByDescending(reference => reference.CreatedAt) + .Select(reference => new IdentityReferenceDetail( + reference.Id, + reference.MeetingNotePath, + reference.TranscriptPath, + reference.CreatedAt)) + .ToArray(), + identity.Snippets + .OrderBy(sample => sample.CreatedAt) + .Select(ToIdentitySampleSummary) + .ToArray()); + } + + private static IdentitySampleSummary ToIdentitySampleSummary(SpeakerSnippet sample) + { + return new IdentitySampleSummary( + sample.Id, + sample.SpeakerIdentityId, + sample.CreatedAt, + sample.WavBytes.Length); + } + + private static IReadOnlyList NormalizeNames(IEnumerable? names) + { + return names? + .Select(name => name.Trim()) + .Where(name => !string.IsNullOrWhiteSpace(name)) + .Distinct(StringComparer.OrdinalIgnoreCase) + .Order(StringComparer.OrdinalIgnoreCase) + .ToArray() + ?? []; + } + + private static string? NormalizeNullable(string? value) + { + return string.IsNullOrWhiteSpace(value) ? null : value.Trim(); + } + + private static string ToJson(T value) + { + return JsonSerializer.Serialize(value, JsonOptions); + } + + private sealed record IdentitySummary( + int Id, + string? CanonicalName, + string? DisplayName, + IReadOnlyList Aliases, + IReadOnlyList CandidateNames, + int SampleCount, + int ReferenceCount, + DateTimeOffset UpdatedAt); + + private sealed record IdentityDetail( + IdentitySummary Identity, + IReadOnlyList References, + IReadOnlyList Samples); + + private sealed record IdentityReferenceDetail( + int Id, + string MeetingNotePath, + string TranscriptPath, + DateTimeOffset CreatedAt); + + private sealed record IdentitySampleSummary( + int Id, + int IdentityId, + DateTimeOffset CreatedAt, + int ByteCount); + + private sealed record IdentitySampleDetail( + int Id, + int IdentityId, + DateTimeOffset CreatedAt, + int ByteCount, + string Base64Wav); +} diff --git a/MeetingAssistant/Workflow/WorkflowRulesPathResolver.cs b/MeetingAssistant/Workflow/WorkflowRulesPathResolver.cs new file mode 100644 index 0000000..03bd8e9 --- /dev/null +++ b/MeetingAssistant/Workflow/WorkflowRulesPathResolver.cs @@ -0,0 +1,17 @@ +namespace MeetingAssistant.Workflow; + +public static class WorkflowRulesPathResolver +{ + public static string? Resolve(string? configuredPath) + { + if (string.IsNullOrWhiteSpace(configuredPath)) + { + return null; + } + + var expanded = Environment.ExpandEnvironmentVariables(configuredPath); + return Path.IsPathRooted(expanded) + ? Path.GetFullPath(expanded) + : Path.GetFullPath(expanded); + } +} diff --git a/MeetingAssistant/appsettings.json b/MeetingAssistant/appsettings.json index e723e13..e817051 100644 --- a/MeetingAssistant/appsettings.json +++ b/MeetingAssistant/appsettings.json @@ -111,6 +111,11 @@ "Automation": { "RulesPath": "C:\\Manuel\\meeting-assistant\\meeting-rules.local.yaml" }, + "WorkflowRulesEditor": { + "Endpoint": "", + "KeyEnv": "", + "Model": "" + }, "Screenshots": { "Hotkey": "Ctrl+Alt+S", "AttachmentsFolder": "Attachments", @@ -123,7 +128,7 @@ } }, "Agent": { - "Endpoint": "https://litellm.schweigert.cloud", + "Endpoint": "http://127.0.0.1:4021", "KeyEnv": "LITELLM_API_KEY", "Model": "chatgpt/gpt-5.5", "EnableThinking": true, diff --git a/README.md b/README.md index 335ad0c..4f4f8c4 100644 --- a/README.md +++ b/README.md @@ -171,6 +171,11 @@ Meeting Assistant uses normal .NET configuration under the `MeetingAssistant` se "Automation": { "RulesPath": "meeting-rules.local.yaml" }, + "WorkflowRulesEditor": { + "Endpoint": "", + "KeyEnv": "", + "Model": "" + }, "Screenshots": { "Hotkey": "Ctrl+Alt+S", "AttachmentsFolder": "Attachments", diff --git a/docs/meeting-workflow-engine.md b/docs/meeting-workflow-engine.md index 48316bb..7f4be73 100644 --- a/docs/meeting-workflow-engine.md +++ b/docs/meeting-workflow-engine.md @@ -22,6 +22,35 @@ The rules file path is configured through `MeetingAssistant:Automation:RulesPath If the configured path is empty, missing, or points to a blank file, the workflow engine does nothing. +The tray menu includes `Edit rules`, which opens a small MewUI chat editor for this configured rules file. The editor uses the summarizer agent configuration by default and can be overridden through `MeetingAssistant:WorkflowRulesEditor`. + +```json +{ + "MeetingAssistant": { + "WorkflowRulesEditor": { + "Endpoint": "", + "KeyEnv": "", + "Model": "", + "EnableThinking": null, + "ReasoningEffort": null, + "MaxOutputTokens": null, + "EnableCompaction": null, + "CompactionRemainingRatio": null, + "ResponsesCompactPath": "", + "InitialPrompt": "" + } + } +} +``` + +Blank editor values inherit from `MeetingAssistant:Agent`. This keeps the editor on the same LiteLLM Responses endpoint and model as the summarizer unless a value is explicitly configured. Each user-submitted editor turn sends its first model request with `X-Initiator: user`; follow-up model requests in the same turn, such as tool-call continuations, use `X-Initiator: agent`. + +The editor agent prompt includes this document and is restricted to these tools: + +- `read_rules`: read the configured rules file, optionally by inclusive 1-based line range. +- `write_rules`: overwrite the configured rules file after validating that the YAML parses as a workflow rules document. +- `search`: search only the configured rules file with ripgrep-style syntax. + `POST /diagnostics/workflow/reload` reloads application configuration without restarting the process. Use it after changing workflow automation configuration such as `MeetingAssistant:Automation:RulesPath`. The endpoint returns the currently bound rules path: ```json @@ -302,9 +331,13 @@ Core files: - `MeetingAssistant/Workflow/MeetingWorkflowModels.cs` - `MeetingAssistant/Workflow/MeetingWorkflowEvent.cs` - `MeetingAssistant/Workflow/FileMeetingWorkflowRulesProvider.cs` +- `MeetingAssistant/Workflow/WorkflowRulesEditorChatPipeline.cs` +- `MeetingAssistant/Workflow/WorkflowRulesEditorTools.cs` +- `MeetingAssistant/Workflow/MewUiWorkflowRulesEditorWindowService.Windows.cs` - `MeetingAssistant/Recording/MeetingRecordingCoordinator.cs` - `MeetingAssistant.Tests/MeetingWorkflowEngineTests.cs` - `MeetingAssistant.Tests/MeetingWorkflowDiagnosticEndpointTests.cs` +- `MeetingAssistant.Tests/WorkflowRulesEditorTests.cs` When extending the workflow engine: diff --git a/openspec/changes/archive/2026-05-27-add-workflow-rules-editor-ui/proposal.md b/openspec/changes/archive/2026-05-27-add-workflow-rules-editor-ui/proposal.md new file mode 100644 index 0000000..0abdae1 --- /dev/null +++ b/openspec/changes/archive/2026-05-27-add-workflow-rules-editor-ui/proposal.md @@ -0,0 +1,18 @@ +# Add workflow rules editor UI + +## Why + +Workflow rules are currently edited by opening the local YAML file manually. A small tray-launched assistant UI should make rule edits faster while keeping the agent constrained to the configured rules file and documented workflow format. + +## What Changes + +- Add an `Edit rules` tray menu item. +- Replace the tray icon implementation with the Uno notification icon stack. +- Add a MewUI chat window for editing workflow rules. +- Add a workflow-rules editor agent pipeline with read, write, and search tools scoped to the configured rules file. +- Allow the editor agent model configuration to inherit summarizer settings by default and be overridden independently. + +## Impact + +- Affected specs: `meeting-session` +- Affected code: taskbar host, workflow automation, agent client configuration, Windows UI package references diff --git a/openspec/changes/archive/2026-05-27-add-workflow-rules-editor-ui/specs/meeting-session/spec.md b/openspec/changes/archive/2026-05-27-add-workflow-rules-editor-ui/specs/meeting-session/spec.md new file mode 100644 index 0000000..85bbd17 --- /dev/null +++ b/openspec/changes/archive/2026-05-27-add-workflow-rules-editor-ui/specs/meeting-session/spec.md @@ -0,0 +1,70 @@ +## ADDED Requirements + +### Requirement: Workflow rules and speaker identities can be edited through a tray-launched assistant +Meeting Assistant SHALL expose an `Edit rules and identities` item from the tray icon menu. + +The tray icon SHALL be implemented through the Uno notification icon stack. + +The tray icon menu SHALL show every configured launch profile when recording can be started and SHALL include each profile's configured toggle hotkey in the corresponding start or switch menu item. + +When the user selects `Edit rules and identities`, Meeting Assistant SHALL open a basic MewUI chat window for editing the configured workflow rules file and speaker identities. + +The chat window SHALL be titled `Edit rules and identities`, SHALL display user and assistant messages as visually distinct cards, SHALL display basic markdown emphasis, inline code, fenced code blocks, pipe tables rendered as grid views, and line breaks in agent responses, SHALL display a plain `Thinking...` line while the agent is working, SHALL provide a multiline text input at the bottom with placeholder text for asking to make a rule or list identities, SHALL send on Enter, SHALL insert a newline on Shift+Enter, and SHALL provide an explicit Send button. + +When a new chat message or thinking state is appended, the chat window SHALL scroll to the bottom of the newly rendered content if the conversation was already near the bottom or did not need scrolling before the append. + +The Windows executable and rules-and-identities editor window SHALL use the Meeting Assistant application icon so the editor has a taskbar icon. + +The rules editor agent SHALL be configured from the summarizer agent settings by default, while allowing workflow-rules-editor-specific endpoint, key, model, reasoning, reconnection, output, and compaction settings to override those defaults. + +The rules and identities editor agent SHALL include the workflow engine documentation in its system prompt and SHALL receive read, write, and search tools scoped to the configured workflow rules file. + +The rules and identities editor agent SHALL receive speaker identity tools to search/list, read, create, update, delete, and merge identities in the local speaker identity database. + +The rules and identities editor agent SHALL receive speaker sample tools to list, read, delete, and queue playback of samples linked to identities. + +The first model request caused by each user-submitted chat turn SHALL send the `X-Initiator: user` header, while follow-up model requests within that same turn, such as tool-call continuations, SHALL send `X-Initiator: agent`. + +Meeting Assistant SHALL provide a diagnostic endpoint that opens the workflow rules editor through the same window service used by the tray menu. + +#### Scenario: Tray menu opens the editor +- **WHEN** the user opens the tray icon menu +- **THEN** the menu includes `Edit rules and identities` +- **WHEN** the user selects `Edit rules and identities` +- **THEN** Meeting Assistant opens the workflow rules and identities editor chat window + +#### Scenario: Tray menu shows configured profile hotkeys +- **GIVEN** the `default` launch profile uses `Ctrl+Alt+M` +- **AND** the `english` launch profile uses `Ctrl+Alt+E` +- **WHEN** the user opens the tray icon menu while Meeting Assistant is idle +- **THEN** the menu includes a start item for `default` showing `Ctrl+Alt+M` +- **AND** the menu includes a start item for `english` showing `Ctrl+Alt+E` + +#### Scenario: Rules editor can be opened diagnostically +- **WHEN** a diagnostic caller requests the workflow rules editor to open +- **THEN** Meeting Assistant invokes the workflow rules editor window service + +#### Scenario: Rules editor is scoped to the configured rules file +- **GIVEN** a configured workflow rules file +- **WHEN** the rules editor agent runs +- **THEN** its read, write, and search tools can access only that configured workflow rules file +- **AND** its system prompt includes the workflow engine documentation + +#### Scenario: Rules editor can manage speaker identities +- **GIVEN** the speaker identity database contains speaker identities and samples +- **WHEN** the rules editor agent runs +- **THEN** it can search, read, create, update, delete, and merge speaker identities +- **AND** it can list, read, delete, and queue playback of identity samples + +#### Scenario: User sends a rules-editing chat turn +- **GIVEN** the rules editor chat window is open +- **WHEN** the user types a prompt and presses Enter +- **THEN** the user message appears in the conversation +- **AND** a plain `Thinking...` line appears while the agent is running +- **AND** the first model request for that turn is marked as user-initiated +- **AND** the final assistant response replaces the thinking line + +#### Scenario: Chat auto-scrolls after appended content +- **GIVEN** the rules editor chat window content fits without scrolling or is already near the bottom +- **WHEN** a new user or assistant message is appended +- **THEN** the conversation scrolls to the bottom of the newly rendered message content diff --git a/openspec/changes/archive/2026-05-27-add-workflow-rules-editor-ui/tasks.md b/openspec/changes/archive/2026-05-27-add-workflow-rules-editor-ui/tasks.md new file mode 100644 index 0000000..fc116d8 --- /dev/null +++ b/openspec/changes/archive/2026-05-27-add-workflow-rules-editor-ui/tasks.md @@ -0,0 +1,16 @@ +## 1. Requirements + +- [x] Add meeting-session requirement scenarios for tray-launched rules and identities editing. + +## 2. Implementation + +- [x] Add scoped workflow-rules and speaker-identity editor tools and prompt builder. +- [x] Add workflow-rules editor chat pipeline with compaction and user-initiated requests. +- [x] Add MewUI chat window. +- [x] Add a Windows application/window icon for the MewUI editor taskbar button. +- [x] Replace WinForms tray icon with Uno notification icon host and add `Edit rules and identities`. + +## 3. Verification + +- [x] Add focused tests for options fallback, tool scoping, identity tools, prompt content, and view-model chat behavior. +- [x] Run relevant tests and strict OpenSpec validation. diff --git a/openspec/specs/meeting-session/spec.md b/openspec/specs/meeting-session/spec.md index e75cc1c..a3ee750 100644 --- a/openspec/specs/meeting-session/spec.md +++ b/openspec/specs/meeting-session/spec.md @@ -182,3 +182,73 @@ Meeting Assistant SHALL support these initial rule steps: - **GIVEN** a configured state-transition rule that matches a title containing a configured marker - **WHEN** the rule runs - **THEN** Meeting Assistant can update the meeting title through `set_property` + +### Requirement: Workflow rules and speaker identities can be edited through a tray-launched assistant +Meeting Assistant SHALL expose an `Edit rules and identities` item from the tray icon menu. + +The tray icon SHALL be implemented through the Uno notification icon stack. + +The tray icon menu SHALL show every configured launch profile when recording can be started and SHALL include each profile's configured toggle hotkey in the corresponding start or switch menu item. + +When the user selects `Edit rules and identities`, Meeting Assistant SHALL open a basic MewUI chat window for editing the configured workflow rules file and speaker identities. + +The chat window SHALL be titled `Edit rules and identities`, SHALL display user and assistant messages as visually distinct cards, SHALL display basic markdown emphasis, inline code, fenced code blocks, pipe tables rendered as grid views, and line breaks in agent responses, SHALL display a plain `Thinking...` line while the agent is working, SHALL provide a multiline text input at the bottom with placeholder text for asking to make a rule or list identities, SHALL send on Enter, SHALL insert a newline on Shift+Enter, and SHALL provide an explicit Send button. + +When a new chat message or thinking state is appended, the chat window SHALL scroll to the bottom of the newly rendered content if the conversation was already near the bottom or did not need scrolling before the append. + +The Windows executable and rules-and-identities editor window SHALL use the Meeting Assistant application icon so the editor has a taskbar icon. + +The rules editor agent SHALL be configured from the summarizer agent settings by default, while allowing workflow-rules-editor-specific endpoint, key, model, reasoning, reconnection, output, and compaction settings to override those defaults. + +The rules and identities editor agent SHALL include the workflow engine documentation in its system prompt and SHALL receive read, write, and search tools scoped to the configured workflow rules file. + +The rules and identities editor agent SHALL receive speaker identity tools to search/list, read, create, update, delete, and merge identities in the local speaker identity database. + +The rules and identities editor agent SHALL receive speaker sample tools to list, read, delete, and queue playback of samples linked to identities. + +The first model request caused by each user-submitted chat turn SHALL send the `X-Initiator: user` header, while follow-up model requests within that same turn, such as tool-call continuations, SHALL send `X-Initiator: agent`. + +Meeting Assistant SHALL provide a diagnostic endpoint that opens the workflow rules editor through the same window service used by the tray menu. + +#### Scenario: Tray menu opens the editor +- **WHEN** the user opens the tray icon menu +- **THEN** the menu includes `Edit rules and identities` +- **WHEN** the user selects `Edit rules and identities` +- **THEN** Meeting Assistant opens the workflow rules and identities editor chat window + +#### Scenario: Tray menu shows configured profile hotkeys +- **GIVEN** the `default` launch profile uses `Ctrl+Alt+M` +- **AND** the `english` launch profile uses `Ctrl+Alt+E` +- **WHEN** the user opens the tray icon menu while Meeting Assistant is idle +- **THEN** the menu includes a start item for `default` showing `Ctrl+Alt+M` +- **AND** the menu includes a start item for `english` showing `Ctrl+Alt+E` + +#### Scenario: Rules editor can be opened diagnostically +- **WHEN** a diagnostic caller requests the workflow rules editor to open +- **THEN** Meeting Assistant invokes the workflow rules editor window service + +#### Scenario: Rules editor is scoped to the configured rules file +- **GIVEN** a configured workflow rules file +- **WHEN** the rules editor agent runs +- **THEN** its read, write, and search tools can access only that configured workflow rules file +- **AND** its system prompt includes the workflow engine documentation + +#### Scenario: Rules editor can manage speaker identities +- **GIVEN** the speaker identity database contains speaker identities and samples +- **WHEN** the rules editor agent runs +- **THEN** it can search, read, create, update, delete, and merge speaker identities +- **AND** it can list, read, delete, and queue playback of identity samples + +#### Scenario: User sends a rules-editing chat turn +- **GIVEN** the rules editor chat window is open +- **WHEN** the user types a prompt and presses Enter +- **THEN** the user message appears in the conversation +- **AND** a plain `Thinking...` line appears while the agent is running +- **AND** the first model request for that turn is marked as user-initiated +- **AND** the final assistant response replaces the thinking line + +#### Scenario: Chat auto-scrolls after appended content +- **GIVEN** the rules editor chat window content fits without scrolling or is already near the bottom +- **WHEN** a new user or assistant message is appended +- **THEN** the conversation scrolls to the bottom of the newly rendered message content +