Public Access
Add taskbar controls and summary oneliner
This commit is contained in:
@@ -6,6 +6,8 @@ public interface ILaunchProfileOptionsProvider
|
||||
{
|
||||
LaunchProfile GetRequiredProfile(string? name);
|
||||
|
||||
IReadOnlyList<LaunchProfile> GetProfiles();
|
||||
|
||||
IReadOnlyList<LaunchProfileHotkey> GetHotkeys();
|
||||
}
|
||||
|
||||
@@ -77,6 +79,13 @@ public sealed class ConfigurationLaunchProfileOptionsProvider : ILaunchProfileOp
|
||||
return hotkeys;
|
||||
}
|
||||
|
||||
public IReadOnlyList<LaunchProfile> GetProfiles()
|
||||
{
|
||||
return GetProfileNames()
|
||||
.Select(GetRequiredProfile)
|
||||
.ToList();
|
||||
}
|
||||
|
||||
private IEnumerable<LaunchProfileHotkey> CreateHotkeys(string profileName)
|
||||
{
|
||||
var profile = GetRequiredProfile(profileName);
|
||||
|
||||
@@ -7,6 +7,10 @@
|
||||
<PreserveCompilationContext>true</PreserveCompilationContext>
|
||||
</PropertyGroup>
|
||||
|
||||
<PropertyGroup Condition="'$(TargetFramework)' == 'net10.0-windows'">
|
||||
<UseWindowsForms>true</UseWindowsForms>
|
||||
</PropertyGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<InternalsVisibleTo Include="MeetingAssistant.Tests" />
|
||||
</ItemGroup>
|
||||
@@ -30,6 +34,7 @@
|
||||
<Compile Remove="Recording\NaudioCaptureSource.cs" />
|
||||
<Compile Remove="MeetingNotes\OutlookClassicMeetingMetadataProvider.Windows.cs" />
|
||||
<Compile Remove="Screenshots\ActiveWindowScreenshotCapture.Windows.cs" />
|
||||
<Compile Remove="Taskbar\WindowsTaskbarIconService.cs" />
|
||||
</ItemGroup>
|
||||
|
||||
</Project>
|
||||
|
||||
@@ -6,6 +6,8 @@ public sealed class MeetingArtifactFrontmatter
|
||||
{
|
||||
public string Title { get; set; } = "";
|
||||
|
||||
public string? OneLiner { get; set; }
|
||||
|
||||
public DateTimeOffset? StartTime { get; set; }
|
||||
|
||||
public DateTimeOffset? EndTime { get; set; }
|
||||
@@ -38,6 +40,7 @@ public static class MeetingArtifactFrontmatterRenderer
|
||||
var builder = new StringBuilder();
|
||||
builder.AppendLine("---");
|
||||
AppendScalar(builder, "title", frontmatter.Title);
|
||||
AppendScalarIfNotEmpty(builder, "oneliner", frontmatter.OneLiner);
|
||||
AppendDateTime(builder, "start_time", frontmatter.StartTime);
|
||||
AppendDateTime(builder, "end_time", frontmatter.EndTime);
|
||||
AppendListIfNotNull(builder, "attendees", frontmatter.Attendees);
|
||||
@@ -124,6 +127,16 @@ public static class MeetingArtifactFrontmatterRenderer
|
||||
builder.AppendLine(string.IsNullOrWhiteSpace(value) ? "\"\"" : EscapeListItem(value));
|
||||
}
|
||||
|
||||
private static void AppendScalarIfNotEmpty(StringBuilder builder, string key, string? value)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(value))
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
AppendScalar(builder, key, value);
|
||||
}
|
||||
|
||||
private static void AppendQuoted(StringBuilder builder, string key, string value)
|
||||
{
|
||||
builder.Append(key);
|
||||
|
||||
@@ -6,6 +6,7 @@ using MeetingAssistant.Recording;
|
||||
using MeetingAssistant.Screenshots;
|
||||
using MeetingAssistant.Speakers;
|
||||
using MeetingAssistant.Summary;
|
||||
using MeetingAssistant.Taskbar;
|
||||
using MeetingAssistant.Transcription;
|
||||
using MeetingAssistant.Workflow;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
@@ -80,6 +81,7 @@ builder.Services.AddHostedService<SpeakerIdentityDatabaseInitializer>();
|
||||
builder.Services.AddHostedService<SpeechRecognitionPipelineHostedService>();
|
||||
#if WINDOWS
|
||||
builder.Services.AddHostedService<GlobalHotkeyService>();
|
||||
builder.Services.AddHostedService<WindowsTaskbarIconService>();
|
||||
#endif
|
||||
|
||||
var app = builder.Build();
|
||||
|
||||
@@ -82,12 +82,26 @@ public sealed class MeetingRecordingCoordinator
|
||||
currentArtifacts?.TranscriptPath ?? currentSession?.TranscriptPath,
|
||||
currentArtifacts?.MeetingNotePath ?? currentMeetingNote?.Path,
|
||||
currentArtifacts?.AssistantContextPath,
|
||||
currentArtifacts?.SummaryPath);
|
||||
currentArtifacts?.SummaryPath,
|
||||
GetProcessState(currentRun),
|
||||
currentRun?.LaunchProfileName);
|
||||
|
||||
public MeetingSessionArtifacts? CurrentArtifacts => currentArtifacts;
|
||||
|
||||
private bool IsRecording => currentRun is { IsCaptureStopping: false };
|
||||
|
||||
private static RecordingProcessState GetProcessState(RecordingRun? run)
|
||||
{
|
||||
if (run is null)
|
||||
{
|
||||
return RecordingProcessState.Idle;
|
||||
}
|
||||
|
||||
return run.IsCaptureStopping
|
||||
? RecordingProcessState.Summarizing
|
||||
: RecordingProcessState.Recording;
|
||||
}
|
||||
|
||||
public async Task<RecordingStatus> ToggleAsync(CancellationToken cancellationToken)
|
||||
{
|
||||
return await ToggleAsync(null, cancellationToken);
|
||||
@@ -1687,4 +1701,13 @@ public sealed record RecordingStatus(
|
||||
string? TranscriptPath,
|
||||
string? MeetingNotePath,
|
||||
string? AssistantContextPath,
|
||||
string? SummaryPath);
|
||||
string? SummaryPath,
|
||||
RecordingProcessState State = RecordingProcessState.Idle,
|
||||
string? LaunchProfile = null);
|
||||
|
||||
public enum RecordingProcessState
|
||||
{
|
||||
Idle,
|
||||
Recording,
|
||||
Summarizing
|
||||
}
|
||||
|
||||
@@ -10,7 +10,8 @@ public sealed class MeetingSummaryInstructionBuilder : IMeetingSummaryInstructio
|
||||
|
||||
Use the provided tools to read the meeting transcript, assistant context, user notes, glossary, and project files.
|
||||
All read tools can return the whole file or a clamped inclusive line range when from and to are supplied; use ranges for large inputs before asking for more lines.
|
||||
Then write the finished meeting summary as markdown by calling write_summary. If the meeting note has no title, provide a concise title parameter to write_summary.
|
||||
Then write the finished meeting summary as markdown by calling write_summary. The write_summary call requires an oneliner parameter: keep it very short, blurb-like, and concise. No line breaks. Complete sentences are not necessary; conciseness is more important.
|
||||
If the meeting note has no title, provide a concise title parameter to write_summary.
|
||||
Use read_meetingnote to inspect frontmatter such as title, attendees, projects, start_time, and end_time.
|
||||
Use read_context and write_context as your own meeting notebook. Its frontmatter may include agenda from the calendar appointment. Record useful internal notes, missing context, requests for future tools, suggested improvements, and relevant context discovered from other sources. Keep user-facing summary content in the summary note.
|
||||
Use add_dictation_word when project context, user notes, or transcript evidence show that a domain term, acronym, name, or unusual word is likely to be repeatedly mistranscribed. Add only the canonical spelling, one term at a time.
|
||||
|
||||
@@ -206,8 +206,19 @@ public sealed class MeetingSummaryTools
|
||||
return $"Deleted identity {deletion.Identity} and relabeled transcript speaker mentions to {deletion.Replacement}.";
|
||||
}
|
||||
|
||||
public async Task<string> WriteSummary(string markdown, string? title = null)
|
||||
public async Task<string> WriteSummary(string markdown, string oneliner, string? title = null)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(oneliner))
|
||||
{
|
||||
return "Refused: oneliner must not be empty.";
|
||||
}
|
||||
|
||||
var trimmedOneLiner = oneliner.Trim();
|
||||
if (ContainsLineBreak(trimmedOneLiner))
|
||||
{
|
||||
return "Refused: oneliner must be a single line.";
|
||||
}
|
||||
|
||||
Directory.CreateDirectory(Path.GetDirectoryName(artifacts.SummaryPath)!);
|
||||
var meetingNote = await ReadMeetingNoteAsync();
|
||||
var summaryTitle = string.IsNullOrWhiteSpace(meetingNote.Frontmatter.Title)
|
||||
@@ -218,6 +229,7 @@ public sealed class MeetingSummaryTools
|
||||
meetingNote,
|
||||
string.IsNullOrWhiteSpace(summaryTitle) ? "Meeting Summary" : summaryTitle,
|
||||
CancellationToken.None);
|
||||
frontmatter.OneLiner = trimmedOneLiner;
|
||||
await File.WriteAllTextAsync(
|
||||
artifacts.SummaryPath,
|
||||
MeetingArtifactFrontmatterRenderer.Render(frontmatter, markdown));
|
||||
@@ -225,6 +237,12 @@ public sealed class MeetingSummaryTools
|
||||
return artifacts.SummaryPath;
|
||||
}
|
||||
|
||||
private static bool ContainsLineBreak(string value)
|
||||
{
|
||||
return value.Contains('\n', StringComparison.Ordinal) ||
|
||||
value.Contains('\r', StringComparison.Ordinal);
|
||||
}
|
||||
|
||||
public bool SummaryWasWritten { get; private set; }
|
||||
|
||||
public async Task<string> WriteContext(
|
||||
|
||||
@@ -0,0 +1,87 @@
|
||||
using MeetingAssistant.LaunchProfiles;
|
||||
using MeetingAssistant.Recording;
|
||||
|
||||
namespace MeetingAssistant.Taskbar;
|
||||
|
||||
public enum MeetingTaskbarAction
|
||||
{
|
||||
StartRecording,
|
||||
StopRecording,
|
||||
AbortRecording,
|
||||
SwitchProfile
|
||||
}
|
||||
|
||||
public sealed record MeetingTaskbarMenu(
|
||||
RecordingProcessState State,
|
||||
string Tooltip,
|
||||
IReadOnlyList<MeetingTaskbarMenuItem> Items);
|
||||
|
||||
public sealed record MeetingTaskbarMenuItem(
|
||||
string Text,
|
||||
MeetingTaskbarAction Action,
|
||||
string? ProfileName = null);
|
||||
|
||||
public static class MeetingTaskbarMenuBuilder
|
||||
{
|
||||
public static MeetingTaskbarMenu Build(
|
||||
RecordingStatus status,
|
||||
IReadOnlyList<LaunchProfile> launchProfiles)
|
||||
{
|
||||
var items = new List<MeetingTaskbarMenuItem>();
|
||||
|
||||
if (status.IsRecording)
|
||||
{
|
||||
items.Add(new MeetingTaskbarMenuItem(
|
||||
"Stop meeting recording and transcribe",
|
||||
MeetingTaskbarAction.StopRecording));
|
||||
items.Add(new MeetingTaskbarMenuItem(
|
||||
"Cancel meeting recording and discard",
|
||||
MeetingTaskbarAction.AbortRecording));
|
||||
|
||||
foreach (var profile in launchProfiles.Where(profile => !IsActiveProfile(profile, status)))
|
||||
{
|
||||
items.Add(new MeetingTaskbarMenuItem(
|
||||
$"Switch to {profile.Name}",
|
||||
MeetingTaskbarAction.SwitchProfile,
|
||||
profile.Name));
|
||||
}
|
||||
}
|
||||
else
|
||||
{
|
||||
foreach (var profile in launchProfiles)
|
||||
{
|
||||
items.Add(new MeetingTaskbarMenuItem(
|
||||
$"Start meeting recording ({profile.Name})",
|
||||
MeetingTaskbarAction.StartRecording,
|
||||
profile.Name));
|
||||
}
|
||||
}
|
||||
|
||||
return new MeetingTaskbarMenu(
|
||||
status.State,
|
||||
BuildTooltip(status),
|
||||
items);
|
||||
}
|
||||
|
||||
private static string BuildTooltip(RecordingStatus status)
|
||||
{
|
||||
return status.State switch
|
||||
{
|
||||
RecordingProcessState.Recording => string.IsNullOrWhiteSpace(status.LaunchProfile)
|
||||
? "Meeting Assistant - recording"
|
||||
: $"Meeting Assistant - recording ({status.LaunchProfile})",
|
||||
RecordingProcessState.Summarizing => "Meeting Assistant - summarizing",
|
||||
_ => "Meeting Assistant - idle"
|
||||
};
|
||||
}
|
||||
|
||||
private static bool IsActiveProfile(
|
||||
LaunchProfile profile,
|
||||
RecordingStatus status)
|
||||
{
|
||||
return string.Equals(
|
||||
profile.Name,
|
||||
status.LaunchProfile,
|
||||
StringComparison.OrdinalIgnoreCase);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,243 @@
|
||||
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);
|
||||
}
|
||||
Reference in New Issue
Block a user