Public Access
Add tray rules and identities editor
PR and Push Build/Test / build-and-test (push) Successful in 7m0s
PR and Push Build/Test / build-and-test (push) Successful in 7m0s
This commit is contained in:
Binary file not shown.
|
After Width: | Height: | Size: 12 KiB |
@@ -7,8 +7,9 @@
|
||||
<PreserveCompilationContext>true</PreserveCompilationContext>
|
||||
</PropertyGroup>
|
||||
|
||||
<PropertyGroup Condition="'$(TargetFramework)' == 'net10.0-windows'">
|
||||
<UseWindowsForms>true</UseWindowsForms>
|
||||
<PropertyGroup Condition="$([MSBuild]::GetTargetPlatformIdentifier('$(TargetFramework)')) == 'windows'">
|
||||
<OutputType>WinExe</OutputType>
|
||||
<ApplicationIcon>Assets\meeting-assistant.ico</ApplicationIcon>
|
||||
</PropertyGroup>
|
||||
|
||||
<ItemGroup>
|
||||
@@ -29,12 +30,32 @@
|
||||
<PackageReference Include="YamlDotNet" Version="17.1.0" />
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup Condition="'$(TargetFramework)' != 'net10.0-windows'">
|
||||
<ItemGroup Condition="$([MSBuild]::GetTargetPlatformIdentifier('$(TargetFramework)')) == 'windows'">
|
||||
<PackageReference Include="Aprillz.MewUI.Windows" Version="0.15.2" />
|
||||
<PackageReference Include="H.NotifyIcon.Uno.WinUI" Version="2.4.1" />
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<Content Update="appsettings*.json" CopyToOutputDirectory="PreserveNewest" CopyToPublishDirectory="PreserveNewest" TargetPath="%(Filename)%(Extension)" />
|
||||
<None Update="Assets\meeting-assistant.ico" CopyToOutputDirectory="PreserveNewest" CopyToPublishDirectory="PreserveNewest" />
|
||||
<None Include="..\docs\meeting-workflow-engine.md" Link="docs\meeting-workflow-engine.md" CopyToOutputDirectory="PreserveNewest" />
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<MeetingAssistantRootConfig Include="$(MSBuildProjectDirectory)\appsettings*.json" />
|
||||
</ItemGroup>
|
||||
|
||||
<Target Name="CopyMeetingAssistantConfigToPublishRoot" AfterTargets="Publish">
|
||||
<Copy SourceFiles="@(MeetingAssistantRootConfig)" DestinationFolder="$(PublishDir)" SkipUnchangedFiles="true" />
|
||||
</Target>
|
||||
|
||||
<ItemGroup Condition="$([MSBuild]::GetTargetPlatformIdentifier('$(TargetFramework)')) != 'windows'">
|
||||
<Compile Remove="Hotkeys\GlobalHotkeyService.cs" />
|
||||
<Compile Remove="Recording\NaudioCaptureSource.cs" />
|
||||
<Compile Remove="MeetingNotes\OutlookClassicMeetingMetadataProvider.Windows.cs" />
|
||||
<Compile Remove="Screenshots\ActiveWindowScreenshotCapture.Windows.cs" />
|
||||
<Compile Remove="Taskbar\WindowsTaskbarIconService.cs" />
|
||||
<Compile Remove="Taskbar\UnoTaskbarIconService.Windows.cs" />
|
||||
<Compile Remove="Workflow\MewUiWorkflowRulesEditorWindowService.Windows.cs" />
|
||||
</ItemGroup>
|
||||
|
||||
</Project>
|
||||
|
||||
@@ -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";
|
||||
|
||||
@@ -61,6 +61,15 @@ builder.Services.AddSingleton<IMeetingSummaryPipeline, OpenAiMeetingSummaryAgent
|
||||
builder.Services.AddSingleton<IMeetingSummaryRetryRunner, MeetingSummaryRetryRunner>();
|
||||
builder.Services.AddSingleton<IMeetingWorkflowRulesProvider, FileMeetingWorkflowRulesProvider>();
|
||||
builder.Services.AddSingleton<IMeetingWorkflowEngine, MeetingWorkflowEngine>();
|
||||
builder.Services.AddSingleton<IWorkflowRulesEditorInstructionBuilder, WorkflowRulesEditorInstructionBuilder>();
|
||||
builder.Services.AddSingleton<IWorkflowRulesEditorSamplePlaybackQueue, WorkflowRulesEditorSamplePlaybackQueue>();
|
||||
builder.Services.AddSingleton<IWorkflowRulesEditorChatPipeline, WorkflowRulesEditorChatPipeline>();
|
||||
builder.Services.AddTransient<WorkflowRulesEditorChatViewModel>();
|
||||
#if WINDOWS
|
||||
builder.Services.AddSingleton<IWorkflowRulesEditorWindowService, MewUiWorkflowRulesEditorWindowService>();
|
||||
#else
|
||||
builder.Services.AddSingleton<IWorkflowRulesEditorWindowService, NoopWorkflowRulesEditorWindowService>();
|
||||
#endif
|
||||
builder.Services.AddSingleton<AsrDiagnosticService>();
|
||||
builder.Services.AddSingleton<ICommandRunner, ProcessCommandRunner>();
|
||||
builder.Services.AddSingleton<IFunAsrBackendReadinessProbe, FunAsrWebSocketBackendReadinessProbe>();
|
||||
@@ -81,7 +90,7 @@ builder.Services.AddHostedService<SpeakerIdentityDatabaseInitializer>();
|
||||
builder.Services.AddHostedService<SpeechRecognitionPipelineHostedService>();
|
||||
#if WINDOWS
|
||||
builder.Services.AddHostedService<GlobalHotkeyService>();
|
||||
builder.Services.AddHostedService<WindowsTaskbarIconService>();
|
||||
builder.Services.AddHostedService<UnoTaskbarIconService>();
|
||||
#endif
|
||||
|
||||
var app = builder.Build();
|
||||
@@ -213,6 +222,12 @@ static async Task<IResult> 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)
|
||||
{
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
@@ -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<LaunchProfile> launchProfiles)
|
||||
{
|
||||
var items = new List<MeetingTaskbarMenuItem>();
|
||||
var items = new List<MeetingTaskbarMenuItem>
|
||||
{
|
||||
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()}";
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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<UnoTaskbarIconService> 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<UnoTaskbarIconService> 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
|
||||
@@ -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<WindowsTaskbarIconService> 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<WindowsTaskbarIconService> 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);
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,21 @@
|
||||
namespace MeetingAssistant.Workflow;
|
||||
|
||||
public interface IWorkflowRulesEditorWindowService
|
||||
{
|
||||
void Show();
|
||||
}
|
||||
|
||||
public sealed class NoopWorkflowRulesEditorWindowService : IWorkflowRulesEditorWindowService
|
||||
{
|
||||
private readonly ILogger<NoopWorkflowRulesEditorWindowService> logger;
|
||||
|
||||
public NoopWorkflowRulesEditorWindowService(ILogger<NoopWorkflowRulesEditorWindowService> logger)
|
||||
{
|
||||
this.logger = logger;
|
||||
}
|
||||
|
||||
public void Show()
|
||||
{
|
||||
logger.LogInformation("Workflow rules editor UI is only available on Windows");
|
||||
}
|
||||
}
|
||||
@@ -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<MewUiWorkflowRulesEditorWindowService> logger;
|
||||
private readonly object sync = new();
|
||||
private bool isRunning;
|
||||
|
||||
public MewUiWorkflowRulesEditorWindowService(
|
||||
IServiceProvider services,
|
||||
ILogger<MewUiWorkflowRulesEditorWindowService> 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<WorkflowRulesEditorChatViewModel>();
|
||||
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
|
||||
@@ -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<WorkflowRulesEditorChatMessage> Conversation);
|
||||
|
||||
public interface IWorkflowRulesEditorChatPipeline
|
||||
{
|
||||
Task<WorkflowRulesEditorChatResult> SendAsync(
|
||||
IReadOnlyList<WorkflowRulesEditorChatMessage> conversation,
|
||||
string userMessage,
|
||||
CancellationToken cancellationToken);
|
||||
}
|
||||
@@ -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<WorkflowRulesEditorChatPipeline> logger;
|
||||
private readonly IWorkflowRulesEditorInstructionBuilder instructionBuilder;
|
||||
private readonly IDbContextFactory<SpeakerIdentityDbContext> speakerIdentityDbContextFactory;
|
||||
private readonly IWorkflowRulesEditorSamplePlaybackQueue samplePlaybackQueue;
|
||||
|
||||
public WorkflowRulesEditorChatPipeline(
|
||||
IOptions<MeetingAssistantOptions> options,
|
||||
ILoggerFactory loggerFactory,
|
||||
ILogger<WorkflowRulesEditorChatPipeline> logger,
|
||||
IWorkflowRulesEditorInstructionBuilder instructionBuilder,
|
||||
IDbContextFactory<SpeakerIdentityDbContext> 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<WorkflowRulesEditorChatResult> SendAsync(
|
||||
IReadOnlyList<WorkflowRulesEditorChatMessage> 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
|
||||
@@ -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<WorkflowRulesEditorChatMessage> 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);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,80 @@
|
||||
using Microsoft.Extensions.Options;
|
||||
|
||||
namespace MeetingAssistant.Workflow;
|
||||
|
||||
public interface IWorkflowRulesEditorInstructionBuilder
|
||||
{
|
||||
Task<string> 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<WorkflowRulesEditorInstructionBuilder> logger;
|
||||
|
||||
public WorkflowRulesEditorInstructionBuilder(ILogger<WorkflowRulesEditorInstructionBuilder> logger)
|
||||
{
|
||||
this.logger = logger;
|
||||
}
|
||||
|
||||
public async Task<string> 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) ?? "<not configured>";
|
||||
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<string> 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<string> 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;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,258 @@
|
||||
namespace MeetingAssistant.Workflow;
|
||||
|
||||
internal enum WorkflowRulesEditorMarkdownInlineStyle
|
||||
{
|
||||
Normal,
|
||||
Italic,
|
||||
Bold,
|
||||
Code
|
||||
}
|
||||
|
||||
internal abstract record WorkflowRulesEditorMarkdownBlock;
|
||||
|
||||
internal sealed record WorkflowRulesEditorMarkdownParagraph(
|
||||
IReadOnlyList<WorkflowRulesEditorMarkdownInline> Inlines) : WorkflowRulesEditorMarkdownBlock;
|
||||
|
||||
internal sealed record WorkflowRulesEditorMarkdownCodeBlock(string Text) : WorkflowRulesEditorMarkdownBlock;
|
||||
|
||||
internal sealed record WorkflowRulesEditorMarkdownTable(
|
||||
IReadOnlyList<string> Header,
|
||||
IReadOnlyList<IReadOnlyList<string>> Rows) : WorkflowRulesEditorMarkdownBlock;
|
||||
|
||||
internal sealed record WorkflowRulesEditorMarkdownInline(
|
||||
string Text,
|
||||
WorkflowRulesEditorMarkdownInlineStyle Style);
|
||||
|
||||
internal static class WorkflowRulesEditorMarkdown
|
||||
{
|
||||
public static IReadOnlyList<WorkflowRulesEditorMarkdownBlock> Parse(string text)
|
||||
{
|
||||
if (string.IsNullOrEmpty(text))
|
||||
{
|
||||
return [];
|
||||
}
|
||||
|
||||
var blocks = new List<WorkflowRulesEditorMarkdownBlock>();
|
||||
var paragraph = new List<string>();
|
||||
var code = new List<string>();
|
||||
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<IReadOnlyList<string>>();
|
||||
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<WorkflowRulesEditorMarkdownInline> ParseInline(string text)
|
||||
{
|
||||
var result = new List<WorkflowRulesEditorMarkdownInline>();
|
||||
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<WorkflowRulesEditorMarkdownBlock> blocks,
|
||||
List<string> paragraph)
|
||||
{
|
||||
if (paragraph.Count == 0)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
blocks.Add(new WorkflowRulesEditorMarkdownParagraph(ParseInline(string.Join('\n', paragraph))));
|
||||
paragraph.Clear();
|
||||
}
|
||||
|
||||
private static bool IsTableStart(IReadOnlyList<string> 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<string> 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<WorkflowRulesEditorMarkdownInline> 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<string> ReadLines(string text)
|
||||
{
|
||||
using var reader = new StringReader(text);
|
||||
while (reader.ReadLine() is { } line)
|
||||
{
|
||||
yield return line;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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<WorkflowRulesEditorMarkdownInline> 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<UIElement> 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<IReadOnlyList<WorkflowRulesEditorMarkdownInline>> SplitInlineLines(
|
||||
IReadOnlyList<WorkflowRulesEditorMarkdownInline> inlines)
|
||||
{
|
||||
var lines = new List<IReadOnlyList<WorkflowRulesEditorMarkdownInline>>();
|
||||
var current = new List<WorkflowRulesEditorMarkdownInline>();
|
||||
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<MarkdownTableRow>(
|
||||
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<string> 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<string> PadCells(IReadOnlyList<string> 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<string> Cells)
|
||||
{
|
||||
public string GetCell(int index)
|
||||
{
|
||||
return index >= 0 && index < Cells.Count
|
||||
? Cells[index]
|
||||
: "";
|
||||
}
|
||||
}
|
||||
}
|
||||
#endif
|
||||
@@ -0,0 +1,100 @@
|
||||
using System.Threading.Channels;
|
||||
using NAudio.Wave;
|
||||
|
||||
namespace MeetingAssistant.Workflow;
|
||||
|
||||
public interface IWorkflowRulesEditorSamplePlaybackQueue
|
||||
{
|
||||
Task<string> QueueAsync(int sampleId, byte[] wavBytes, CancellationToken cancellationToken = default);
|
||||
}
|
||||
|
||||
public sealed class WorkflowRulesEditorSamplePlaybackQueue : IWorkflowRulesEditorSamplePlaybackQueue, IDisposable
|
||||
{
|
||||
private readonly Channel<QueuedSample> channel = Channel.CreateUnbounded<QueuedSample>();
|
||||
private readonly ILogger<WorkflowRulesEditorSamplePlaybackQueue> logger;
|
||||
private readonly CancellationTokenSource cancellation = new();
|
||||
private readonly Task playbackTask;
|
||||
|
||||
public WorkflowRulesEditorSamplePlaybackQueue(ILogger<WorkflowRulesEditorSamplePlaybackQueue> logger)
|
||||
{
|
||||
this.logger = logger;
|
||||
playbackTask = Task.Run(PlayQueuedSamplesAsync);
|
||||
}
|
||||
|
||||
public async Task<string> 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);
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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<SpeakerIdentityDbContext>? dbContextFactory;
|
||||
private readonly IWorkflowRulesEditorSamplePlaybackQueue? samplePlaybackQueue;
|
||||
|
||||
public WorkflowRulesEditorTools(
|
||||
MeetingAssistantOptions options,
|
||||
IDbContextFactory<SpeakerIdentityDbContext>? dbContextFactory = null,
|
||||
IWorkflowRulesEditorSamplePlaybackQueue? samplePlaybackQueue = null)
|
||||
{
|
||||
rulesPath = WorkflowRulesPathResolver.Resolve(options.Automation.RulesPath);
|
||||
speakerOptions = options.SpeakerIdentification;
|
||||
this.dbContextFactory = dbContextFactory;
|
||||
this.samplePlaybackQueue = samplePlaybackQueue;
|
||||
}
|
||||
|
||||
public async Task<string> 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<string> 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<string> 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<string> 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<string> 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<string> 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<string> 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<string> 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<string> 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<string> 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<string> 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<string> 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<string> 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<MeetingWorkflowRulesFile>(yaml);
|
||||
return null;
|
||||
}
|
||||
catch (YamlException exception)
|
||||
{
|
||||
return $"Refused: workflow rules YAML is invalid. {exception.Message}";
|
||||
}
|
||||
}
|
||||
|
||||
private static async Task<string?> 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<string>();
|
||||
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<string>();
|
||||
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<SpeakerIdentityDbContext?> CreateIdentityContextAsync(CancellationToken cancellationToken)
|
||||
{
|
||||
return dbContextFactory is null
|
||||
? null
|
||||
: await dbContextFactory.CreateDbContextAsync(cancellationToken);
|
||||
}
|
||||
|
||||
private async Task<SpeakerIdentityDbContext?> CreatePreparedIdentityContextAsync(CancellationToken cancellationToken)
|
||||
{
|
||||
var context = await CreateIdentityContextAsync(cancellationToken);
|
||||
if (context is null)
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
await SpeakerIdentitySchema.EnsureCreatedOrUpdatedAsync(context, cancellationToken);
|
||||
return context;
|
||||
}
|
||||
|
||||
private static IQueryable<SpeakerIdentity> 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<string> NormalizeNames(IEnumerable<string>? 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>(T value)
|
||||
{
|
||||
return JsonSerializer.Serialize(value, JsonOptions);
|
||||
}
|
||||
|
||||
private sealed record IdentitySummary(
|
||||
int Id,
|
||||
string? CanonicalName,
|
||||
string? DisplayName,
|
||||
IReadOnlyList<string> Aliases,
|
||||
IReadOnlyList<string> CandidateNames,
|
||||
int SampleCount,
|
||||
int ReferenceCount,
|
||||
DateTimeOffset UpdatedAt);
|
||||
|
||||
private sealed record IdentityDetail(
|
||||
IdentitySummary Identity,
|
||||
IReadOnlyList<IdentityReferenceDetail> References,
|
||||
IReadOnlyList<IdentitySampleSummary> 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);
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user