Public Access
Update meeting summary agent UI
This commit is contained in:
@@ -42,6 +42,99 @@ public sealed class LiteLlmResponsesChatClientTests
|
||||
Assert.Equal("gpt-5.5-2026-04-23", response.ModelId);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task ClientReportsVisibleReasoningSummariesSeparatelyFromResponseText()
|
||||
{
|
||||
var handler = new SequencedHttpMessageHandler(
|
||||
new HttpResponseMessage(HttpStatusCode.OK)
|
||||
{
|
||||
Content = new StringContent("""
|
||||
{
|
||||
"output": [
|
||||
{
|
||||
"type": "reasoning",
|
||||
"summary": [
|
||||
{
|
||||
"type": "summary_text",
|
||||
"text": "Checked the configured rules file."
|
||||
},
|
||||
{
|
||||
"type": "summary_text",
|
||||
"text": "Prepared a targeted update."
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"type": "message",
|
||||
"content": [
|
||||
{
|
||||
"type": "output_text",
|
||||
"text": "Done."
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
""")
|
||||
});
|
||||
var reasoningSummaries = new List<string>();
|
||||
using var client = CreateClient(
|
||||
handler,
|
||||
reconnectionAttempts: 0,
|
||||
reasoningSummaryChanged: reasoningSummaries.Add);
|
||||
|
||||
var response = await client.GetResponseAsync([new ChatMessage(ChatRole.User, "update rules")]);
|
||||
|
||||
Assert.Equal("Done.", response.Text);
|
||||
Assert.Equal(
|
||||
[
|
||||
"Checked the configured rules file.",
|
||||
"Prepared a targeted update."
|
||||
],
|
||||
reasoningSummaries);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task ClientDoesNotFailResponseWhenReasoningSummaryCallbackFails()
|
||||
{
|
||||
var handler = new SequencedHttpMessageHandler(
|
||||
new HttpResponseMessage(HttpStatusCode.OK)
|
||||
{
|
||||
Content = new StringContent("""
|
||||
{
|
||||
"output": [
|
||||
{
|
||||
"type": "reasoning",
|
||||
"summary": [
|
||||
{
|
||||
"type": "summary_text",
|
||||
"text": "Checked the configured rules file."
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"type": "message",
|
||||
"content": [
|
||||
{
|
||||
"type": "output_text",
|
||||
"text": "Done."
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
""")
|
||||
});
|
||||
using var client = CreateClient(
|
||||
handler,
|
||||
reconnectionAttempts: 0,
|
||||
reasoningSummaryChanged: _ => throw new InvalidOperationException("UI failed"));
|
||||
|
||||
var response = await client.GetResponseAsync([new ChatMessage(ChatRole.User, "update rules")]);
|
||||
|
||||
Assert.Equal("Done.", response.Text);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ParserReadsFunctionCalls()
|
||||
{
|
||||
@@ -306,7 +399,8 @@ public sealed class LiteLlmResponsesChatClientTests
|
||||
HttpMessageHandler handler,
|
||||
int reconnectionAttempts,
|
||||
LiteLlmResponsesCompactionOptions? compactionOptions = null,
|
||||
Action? retrying = null)
|
||||
Action? retrying = null,
|
||||
Action<string>? reasoningSummaryChanged = null)
|
||||
{
|
||||
return new LiteLlmResponsesChatClient(
|
||||
new HttpClient(handler)
|
||||
@@ -320,7 +414,8 @@ public sealed class LiteLlmResponsesChatClientTests
|
||||
reconnectionAttempts,
|
||||
TimeSpan.Zero,
|
||||
compactionOptions,
|
||||
retrying: retrying);
|
||||
retrying: retrying,
|
||||
reasoningSummaryChanged: reasoningSummaryChanged);
|
||||
}
|
||||
|
||||
private sealed class SequencedHttpMessageHandler : HttpMessageHandler
|
||||
|
||||
@@ -117,7 +117,7 @@ public sealed class RecordingCoordinatorTests
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task TranscriptLineWorkflowRuleTransformsLiveTranscriptBeforePersistingMarkdown()
|
||||
public async Task TranscriptLineWorkflowRuleTransformsLiveTranscriptAfterDurableAppend()
|
||||
{
|
||||
var root = Path.Combine(Path.GetTempPath(), "meeting-assistant-tests", Guid.NewGuid().ToString("N"));
|
||||
var rulesPath = Path.Combine(root, "rules.yaml");
|
||||
|
||||
@@ -18,7 +18,7 @@ public sealed class TaskbarIconTests
|
||||
Assert.Equal(RecordingProcessState.Idle, menu.State);
|
||||
Assert.Contains(menu.Items, item =>
|
||||
item.Action == MeetingTaskbarAction.EditRules &&
|
||||
item.Text == "Settings and logs");
|
||||
item.Text == "Open agent");
|
||||
Assert.Contains(menu.Items, item =>
|
||||
item.Action == MeetingTaskbarAction.StartRecording &&
|
||||
item.ProfileName == "default" &&
|
||||
|
||||
@@ -857,15 +857,13 @@ public sealed class WorkflowRulesEditorTests
|
||||
Assert.Equal("", viewModel.Draft);
|
||||
Assert.Equal(
|
||||
[new WorkflowRulesEditorChatMessage(WorkflowRulesEditorChatRole.User, "Rename ABS Daily")],
|
||||
viewModel.Messages.ToArray());
|
||||
viewModel.Messages.Select(item => item.Message).OfType<WorkflowRulesEditorChatMessage>().ToArray());
|
||||
|
||||
pipeline.Release.SetResult();
|
||||
await sendTask.WaitAsync(TimeSpan.FromSeconds(2));
|
||||
|
||||
Assert.False(viewModel.IsThinking);
|
||||
Assert.Equal(2, viewModel.Messages.Count);
|
||||
Assert.Equal(WorkflowRulesEditorChatRole.Agent, viewModel.Messages[1].Role);
|
||||
Assert.Equal("Updated rules.", viewModel.Messages[1].Content);
|
||||
AssertCompletedActivity(viewModel, ["Thinking..."], "Updated rules.");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
@@ -881,20 +879,19 @@ public sealed class WorkflowRulesEditorTests
|
||||
|
||||
Assert.False(viewModel.IsThinking);
|
||||
Assert.Equal("", viewModel.Draft);
|
||||
Assert.Equal(
|
||||
[
|
||||
new WorkflowRulesEditorChatMessage(WorkflowRulesEditorChatRole.User, "check logs"),
|
||||
new WorkflowRulesEditorChatMessage(
|
||||
WorkflowRulesEditorChatRole.Agent,
|
||||
"Settings and logs failed: The request timed out.")
|
||||
],
|
||||
viewModel.Messages.ToArray());
|
||||
Assert.Equal(new WorkflowRulesEditorChatMessage(WorkflowRulesEditorChatRole.User, "check logs"), viewModel.Messages[0].Message);
|
||||
AssertCompletedActivity(
|
||||
viewModel,
|
||||
["Thinking..."],
|
||||
"Meeting Summary Agent failed: The request timed out.");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task ViewModelShowsReconnectStatusReportedByPipeline()
|
||||
{
|
||||
var pipeline = new StatusReportingRulesEditorPipeline("Updated rules.");
|
||||
var pipeline = new ReportingRulesEditorPipeline(
|
||||
"Updated rules.",
|
||||
WorkflowRulesEditorActivityUpdate.Status("Reconnecting..."));
|
||||
var viewModel = new WorkflowRulesEditorChatViewModel(pipeline)
|
||||
{
|
||||
Draft = "check logs"
|
||||
@@ -911,7 +908,146 @@ public sealed class WorkflowRulesEditorTests
|
||||
|
||||
Assert.False(viewModel.IsThinking);
|
||||
Assert.Equal("Thinking...", viewModel.ActivityMessage);
|
||||
Assert.Equal("Updated rules.", viewModel.Messages[1].Content);
|
||||
AssertCompletedActivity(viewModel, ["Thinking...", "Reconnecting..."], "Updated rules.");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task ViewModelShowsToolCallsAbovePersistentThinkingLine()
|
||||
{
|
||||
var pipeline = new ReportingRulesEditorPipeline(
|
||||
"Updated rules.",
|
||||
WorkflowRulesEditorActivityUpdate.ToolCall("read_logs"));
|
||||
var viewModel = new WorkflowRulesEditorChatViewModel(pipeline)
|
||||
{
|
||||
Draft = "check logs"
|
||||
};
|
||||
|
||||
var sendTask = viewModel.SendAsync();
|
||||
await pipeline.Reported.Task.WaitAsync(TimeSpan.FromSeconds(2));
|
||||
|
||||
Assert.True(viewModel.IsThinking);
|
||||
Assert.Equal("Thinking...", viewModel.ActivityMessage);
|
||||
Assert.Equal(["Called tool: read_logs"], viewModel.ActivityMessages.ToArray());
|
||||
|
||||
pipeline.Release.SetResult();
|
||||
await sendTask.WaitAsync(TimeSpan.FromSeconds(2));
|
||||
|
||||
Assert.False(viewModel.IsThinking);
|
||||
Assert.Empty(viewModel.ActivityMessages);
|
||||
AssertCompletedActivity(viewModel, ["Thinking...", "Called tool: read_logs"], "Updated rules.");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task ViewModelShowsVisibleThinkingOutputInActivityExpander()
|
||||
{
|
||||
var pipeline = new ReportingRulesEditorPipeline(
|
||||
"Updated rules.",
|
||||
WorkflowRulesEditorActivityUpdate.Thinking("Checked the recent logs."),
|
||||
WorkflowRulesEditorActivityUpdate.ToolCall("read_logs"),
|
||||
WorkflowRulesEditorActivityUpdate.Thinking("Matched the rule to the user request."));
|
||||
var viewModel = new WorkflowRulesEditorChatViewModel(pipeline)
|
||||
{
|
||||
Draft = "check logs"
|
||||
};
|
||||
|
||||
var sendTask = viewModel.SendAsync();
|
||||
await pipeline.Reported.Task.WaitAsync(TimeSpan.FromSeconds(2));
|
||||
|
||||
Assert.True(viewModel.IsThinking);
|
||||
Assert.Equal("Thinking...", viewModel.ActivityMessage);
|
||||
Assert.Equal(
|
||||
[
|
||||
"Checked the recent logs.",
|
||||
"Called tool: read_logs",
|
||||
"Matched the rule to the user request."
|
||||
],
|
||||
viewModel.ActivityMessages.ToArray());
|
||||
|
||||
pipeline.Release.SetResult();
|
||||
await sendTask.WaitAsync(TimeSpan.FromSeconds(2));
|
||||
|
||||
Assert.False(viewModel.IsThinking);
|
||||
Assert.Empty(viewModel.ActivityMessages);
|
||||
AssertCompletedActivity(
|
||||
viewModel,
|
||||
[
|
||||
"Checked the recent logs.",
|
||||
"Called tool: read_logs",
|
||||
"Matched the rule to the user request."
|
||||
],
|
||||
"Updated rules.");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task ViewModelMarshalsBackgroundActivityUpdatesToCapturedContext()
|
||||
{
|
||||
var previousContext = SynchronizationContext.Current;
|
||||
var context = new RecordingSynchronizationContext();
|
||||
var pipeline = new BackgroundReportingRulesEditorPipeline(
|
||||
"Updated rules.",
|
||||
WorkflowRulesEditorActivityUpdate.Thinking("Checked the recent logs."));
|
||||
var viewModel = new WorkflowRulesEditorChatViewModel(pipeline)
|
||||
{
|
||||
Draft = "check logs"
|
||||
};
|
||||
|
||||
SynchronizationContext.SetSynchronizationContext(context);
|
||||
var sendTask = viewModel.SendAsync();
|
||||
SynchronizationContext.SetSynchronizationContext(previousContext);
|
||||
await pipeline.Reported.Task.WaitAsync(TimeSpan.FromSeconds(2));
|
||||
|
||||
Assert.True(viewModel.IsThinking);
|
||||
Assert.Equal(["Checked the recent logs."], viewModel.ActivityMessages.ToArray());
|
||||
Assert.Equal(1, context.SendCount);
|
||||
|
||||
pipeline.Release.SetResult();
|
||||
await sendTask.WaitAsync(TimeSpan.FromSeconds(2));
|
||||
|
||||
AssertCompletedActivity(viewModel, ["Checked the recent logs."], "Updated rules.");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task ViewModelKeepsActivityItemsOutOfModelConversation()
|
||||
{
|
||||
var pipeline = new BlockingRulesEditorPipeline("Updated rules.");
|
||||
var viewModel = new WorkflowRulesEditorChatViewModel(pipeline)
|
||||
{
|
||||
Draft = "first"
|
||||
};
|
||||
|
||||
var firstSend = viewModel.SendAsync();
|
||||
await pipeline.Started.Task.WaitAsync(TimeSpan.FromSeconds(2));
|
||||
pipeline.Release.SetResult();
|
||||
await firstSend.WaitAsync(TimeSpan.FromSeconds(2));
|
||||
|
||||
pipeline.Reset("Second response.");
|
||||
viewModel.Draft = "second";
|
||||
var secondSend = viewModel.SendAsync();
|
||||
await pipeline.Started.Task.WaitAsync(TimeSpan.FromSeconds(2));
|
||||
|
||||
Assert.Equal(
|
||||
[
|
||||
new WorkflowRulesEditorChatMessage(WorkflowRulesEditorChatRole.User, "first"),
|
||||
new WorkflowRulesEditorChatMessage(WorkflowRulesEditorChatRole.Agent, "Updated rules.")
|
||||
],
|
||||
pipeline.LastConversation);
|
||||
|
||||
pipeline.Release.SetResult();
|
||||
await secondSend.WaitAsync(TimeSpan.FromSeconds(2));
|
||||
}
|
||||
|
||||
private static void AssertCompletedActivity(
|
||||
WorkflowRulesEditorChatViewModel viewModel,
|
||||
IReadOnlyList<string> expectedActivityLines,
|
||||
string expectedAgentMessage)
|
||||
{
|
||||
Assert.Equal(3, viewModel.Messages.Count);
|
||||
Assert.Equal(WorkflowRulesEditorConversationItemKind.Activity, viewModel.Messages[1].Kind);
|
||||
Assert.StartsWith("Worked for ", viewModel.Messages[1].Content);
|
||||
Assert.Equal(expectedActivityLines, viewModel.Messages[1].ActivityLines);
|
||||
Assert.Equal(WorkflowRulesEditorConversationItemKind.Message, viewModel.Messages[2].Kind);
|
||||
Assert.Equal(WorkflowRulesEditorChatRole.Agent, viewModel.Messages[2].Message?.Role);
|
||||
Assert.Equal(expectedAgentMessage, viewModel.Messages[2].Message?.Content);
|
||||
}
|
||||
|
||||
[Theory]
|
||||
@@ -1196,31 +1332,36 @@ public sealed class WorkflowRulesEditorTests
|
||||
|
||||
private sealed class BlockingRulesEditorPipeline : IWorkflowRulesEditorChatPipeline
|
||||
{
|
||||
private readonly string response;
|
||||
private string response;
|
||||
|
||||
public BlockingRulesEditorPipeline(string response)
|
||||
{
|
||||
this.response = response;
|
||||
}
|
||||
|
||||
public TaskCompletionSource Started { get; } = new(TaskCreationOptions.RunContinuationsAsynchronously);
|
||||
public TaskCompletionSource Started { get; private set; } = new(TaskCreationOptions.RunContinuationsAsynchronously);
|
||||
|
||||
public TaskCompletionSource Release { get; } = new(TaskCreationOptions.RunContinuationsAsynchronously);
|
||||
public TaskCompletionSource Release { get; private set; } = new(TaskCreationOptions.RunContinuationsAsynchronously);
|
||||
|
||||
public IReadOnlyList<WorkflowRulesEditorChatMessage> LastConversation { get; private set; } = [];
|
||||
|
||||
public void Reset(string nextResponse)
|
||||
{
|
||||
response = nextResponse;
|
||||
Started = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously);
|
||||
Release = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously);
|
||||
}
|
||||
|
||||
public async Task<WorkflowRulesEditorChatResult> SendAsync(
|
||||
IReadOnlyList<WorkflowRulesEditorChatMessage> conversation,
|
||||
string userMessage,
|
||||
CancellationToken cancellationToken,
|
||||
Action<string>? statusChanged = null)
|
||||
Action<WorkflowRulesEditorActivityUpdate>? activityChanged = null)
|
||||
{
|
||||
LastConversation = conversation;
|
||||
Started.SetResult();
|
||||
await Release.Task.WaitAsync(cancellationToken);
|
||||
return new WorkflowRulesEditorChatResult(
|
||||
response,
|
||||
conversation
|
||||
.Append(new WorkflowRulesEditorChatMessage(WorkflowRulesEditorChatRole.User, userMessage))
|
||||
.Append(new WorkflowRulesEditorChatMessage(WorkflowRulesEditorChatRole.Agent, response))
|
||||
.ToList());
|
||||
return new WorkflowRulesEditorChatResult(response);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1237,19 +1378,23 @@ public sealed class WorkflowRulesEditorTests
|
||||
IReadOnlyList<WorkflowRulesEditorChatMessage> conversation,
|
||||
string userMessage,
|
||||
CancellationToken cancellationToken,
|
||||
Action<string>? statusChanged = null)
|
||||
Action<WorkflowRulesEditorActivityUpdate>? activityChanged = null)
|
||||
{
|
||||
throw exception;
|
||||
}
|
||||
}
|
||||
|
||||
private sealed class StatusReportingRulesEditorPipeline : IWorkflowRulesEditorChatPipeline
|
||||
private sealed class ReportingRulesEditorPipeline : IWorkflowRulesEditorChatPipeline
|
||||
{
|
||||
private readonly string response;
|
||||
private readonly IReadOnlyList<WorkflowRulesEditorActivityUpdate> updates;
|
||||
|
||||
public StatusReportingRulesEditorPipeline(string response)
|
||||
public ReportingRulesEditorPipeline(
|
||||
string response,
|
||||
params WorkflowRulesEditorActivityUpdate[] updates)
|
||||
{
|
||||
this.response = response;
|
||||
this.updates = updates;
|
||||
}
|
||||
|
||||
public TaskCompletionSource Reported { get; } = new(TaskCreationOptions.RunContinuationsAsynchronously);
|
||||
@@ -1260,17 +1405,57 @@ public sealed class WorkflowRulesEditorTests
|
||||
IReadOnlyList<WorkflowRulesEditorChatMessage> conversation,
|
||||
string userMessage,
|
||||
CancellationToken cancellationToken,
|
||||
Action<string>? statusChanged = null)
|
||||
Action<WorkflowRulesEditorActivityUpdate>? activityChanged = null)
|
||||
{
|
||||
statusChanged?.Invoke("Reconnecting...");
|
||||
foreach (var update in updates)
|
||||
{
|
||||
activityChanged?.Invoke(update);
|
||||
}
|
||||
|
||||
Reported.SetResult();
|
||||
await Release.Task.WaitAsync(cancellationToken);
|
||||
return new WorkflowRulesEditorChatResult(
|
||||
response,
|
||||
conversation
|
||||
.Append(new WorkflowRulesEditorChatMessage(WorkflowRulesEditorChatRole.User, userMessage))
|
||||
.Append(new WorkflowRulesEditorChatMessage(WorkflowRulesEditorChatRole.Agent, response))
|
||||
.ToList());
|
||||
return new WorkflowRulesEditorChatResult(response);
|
||||
}
|
||||
}
|
||||
|
||||
private sealed class BackgroundReportingRulesEditorPipeline : IWorkflowRulesEditorChatPipeline
|
||||
{
|
||||
private readonly string response;
|
||||
private readonly WorkflowRulesEditorActivityUpdate update;
|
||||
|
||||
public BackgroundReportingRulesEditorPipeline(
|
||||
string response,
|
||||
WorkflowRulesEditorActivityUpdate update)
|
||||
{
|
||||
this.response = response;
|
||||
this.update = update;
|
||||
}
|
||||
|
||||
public TaskCompletionSource Reported { get; } = new(TaskCreationOptions.RunContinuationsAsynchronously);
|
||||
|
||||
public TaskCompletionSource Release { get; } = new(TaskCreationOptions.RunContinuationsAsynchronously);
|
||||
|
||||
public async Task<WorkflowRulesEditorChatResult> SendAsync(
|
||||
IReadOnlyList<WorkflowRulesEditorChatMessage> conversation,
|
||||
string userMessage,
|
||||
CancellationToken cancellationToken,
|
||||
Action<WorkflowRulesEditorActivityUpdate>? activityChanged = null)
|
||||
{
|
||||
await Task.Run(() => activityChanged?.Invoke(update), cancellationToken);
|
||||
Reported.SetResult();
|
||||
await Release.Task.WaitAsync(cancellationToken);
|
||||
return new WorkflowRulesEditorChatResult(response);
|
||||
}
|
||||
}
|
||||
|
||||
private sealed class RecordingSynchronizationContext : SynchronizationContext
|
||||
{
|
||||
public int SendCount { get; private set; }
|
||||
|
||||
public override void Send(SendOrPostCallback callback, object? state)
|
||||
{
|
||||
SendCount++;
|
||||
callback(state);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -22,6 +22,7 @@ public sealed class LiteLlmResponsesChatClient : IChatClient
|
||||
private readonly LiteLlmResponsesCompactionOptions? compactionOptions;
|
||||
private readonly ILogger? logger;
|
||||
private readonly Action? retrying;
|
||||
private readonly Action<string>? reasoningSummaryChanged;
|
||||
private int responsesRequestCount;
|
||||
|
||||
public LiteLlmResponsesChatClient(
|
||||
@@ -35,7 +36,8 @@ public sealed class LiteLlmResponsesChatClient : IChatClient
|
||||
LiteLlmResponsesCompactionOptions? compactionOptions = null,
|
||||
ILogger? logger = null,
|
||||
bool firstRequestIsUser = true,
|
||||
Action? retrying = null)
|
||||
Action? retrying = null,
|
||||
Action<string>? reasoningSummaryChanged = null)
|
||||
: this(
|
||||
new HttpClient { BaseAddress = NormalizeEndpoint(endpoint) },
|
||||
apiKey,
|
||||
@@ -47,7 +49,8 @@ public sealed class LiteLlmResponsesChatClient : IChatClient
|
||||
compactionOptions,
|
||||
logger,
|
||||
firstRequestIsUser,
|
||||
retrying)
|
||||
retrying,
|
||||
reasoningSummaryChanged)
|
||||
{
|
||||
}
|
||||
|
||||
@@ -62,7 +65,8 @@ public sealed class LiteLlmResponsesChatClient : IChatClient
|
||||
LiteLlmResponsesCompactionOptions? compactionOptions = null,
|
||||
ILogger? logger = null,
|
||||
bool firstRequestIsUser = true,
|
||||
Action? retrying = null)
|
||||
Action? retrying = null,
|
||||
Action<string>? reasoningSummaryChanged = null)
|
||||
{
|
||||
this.httpClient = httpClient;
|
||||
this.httpClient.DefaultRequestHeaders.Authorization = new AuthenticationHeaderValue("Bearer", apiKey);
|
||||
@@ -74,6 +78,7 @@ public sealed class LiteLlmResponsesChatClient : IChatClient
|
||||
this.compactionOptions = compactionOptions;
|
||||
this.logger = logger;
|
||||
this.retrying = retrying;
|
||||
this.reasoningSummaryChanged = reasoningSummaryChanged;
|
||||
responsesRequestCount = firstRequestIsUser ? 0 : 1;
|
||||
}
|
||||
|
||||
@@ -90,7 +95,8 @@ public sealed class LiteLlmResponsesChatClient : IChatClient
|
||||
var payload = await CreateCompactedPayloadAsync(messages.ToList(), options, cancellationToken).ConfigureAwait(false);
|
||||
var responseJson = await PostWithRetryAsync(payload, cancellationToken).ConfigureAwait(false);
|
||||
|
||||
var response = ParseResponseJson(responseJson);
|
||||
var response = ParseResponseJson(responseJson, out var reasoningSummaries);
|
||||
ReportVisibleReasoningSummaries(reasoningSummaries);
|
||||
LogResponseDiagnostics(responseJson, response);
|
||||
ThrowIfResponseHasNoContent(responseJson, response);
|
||||
LogResponseUsage(response.Usage);
|
||||
@@ -122,10 +128,18 @@ public sealed class LiteLlmResponsesChatClient : IChatClient
|
||||
}
|
||||
|
||||
public static ChatResponse ParseResponseJson(string responseJson)
|
||||
{
|
||||
return ParseResponseJson(responseJson, out _);
|
||||
}
|
||||
|
||||
private static ChatResponse ParseResponseJson(
|
||||
string responseJson,
|
||||
out IReadOnlyList<string> reasoningSummaries)
|
||||
{
|
||||
using var document = JsonDocument.Parse(responseJson);
|
||||
var root = document.RootElement;
|
||||
var contents = new List<AIContent>();
|
||||
reasoningSummaries = ExtractVisibleReasoningSummaries(root);
|
||||
|
||||
if (root.TryGetProperty("output", out var output) && output.ValueKind == JsonValueKind.Array)
|
||||
{
|
||||
@@ -162,6 +176,54 @@ public sealed class LiteLlmResponsesChatClient : IChatClient
|
||||
};
|
||||
}
|
||||
|
||||
internal static IReadOnlyList<string> ExtractVisibleReasoningSummaries(string responseJson)
|
||||
{
|
||||
using var document = JsonDocument.Parse(responseJson);
|
||||
return ExtractVisibleReasoningSummaries(document.RootElement);
|
||||
}
|
||||
|
||||
private static IReadOnlyList<string> ExtractVisibleReasoningSummaries(JsonElement root)
|
||||
{
|
||||
var summaries = new List<string>();
|
||||
|
||||
if (!root.TryGetProperty("output", out var output) || output.ValueKind != JsonValueKind.Array)
|
||||
{
|
||||
return summaries;
|
||||
}
|
||||
|
||||
foreach (var item in output.EnumerateArray())
|
||||
{
|
||||
if (GetString(item, "type") == "reasoning")
|
||||
{
|
||||
AddVisibleReasoningSummaryText(summaries, item);
|
||||
}
|
||||
}
|
||||
|
||||
return summaries;
|
||||
}
|
||||
|
||||
private void ReportVisibleReasoningSummaries(IReadOnlyList<string> summaries)
|
||||
{
|
||||
if (reasoningSummaryChanged is null)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
foreach (var summary in summaries)
|
||||
{
|
||||
try
|
||||
{
|
||||
reasoningSummaryChanged(summary);
|
||||
}
|
||||
catch (Exception exception)
|
||||
{
|
||||
logger?.LogWarning(
|
||||
exception,
|
||||
"Reasoning summary callback failed; continuing with the LiteLLM response.");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private async Task<JsonObject> CreateCompactedPayloadAsync(
|
||||
IReadOnlyList<ChatMessage> messages,
|
||||
ChatOptions? options,
|
||||
@@ -660,6 +722,66 @@ public sealed class LiteLlmResponsesChatClient : IChatClient
|
||||
}
|
||||
}
|
||||
|
||||
private static void AddVisibleReasoningSummaryText(List<string> summaries, JsonElement item)
|
||||
{
|
||||
AddVisibleReasoningSummaryText(summaries, item, "summary");
|
||||
AddVisibleReasoningSummaryText(summaries, item, "content");
|
||||
}
|
||||
|
||||
private static void AddVisibleReasoningSummaryText(
|
||||
List<string> summaries,
|
||||
JsonElement item,
|
||||
string propertyName)
|
||||
{
|
||||
if (!item.TryGetProperty(propertyName, out var property))
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
if (property.ValueKind == JsonValueKind.String)
|
||||
{
|
||||
AddNonEmpty(summaries, property.GetString());
|
||||
return;
|
||||
}
|
||||
|
||||
if (property.ValueKind != JsonValueKind.Array)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
foreach (var summaryItem in property.EnumerateArray())
|
||||
{
|
||||
if (summaryItem.ValueKind == JsonValueKind.String)
|
||||
{
|
||||
AddNonEmpty(summaries, summaryItem.GetString());
|
||||
}
|
||||
else if (summaryItem.ValueKind == JsonValueKind.Object)
|
||||
{
|
||||
if (propertyName == "content" && !IsSummaryContent(summaryItem))
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
AddNonEmpty(summaries, GetString(summaryItem, "text"));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private static bool IsSummaryContent(JsonElement item)
|
||||
{
|
||||
var type = GetString(item, "type");
|
||||
return !string.IsNullOrWhiteSpace(type)
|
||||
&& type.Contains("summary", StringComparison.OrdinalIgnoreCase);
|
||||
}
|
||||
|
||||
private static void AddNonEmpty(List<string> values, string? value)
|
||||
{
|
||||
if (!string.IsNullOrWhiteSpace(value))
|
||||
{
|
||||
values.Add(value.Trim());
|
||||
}
|
||||
}
|
||||
|
||||
private static Dictionary<string, object?> ParseArguments(string? arguments)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(arguments))
|
||||
|
||||
@@ -38,7 +38,7 @@ public static class MeetingTaskbarMenuBuilder
|
||||
{
|
||||
var items = new List<MeetingTaskbarMenuItem>
|
||||
{
|
||||
new("Settings and logs", MeetingTaskbarAction.EditRules)
|
||||
new("Open agent", MeetingTaskbarAction.EditRules)
|
||||
};
|
||||
|
||||
if (microphones is { Count: > 0 })
|
||||
|
||||
@@ -10,9 +10,66 @@ public sealed record WorkflowRulesEditorChatMessage(
|
||||
WorkflowRulesEditorChatRole Role,
|
||||
string Content);
|
||||
|
||||
public sealed record WorkflowRulesEditorChatResult(
|
||||
string Response,
|
||||
IReadOnlyList<WorkflowRulesEditorChatMessage> Conversation);
|
||||
public enum WorkflowRulesEditorConversationItemKind
|
||||
{
|
||||
Message,
|
||||
Activity
|
||||
}
|
||||
|
||||
public sealed record WorkflowRulesEditorConversationItem(
|
||||
WorkflowRulesEditorConversationItemKind Kind,
|
||||
WorkflowRulesEditorChatMessage? Message,
|
||||
string Content,
|
||||
IReadOnlyList<string>? ActivityLines = null)
|
||||
{
|
||||
public bool IsExpanded { get; set; }
|
||||
|
||||
public static WorkflowRulesEditorConversationItem ChatMessage(WorkflowRulesEditorChatMessage message)
|
||||
{
|
||||
return new WorkflowRulesEditorConversationItem(
|
||||
WorkflowRulesEditorConversationItemKind.Message,
|
||||
message,
|
||||
message.Content);
|
||||
}
|
||||
|
||||
public static WorkflowRulesEditorConversationItem Activity(string content, IReadOnlyList<string> activityLines)
|
||||
{
|
||||
return new WorkflowRulesEditorConversationItem(
|
||||
WorkflowRulesEditorConversationItemKind.Activity,
|
||||
null,
|
||||
content,
|
||||
activityLines);
|
||||
}
|
||||
}
|
||||
|
||||
public sealed record WorkflowRulesEditorChatResult(string Response);
|
||||
|
||||
public enum WorkflowRulesEditorActivityKind
|
||||
{
|
||||
Status,
|
||||
ToolCall,
|
||||
Thinking
|
||||
}
|
||||
|
||||
public sealed record WorkflowRulesEditorActivityUpdate(
|
||||
WorkflowRulesEditorActivityKind Kind,
|
||||
string Text)
|
||||
{
|
||||
public static WorkflowRulesEditorActivityUpdate Status(string text)
|
||||
{
|
||||
return new WorkflowRulesEditorActivityUpdate(WorkflowRulesEditorActivityKind.Status, text);
|
||||
}
|
||||
|
||||
public static WorkflowRulesEditorActivityUpdate ToolCall(string toolName)
|
||||
{
|
||||
return new WorkflowRulesEditorActivityUpdate(WorkflowRulesEditorActivityKind.ToolCall, toolName);
|
||||
}
|
||||
|
||||
public static WorkflowRulesEditorActivityUpdate Thinking(string text)
|
||||
{
|
||||
return new WorkflowRulesEditorActivityUpdate(WorkflowRulesEditorActivityKind.Thinking, text);
|
||||
}
|
||||
}
|
||||
|
||||
public interface IWorkflowRulesEditorChatPipeline
|
||||
{
|
||||
@@ -20,5 +77,5 @@ public interface IWorkflowRulesEditorChatPipeline
|
||||
IReadOnlyList<WorkflowRulesEditorChatMessage> conversation,
|
||||
string userMessage,
|
||||
CancellationToken cancellationToken,
|
||||
Action<string>? statusChanged = null);
|
||||
Action<WorkflowRulesEditorActivityUpdate>? activityChanged = null);
|
||||
}
|
||||
|
||||
@@ -59,11 +59,11 @@ public sealed class WorkflowRulesEditorChatPipeline : IWorkflowRulesEditorChatPi
|
||||
IReadOnlyList<WorkflowRulesEditorChatMessage> conversation,
|
||||
string userMessage,
|
||||
CancellationToken cancellationToken,
|
||||
Action<string>? statusChanged = null)
|
||||
Action<WorkflowRulesEditorActivityUpdate>? activityChanged = null)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(userMessage))
|
||||
{
|
||||
return new WorkflowRulesEditorChatResult("", conversation);
|
||||
return new WorkflowRulesEditorChatResult("");
|
||||
}
|
||||
|
||||
var agentOptions = options.WorkflowRulesEditor.ToEffectiveAgentOptions(options.Agent);
|
||||
@@ -108,10 +108,18 @@ public sealed class WorkflowRulesEditorChatPipeline : IWorkflowRulesEditorChatPi
|
||||
compactionOptions,
|
||||
logger,
|
||||
firstRequestIsUser: true,
|
||||
retrying: () => statusChanged?.Invoke("Reconnecting..."));
|
||||
retrying: () => activityChanged?.Invoke(WorkflowRulesEditorActivityUpdate.Status("Reconnecting...")),
|
||||
reasoningSummaryChanged: text => activityChanged?.Invoke(WorkflowRulesEditorActivityUpdate.Thinking(text)));
|
||||
var functionClient = chatClient
|
||||
.AsBuilder()
|
||||
.UseFunctionInvocation(loggerFactory)
|
||||
.UseFunctionInvocation(loggerFactory, client =>
|
||||
{
|
||||
client.FunctionInvoker = async (context, token) =>
|
||||
{
|
||||
activityChanged?.Invoke(WorkflowRulesEditorActivityUpdate.ToolCall(context.Function.Name));
|
||||
return await context.Function.InvokeAsync(context.Arguments, token);
|
||||
};
|
||||
})
|
||||
.Build();
|
||||
|
||||
var response = await functionClient.GetResponseAsync(
|
||||
@@ -121,11 +129,7 @@ public sealed class WorkflowRulesEditorChatPipeline : IWorkflowRulesEditorChatPi
|
||||
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);
|
||||
return new WorkflowRulesEditorChatResult(responseText);
|
||||
}
|
||||
|
||||
private static ChatMessage ToChatMessage(WorkflowRulesEditorChatMessage message)
|
||||
|
||||
@@ -1,9 +1,11 @@
|
||||
using System.Collections.ObjectModel;
|
||||
using System.Diagnostics;
|
||||
|
||||
namespace MeetingAssistant.Workflow;
|
||||
|
||||
public sealed class WorkflowRulesEditorChatViewModel
|
||||
{
|
||||
private const string ThinkingPlaceholder = "Thinking...";
|
||||
private readonly IWorkflowRulesEditorChatPipeline pipeline;
|
||||
|
||||
public WorkflowRulesEditorChatViewModel(IWorkflowRulesEditorChatPipeline pipeline)
|
||||
@@ -11,13 +13,15 @@ public sealed class WorkflowRulesEditorChatViewModel
|
||||
this.pipeline = pipeline;
|
||||
}
|
||||
|
||||
public ObservableCollection<WorkflowRulesEditorChatMessage> Messages { get; } = [];
|
||||
public ObservableCollection<WorkflowRulesEditorConversationItem> Messages { get; } = [];
|
||||
|
||||
public ObservableCollection<string> ActivityMessages { get; } = [];
|
||||
|
||||
public string Draft { get; set; } = "";
|
||||
|
||||
public bool IsThinking { get; private set; }
|
||||
|
||||
public string ActivityMessage { get; private set; } = "Thinking...";
|
||||
public string ActivityMessage { get; private set; } = ThinkingPlaceholder;
|
||||
|
||||
public event EventHandler? Changed;
|
||||
|
||||
@@ -30,10 +34,19 @@ public sealed class WorkflowRulesEditorChatViewModel
|
||||
}
|
||||
|
||||
Draft = "";
|
||||
var priorConversation = Messages.ToList();
|
||||
Messages.Add(new WorkflowRulesEditorChatMessage(WorkflowRulesEditorChatRole.User, prompt));
|
||||
var startedAt = Stopwatch.GetTimestamp();
|
||||
var activityLines = new List<string>();
|
||||
var hasVisibleThinking = false;
|
||||
var activityContext = SynchronizationContext.Current;
|
||||
var priorConversation = Messages
|
||||
.Select(item => item.Message)
|
||||
.OfType<WorkflowRulesEditorChatMessage>()
|
||||
.ToList();
|
||||
Messages.Add(WorkflowRulesEditorConversationItem.ChatMessage(
|
||||
new WorkflowRulesEditorChatMessage(WorkflowRulesEditorChatRole.User, prompt)));
|
||||
ActivityMessages.Clear();
|
||||
IsThinking = true;
|
||||
ActivityMessage = "Thinking...";
|
||||
ActivityMessage = ThinkingPlaceholder;
|
||||
OnChanged();
|
||||
|
||||
try
|
||||
@@ -42,36 +55,121 @@ public sealed class WorkflowRulesEditorChatViewModel
|
||||
priorConversation,
|
||||
prompt,
|
||||
cancellationToken,
|
||||
SetActivityMessage);
|
||||
Messages.Clear();
|
||||
foreach (var message in result.Conversation)
|
||||
{
|
||||
Messages.Add(message);
|
||||
}
|
||||
update => hasVisibleThinking |= ApplyActivityUpdate(activityContext, update, activityLines));
|
||||
AddCompletedActivity(startedAt, activityLines, hasVisibleThinking);
|
||||
Messages.Add(WorkflowRulesEditorConversationItem.ChatMessage(
|
||||
new WorkflowRulesEditorChatMessage(
|
||||
WorkflowRulesEditorChatRole.Agent,
|
||||
string.IsNullOrWhiteSpace(result.Response)
|
||||
? "(No response text returned.)"
|
||||
: result.Response.Trim())));
|
||||
}
|
||||
catch (Exception exception) when (exception is not OperationCanceledException || !cancellationToken.IsCancellationRequested)
|
||||
{
|
||||
Messages.Add(new WorkflowRulesEditorChatMessage(
|
||||
WorkflowRulesEditorChatRole.Agent,
|
||||
$"Settings and logs failed: {exception.Message}"));
|
||||
AddCompletedActivity(startedAt, activityLines, hasVisibleThinking);
|
||||
Messages.Add(WorkflowRulesEditorConversationItem.ChatMessage(
|
||||
new WorkflowRulesEditorChatMessage(
|
||||
WorkflowRulesEditorChatRole.Agent,
|
||||
$"Meeting Summary Agent failed: {exception.Message}")));
|
||||
}
|
||||
finally
|
||||
{
|
||||
IsThinking = false;
|
||||
ActivityMessage = "Thinking...";
|
||||
ActivityMessages.Clear();
|
||||
ActivityMessage = ThinkingPlaceholder;
|
||||
OnChanged();
|
||||
}
|
||||
}
|
||||
|
||||
private void SetActivityMessage(string message)
|
||||
private bool ApplyActivityUpdate(
|
||||
SynchronizationContext? context,
|
||||
WorkflowRulesEditorActivityUpdate update,
|
||||
List<string> activityLines)
|
||||
{
|
||||
if (!IsThinking || string.IsNullOrWhiteSpace(message))
|
||||
if (context is null || SynchronizationContext.Current == context)
|
||||
{
|
||||
return;
|
||||
return ApplyActivityUpdate(update, activityLines);
|
||||
}
|
||||
|
||||
var hasVisibleThinking = false;
|
||||
Exception? exception = null;
|
||||
context.Send(
|
||||
_ =>
|
||||
{
|
||||
try
|
||||
{
|
||||
hasVisibleThinking = ApplyActivityUpdate(update, activityLines);
|
||||
}
|
||||
catch (Exception caught)
|
||||
{
|
||||
exception = caught;
|
||||
}
|
||||
},
|
||||
null);
|
||||
|
||||
if (exception is not null)
|
||||
{
|
||||
throw exception;
|
||||
}
|
||||
|
||||
return hasVisibleThinking;
|
||||
}
|
||||
|
||||
private bool ApplyActivityUpdate(
|
||||
WorkflowRulesEditorActivityUpdate update,
|
||||
List<string> activityLines)
|
||||
{
|
||||
if (!IsThinking || string.IsNullOrWhiteSpace(update.Text))
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
var hasVisibleThinking = false;
|
||||
if (update.Kind == WorkflowRulesEditorActivityKind.ToolCall)
|
||||
{
|
||||
var line = $"Called tool: {update.Text.Trim()}";
|
||||
ActivityMessages.Add(line);
|
||||
activityLines.Add(line);
|
||||
}
|
||||
else if (update.Kind == WorkflowRulesEditorActivityKind.Thinking)
|
||||
{
|
||||
var line = update.Text.Trim();
|
||||
ActivityMessages.Add(line);
|
||||
activityLines.Add(line);
|
||||
hasVisibleThinking = true;
|
||||
}
|
||||
else
|
||||
{
|
||||
var line = update.Text.Trim();
|
||||
ActivityMessage = line;
|
||||
activityLines.Add(line);
|
||||
}
|
||||
|
||||
ActivityMessage = message;
|
||||
OnChanged();
|
||||
return hasVisibleThinking;
|
||||
}
|
||||
|
||||
private void AddCompletedActivity(
|
||||
long startedAt,
|
||||
IReadOnlyList<string> activityLines,
|
||||
bool hasVisibleThinking)
|
||||
{
|
||||
var completedActivityLines = hasVisibleThinking
|
||||
? activityLines.ToArray()
|
||||
: new[] { ThinkingPlaceholder }.Concat(activityLines).ToArray();
|
||||
Messages.Add(WorkflowRulesEditorConversationItem.Activity(
|
||||
$"Worked for {FormatDuration(Stopwatch.GetElapsedTime(startedAt))}",
|
||||
completedActivityLines));
|
||||
}
|
||||
|
||||
private static string FormatDuration(TimeSpan duration)
|
||||
{
|
||||
if (duration.TotalMinutes >= 1)
|
||||
{
|
||||
return $"{(int)duration.TotalMinutes}m {duration.Seconds}s";
|
||||
}
|
||||
|
||||
return $"{Math.Max(0, (int)Math.Round(duration.TotalSeconds))}s";
|
||||
}
|
||||
|
||||
private void OnChanged()
|
||||
|
||||
@@ -11,7 +11,7 @@ namespace MeetingAssistant.Workflow;
|
||||
|
||||
internal sealed class WpfWorkflowRulesEditorWindowService : IWorkflowRulesEditorWindowService
|
||||
{
|
||||
internal const string WindowTitle = "Settings and logs";
|
||||
internal const string WindowTitle = "Meeting Summary Agent";
|
||||
|
||||
private readonly IServiceProvider services;
|
||||
private readonly WorkflowRulesEditorMarkdownLinkResolver linkResolver;
|
||||
@@ -288,19 +288,21 @@ internal sealed class WorkflowRulesEditorWpfWindow : Window
|
||||
conversationPanel.ActualHeight);
|
||||
|
||||
conversationPanel.Children.Clear();
|
||||
foreach (var message in viewModel.Messages)
|
||||
foreach (var item in viewModel.Messages)
|
||||
{
|
||||
conversationPanel.Children.Add(CreateMessageCard(message));
|
||||
conversationPanel.Children.Add(item.Kind == WorkflowRulesEditorConversationItemKind.Activity
|
||||
? CreateActivityExpander(item)
|
||||
: CreateMessageCard(item.Message!));
|
||||
}
|
||||
|
||||
if (viewModel.IsThinking)
|
||||
{
|
||||
conversationPanel.Children.Add(new TextBlock
|
||||
foreach (var activity in viewModel.ActivityMessages)
|
||||
{
|
||||
Text = viewModel.ActivityMessage,
|
||||
Foreground = MutedText,
|
||||
Margin = new Thickness(4, 2, 4, 2)
|
||||
});
|
||||
conversationPanel.Children.Add(CreateActivityLine(activity));
|
||||
}
|
||||
|
||||
conversationPanel.Children.Add(CreateActivityLine(viewModel.ActivityMessage));
|
||||
}
|
||||
|
||||
sendButton.IsEnabled = !viewModel.IsThinking;
|
||||
@@ -338,6 +340,43 @@ internal sealed class WorkflowRulesEditorWpfWindow : Window
|
||||
return card;
|
||||
}
|
||||
|
||||
private static Expander CreateActivityExpander(WorkflowRulesEditorConversationItem item)
|
||||
{
|
||||
var details = new StackPanel
|
||||
{
|
||||
Orientation = Orientation.Vertical,
|
||||
Margin = new Thickness(16, 2, 4, 6)
|
||||
};
|
||||
|
||||
foreach (var line in item.ActivityLines ?? [])
|
||||
{
|
||||
details.Children.Add(CreateActivityLine(line));
|
||||
}
|
||||
|
||||
var expander = new Expander
|
||||
{
|
||||
Header = item.Content,
|
||||
Content = details,
|
||||
IsExpanded = item.IsExpanded,
|
||||
Foreground = MutedText,
|
||||
Background = Brushes.Transparent,
|
||||
Margin = new Thickness(4, 0, 4, 8)
|
||||
};
|
||||
expander.Expanded += (_, _) => item.IsExpanded = true;
|
||||
expander.Collapsed += (_, _) => item.IsExpanded = false;
|
||||
return expander;
|
||||
}
|
||||
|
||||
private static TextBlock CreateActivityLine(string text)
|
||||
{
|
||||
return new TextBlock
|
||||
{
|
||||
Text = text,
|
||||
Foreground = MutedText,
|
||||
Margin = new Thickness(4, 2, 4, 2)
|
||||
};
|
||||
}
|
||||
|
||||
private static Style CreateSendButtonStyle()
|
||||
{
|
||||
var style = new Style(typeof(Button));
|
||||
|
||||
@@ -108,7 +108,7 @@ Important settings:
|
||||
- `CalendarRecordingPrompts`: enables Outlook Classic Teams-start prompts on Windows.
|
||||
- `Screenshots`: controls the capture hotkey, attachment folder, and optional OCR/vision model.
|
||||
- `Agent`: configures the OpenAI-compatible summary/project agent endpoint, model, retries, output limits, and compaction.
|
||||
- `WorkflowRulesEditor`: optionally overrides the agent settings for the tray-launched settings/logs assistant.
|
||||
- `WorkflowRulesEditor`: optionally overrides the agent settings for the tray-launched assistant.
|
||||
|
||||
Required or commonly used secrets:
|
||||
|
||||
@@ -125,14 +125,14 @@ See `docs/meeting-assistant-configuration.md` for the full configuration referen
|
||||
- **Azure AI Speech**: default checked-in ASR path, live diarized conversation transcription, and speaker identity matching.
|
||||
- **FunASR**: optional WebSocket streaming ASR. When managed backend startup is enabled, the app pulls and runs the configured Docker image as `meeting-assistant-funasr` on port `10095`.
|
||||
- **Whisper.NET plus pyannote**: optional local Whisper fallback and Docker-backed final diarization.
|
||||
- **LiteLLM/OpenAI-compatible Responses endpoint**: summary generation, screenshot OCR fallback, project tools, settings/logs assistant, and retry flows.
|
||||
- **LiteLLM/OpenAI-compatible Responses endpoint**: summary generation, screenshot OCR fallback, project tools, the tray-launched assistant, and retry flows.
|
||||
- **Docker Desktop or compatible Docker CLI**: required only for managed FunASR and pyannote paths.
|
||||
|
||||
## Workflow Rules And Agents
|
||||
|
||||
Meeting-specific automation lives in a local YAML file, not in committed personal rules. Rules can trigger on meeting creation, assistant-context state transitions, identified speakers, and transcript-line writes. They can add/remove attendees, set supported properties, add context, add projects, and rewrite a transcript line before persistence.
|
||||
|
||||
The tray menu exposes the settings/logs assistant. It can edit workflow rules with validation, inspect logs and health/status, manage speaker identities and samples, run ASR diagnostics, and read/write scoped meeting/project artifacts through explicit tools.
|
||||
The tray menu exposes `Open agent`, which opens the `Meeting Summary Agent` window. It can edit workflow rules with validation, inspect logs and health/status, manage speaker identities and samples, run ASR diagnostics, and read/write scoped meeting/project artifacts through explicit tools.
|
||||
|
||||
Detailed workflow syntax and extension guidance live in `docs/meeting-workflow-engine.md`.
|
||||
|
||||
|
||||
@@ -358,7 +358,7 @@ The summary agent can add and remove meeting-note attendees when transcript or O
|
||||
|
||||
## Workflow Rules Editor
|
||||
|
||||
`WorkflowRulesEditor` configures the tray-launched settings/logs assistant. Blank values inherit from `Agent`, so it uses the summarizer endpoint, key, model, reasoning, retry, output, and compaction settings unless explicitly overridden.
|
||||
`WorkflowRulesEditor` configures the tray-launched `Meeting Summary Agent` window. Blank values inherit from `Agent`, so it uses the summarizer endpoint, key, model, reasoning, retry, output, and compaction settings unless explicitly overridden.
|
||||
|
||||
The overridable fields are `Endpoint`, `Key`, `KeyEnv`, `Model`, `EnableThinking`, `ReasoningEffort`, `ReconnectionAttempts`, `ReconnectionDelay`, `ContextWindowTokens`, `MaxOutputTokens`, `EnableCompaction`, `CompactionRemainingRatio`, `ResponsesCompactPath`, and `InitialPrompt`.
|
||||
|
||||
@@ -378,7 +378,7 @@ Independently from stdout and stderr redirection, Meeting Assistant writes an ap
|
||||
%TEMP%\MeetingAssistant\Logs\meeting-assistant.log
|
||||
```
|
||||
|
||||
On startup it rotates the previous current file to `meeting-assistant.log.1` and keeps up to `.4`. The settings/logs assistant reads and searches these current and rotated files. If another app instance or test host still has the file open, rotation is skipped and logging appends to the current file so startup is not blocked.
|
||||
On startup it rotates the previous current file to `meeting-assistant.log.1` and keeps up to `.4`. The tray-launched assistant reads and searches these current and rotated files. If another app instance or test host still has the file open, rotation is skipped and logging appends to the current file so startup is not blocked.
|
||||
|
||||
## API
|
||||
|
||||
|
||||
@@ -22,7 +22,7 @@ The rules file path is configured through `MeetingAssistant:Automation:RulesPath
|
||||
|
||||
If the configured path is empty, missing, or points to a blank file, the workflow engine does nothing.
|
||||
|
||||
The tray menu includes `Settings and logs`, which opens a small MewUI chat assistant for this configured rules file, the local speaker identity database, appsettings configuration, and application logs. The assistant uses the summarizer agent configuration by default and can be overridden through `MeetingAssistant:WorkflowRulesEditor`.
|
||||
The tray menu includes `Open agent`, which opens the `Meeting Summary Agent` chat window for this configured rules file, the local speaker identity database, appsettings configuration, and application logs. The assistant uses the summarizer agent configuration by default and can be overridden through `MeetingAssistant:WorkflowRulesEditor`.
|
||||
|
||||
```json
|
||||
{
|
||||
@@ -45,6 +45,8 @@ The tray menu includes `Settings and logs`, which opens a small MewUI chat assis
|
||||
|
||||
Blank editor values inherit from `MeetingAssistant:Agent`. This keeps the editor on the same LiteLLM Responses endpoint and model as the summarizer unless a value is explicitly configured. Each user-submitted editor turn sends its first model request with `X-Initiator: user`; follow-up model requests in the same turn, such as tool-call continuations, use `X-Initiator: agent`.
|
||||
|
||||
While the agent is working, the window shows visible reasoning summaries returned by the Responses endpoint and called tool names as lightweight activity lines above the bottom activity line. After the turn completes, those lines move into a collapsible `Worked for <duration>` expander between the user and assistant messages.
|
||||
|
||||
The editor agent prompt includes this document and is restricted to these tools:
|
||||
|
||||
- `read_rules`: read the configured rules file, optionally by inclusive 1-based line range.
|
||||
|
||||
@@ -229,6 +229,10 @@ A `speaker_identified` trigger MAY filter by speaker name.
|
||||
|
||||
A `transcript_line` trigger MAY filter by speaker name.
|
||||
|
||||
Workflow rule execution SHALL log each triggered rule with its rule name and event type.
|
||||
|
||||
Workflow rule execution SHALL log rule failures with the rule name and event type.
|
||||
|
||||
#### Scenario: State transition rule matches from and to
|
||||
- **GIVEN** a configured rule that triggers on a state transition from `collecting metadata` to `transcribing`
|
||||
- **WHEN** Meeting Assistant transitions that meeting from `collecting metadata` to `transcribing`
|
||||
@@ -241,11 +245,19 @@ A `transcript_line` trigger MAY filter by speaker name.
|
||||
- **WHEN** Meeting Assistant identifies speaker `Ada`
|
||||
- **THEN** it applies the rule steps
|
||||
|
||||
#### Scenario: Transcript line rule rewrites masked profanity before persistence
|
||||
#### Scenario: Transcript line rule rewrites masked profanity after durable append
|
||||
- **GIVEN** a configured rule that triggers on transcript line writes and sets `transcript.line` by replacing `*****` with `[redacted]`
|
||||
- **WHEN** Meeting Assistant writes a transcript line for speaker `Guest-1` containing `*****`
|
||||
- **THEN** the written transcript line contains `[redacted]`
|
||||
- **AND** the written transcript line does not contain `*****`
|
||||
- **THEN** Meeting Assistant first appends the original formatted line to the transcript file
|
||||
- **AND** rewrites that written line to contain `[redacted]`
|
||||
- **AND** the final written transcript line does not contain `*****`
|
||||
|
||||
#### Scenario: Transcript line workflow failure keeps transcript writing
|
||||
- **GIVEN** a configured transcript line workflow rule fails while processing a transcript line
|
||||
- **WHEN** Meeting Assistant receives that live transcript segment
|
||||
- **THEN** Meeting Assistant keeps the original formatted line in the transcript file
|
||||
- **AND** logs the workflow rule failure
|
||||
- **AND** continues processing later transcript segments
|
||||
|
||||
### Requirement: Meeting automation rules support conditions and steps
|
||||
Meeting Assistant SHALL support rule conditions using an expression engine.
|
||||
@@ -254,6 +266,8 @@ Rules SHALL support nested `and`, `or`, and `not` condition groups.
|
||||
|
||||
Step values SHALL support Razor syntax against the current meeting event model.
|
||||
|
||||
Rendered step values SHALL be treated as plain UTF-8 text for markdown artifacts and SHALL NOT persist Razor HTML entity encoding.
|
||||
|
||||
Step values SHALL treat `@` characters inside valid email address tokens as literal text rather than Razor transitions.
|
||||
|
||||
Meeting Assistant SHALL expose the formatted transcript line and transcript speaker to `transcript_line` rule conditions and Razor step templates.
|
||||
@@ -290,6 +304,12 @@ The `set_property` step SHALL support setting `transcript.line` during `transcri
|
||||
- **WHEN** the rule runs
|
||||
- **THEN** Meeting Assistant renders the Razor expression and preserves the email address as literal text
|
||||
|
||||
#### Scenario: Razor-rendered transcript line preserves UTF-8 text
|
||||
- **GIVEN** a configured `transcript_line` rule uses Razor to replace part of a transcript line containing `heißt`, `Schimpfwörter`, and `nächstes`
|
||||
- **WHEN** the rule changes `transcript.line`
|
||||
- **THEN** the resulting transcript line contains the original UTF-8 characters
|
||||
- **AND** does not contain HTML entities such as `ß`, `ö`, or `ä`
|
||||
|
||||
#### Scenario: Rule can clean a meeting title
|
||||
- **GIVEN** a configured state-transition rule that matches a title containing a configured marker
|
||||
- **WHEN** the rule runs
|
||||
@@ -298,18 +318,19 @@ The `set_property` step SHALL support setting `transcript.line` during `transcri
|
||||
#### Scenario: Transcript line conditions can use the written line and speaker
|
||||
- **GIVEN** a configured `transcript_line` rule with conditions over `transcript.line` and `transcript.speaker`
|
||||
- **WHEN** Meeting Assistant writes a transcript line that matches both conditions
|
||||
- **THEN** it applies the rule steps before the line is persisted
|
||||
- **THEN** Meeting Assistant applies the rule steps after the original formatted line is durably appended
|
||||
- **AND** rewrites the written line if a rule changes `transcript.line`
|
||||
|
||||
### Requirement: Workflow rules and speaker identities can be edited through a tray-launched assistant
|
||||
Meeting Assistant SHALL expose an `Edit rules and identities` item from the tray icon menu.
|
||||
Meeting Assistant SHALL expose an `Open agent` item from the tray icon menu.
|
||||
|
||||
The tray icon SHALL be implemented through the Uno notification icon stack.
|
||||
|
||||
The tray icon menu SHALL show every configured launch profile when recording can be started and SHALL include each profile's configured toggle hotkey in the corresponding start or switch menu item.
|
||||
|
||||
When the user selects `Edit rules and identities`, Meeting Assistant SHALL open a basic MewUI chat window for editing the configured workflow rules file and speaker identities.
|
||||
When the user selects `Open agent`, Meeting Assistant SHALL open a basic MewUI chat window for editing the configured workflow rules file and speaker identities.
|
||||
|
||||
The chat window SHALL be titled `Edit rules and identities`, SHALL display user and assistant messages as visually distinct cards, SHALL display basic markdown emphasis, inline code, fenced code blocks, pipe tables rendered as grid views, and line breaks in agent responses, SHALL display a plain `Thinking...` line while the agent is working, MAY replace that line with `Reconnecting...` while retrying a transient agent request, SHALL provide a multiline text input at the bottom with placeholder text for asking to make a rule or list identities, SHALL send on Enter, SHALL insert a newline on Shift+Enter, and SHALL provide an explicit Send button.
|
||||
The chat window SHALL be titled `Meeting Summary Agent`, SHALL display user and assistant messages as visually distinct cards, SHALL display basic markdown emphasis, inline code, fenced code blocks, pipe tables rendered as grid views, and line breaks in agent responses, SHALL display lightweight visible reasoning summary lines and tool-call lines that include only the called tool name while the agent is working, SHALL display a plain `Thinking...` line as the bottom activity line while the agent is working, MAY replace that bottom line with `Reconnecting...` while retrying a transient agent request, SHALL replace per-turn activity after completion with a collapsible `Worked for <duration>` expander between the user and assistant cards, SHALL show visible reasoning summary lines, tool-call lines, and status lines in order inside the expander when expanded, SHALL collapse those details again when the expander is closed, SHALL provide a multiline text input at the bottom with placeholder text for asking to make a rule or list identities, SHALL send on Enter, SHALL insert a newline on Shift+Enter, and SHALL provide an explicit Send button.
|
||||
|
||||
When a new chat message or thinking state is appended, the chat window SHALL scroll to the bottom of the newly rendered content if the conversation was already near the bottom or did not need scrolling before the append.
|
||||
|
||||
@@ -335,8 +356,8 @@ Meeting Assistant SHALL provide a diagnostic endpoint that opens the workflow ru
|
||||
|
||||
#### Scenario: Tray menu opens the editor
|
||||
- **WHEN** the user opens the tray icon menu
|
||||
- **THEN** the menu includes `Edit rules and identities`
|
||||
- **WHEN** the user selects `Edit rules and identities`
|
||||
- **THEN** the menu includes `Open agent`
|
||||
- **WHEN** the user selects `Open agent`
|
||||
- **THEN** Meeting Assistant opens the workflow rules and identities editor chat window
|
||||
|
||||
#### Scenario: Tray menu shows configured profile hotkeys
|
||||
@@ -385,9 +406,16 @@ Meeting Assistant SHALL provide a diagnostic endpoint that opens the workflow ru
|
||||
- **GIVEN** the rules editor chat window is open
|
||||
- **WHEN** the user types a prompt and presses Enter
|
||||
- **THEN** the user message appears in the conversation
|
||||
- **AND** a plain `Thinking...` line appears while the agent is running
|
||||
- **AND** a plain `Thinking...` line appears at the bottom while the agent is running
|
||||
- **WHEN** the agent emits visible reasoning summaries or calls tools
|
||||
- **THEN** lightweight activity lines appear above the bottom activity line with the visible reasoning summary text or only the called tool names
|
||||
- **WHEN** the agent turn completes
|
||||
- **THEN** the activity is represented between the user and assistant cards as a collapsed `Worked for <duration>` expander
|
||||
- **AND** expanding it shows the visible reasoning summary, status, and tool-call lines in order
|
||||
- **AND** collapsing it hides those details again
|
||||
- **AND** if no visible reasoning summary was emitted, the expander includes the fallback `Thinking...` line
|
||||
- **AND** the first model request for that turn is marked as user-initiated
|
||||
- **AND** the final assistant response replaces the thinking line
|
||||
- **AND** the final assistant response replaces the live activity lines
|
||||
|
||||
#### Scenario: Rules editor displays agent request failures
|
||||
- **GIVEN** the rules editor chat window is open
|
||||
|
||||
Reference in New Issue
Block a user