Skip to content

Commit 0c64f8b

Browse files
sharpninjaCopilot
andcommitted
Add persistent YAML chat logging for voice conversations
- ChatLogService: singleton rolling YAML log to LocalApplicationData/McpServerManager/voice-chat.yaml - Logs every request-response exchange with session ID, timestamps, timing metrics, success/error - Wired into conversation loop (both streaming responses and seed session) - Made FormatDuration accessible as static method for log formatting Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
1 parent 5902e33 commit 0c64f8b

2 files changed

Lines changed: 173 additions & 1 deletion

File tree

src/McpServerManager.Android/Views/SimplifiedVoiceView.axaml.cs

Lines changed: 35 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -111,7 +111,10 @@ public void UpdateTiming(TimeSpan? elapsed = null)
111111
/// <summary>Sets timing text from captured durations (finalized).</summary>
112112
public void SetTimingFromDurations() => UpdateTiming();
113113

114-
private static string FormatDuration(TimeSpan d) =>
114+
private static string FormatDuration(TimeSpan d) => FormatDurationStatic(d);
115+
116+
/// <summary>Formats a duration for display. Used by ChatLogService.</summary>
117+
public static string FormatDurationStatic(TimeSpan d) =>
115118
d.TotalSeconds < 1 ? $"{d.TotalMilliseconds:F0}ms"
116119
: d.TotalMinutes < 1 ? $"{d.TotalSeconds:F1}s"
117120
: $"{d.TotalMinutes:F1}m";
@@ -448,6 +451,22 @@ private async Task RunConversationLoopAsync(CancellationToken ct)
448451
ScrollToBottom();
449452
}
450453

454+
// Persist the exchange to the rolling chat log
455+
ChatLogService.Instance.LogExchange(new ChatLogEntry
456+
{
457+
SessionId = vm.SessionId,
458+
RequestTimestamp = requestTime.ToString("O"),
459+
RequestText = transcript,
460+
ResponseText = assistantBubble.Text,
461+
FirstResponseDuration = assistantBubble.FirstResponseDuration.HasValue
462+
? ChatMessage.FormatDurationStatic(assistantBubble.FirstResponseDuration.Value) : null,
463+
TotalDuration = assistantBubble.FinalResponseDuration.HasValue
464+
? ChatMessage.FormatDurationStatic(assistantBubble.FinalResponseDuration.Value) : null,
465+
FirstResponseMs = (long?)assistantBubble.FirstResponseDuration?.TotalMilliseconds,
466+
TotalMs = (long?)assistantBubble.FinalResponseDuration?.TotalMilliseconds,
467+
Success = isDone
468+
});
469+
451470
// Speak any remaining buffered text
452471
var remainder = sentenceBuffer.ToString().Trim();
453472
if (!string.IsNullOrWhiteSpace(remainder) && !_ttsStopped)
@@ -529,6 +548,21 @@ private async Task SeedSessionAsync(VoiceConversationViewModel vm, CancellationT
529548

530549
seedBubble.SetTimingFromDurations();
531550

551+
ChatLogService.Instance.LogExchange(new ChatLogEntry
552+
{
553+
SessionId = vm.SessionId,
554+
RequestTimestamp = seedRequestTime.ToString("O"),
555+
RequestText = seedPrompt,
556+
ResponseText = seedBubble.Text,
557+
FirstResponseDuration = seedBubble.FirstResponseDuration.HasValue
558+
? ChatMessage.FormatDurationStatic(seedBubble.FirstResponseDuration.Value) : null,
559+
TotalDuration = seedBubble.FinalResponseDuration.HasValue
560+
? ChatMessage.FormatDurationStatic(seedBubble.FinalResponseDuration.Value) : null,
561+
FirstResponseMs = (long?)seedBubble.FirstResponseDuration?.TotalMilliseconds,
562+
TotalMs = (long?)seedBubble.FinalResponseDuration?.TotalMilliseconds,
563+
Success = true
564+
});
565+
532566
await _tts.SpeakAsync("Copilot ready", vm.Language, ct).ConfigureAwait(true);
533567
SetStatus("Copilot ready. Listening...");
534568
}
Lines changed: 138 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,138 @@
1+
using System;
2+
using System.IO;
3+
using System.Text;
4+
using Microsoft.Extensions.Logging;
5+
6+
namespace McpServerManager.Core.Services;
7+
8+
/// <summary>
9+
/// Persists voice chat request-response pairs to a rolling YAML log file.
10+
/// Each exchange is appended as a YAML document separated by "---".
11+
/// </summary>
12+
public sealed class ChatLogService
13+
{
14+
private static readonly ILogger Logger = AppLogService.Instance.CreateLogger("ChatLog");
15+
private static readonly Lazy<ChatLogService> LazyInstance = new(() => new ChatLogService());
16+
17+
private const string LogFileName = "voice-chat.yaml";
18+
private readonly object _lock = new();
19+
20+
/// <summary>Singleton instance shared across the app.</summary>
21+
public static ChatLogService Instance => LazyInstance.Value;
22+
23+
private ChatLogService() { }
24+
25+
/// <summary>Gets the path to the rolling chat log file.</summary>
26+
public static string GetLogFilePath()
27+
{
28+
var appDataPath = Path.Combine(
29+
Environment.GetFolderPath(Environment.SpecialFolder.LocalApplicationData),
30+
"McpServerManager");
31+
Directory.CreateDirectory(appDataPath);
32+
return Path.Combine(appDataPath, LogFileName);
33+
}
34+
35+
/// <summary>Appends a completed request-response exchange to the log.</summary>
36+
public void LogExchange(ChatLogEntry entry)
37+
{
38+
try
39+
{
40+
var yaml = FormatYaml(entry);
41+
lock (_lock)
42+
{
43+
File.AppendAllText(GetLogFilePath(), yaml);
44+
}
45+
Logger.LogDebug("Logged chat exchange: {TurnLength} chars", entry.ResponseText?.Length ?? 0);
46+
}
47+
catch (Exception ex)
48+
{
49+
Logger.LogWarning(ex, "Failed to write chat log entry");
50+
}
51+
}
52+
53+
private static string FormatYaml(ChatLogEntry e)
54+
{
55+
var sb = new StringBuilder();
56+
sb.AppendLine("---");
57+
if (e.SessionId is not null)
58+
sb.AppendLine($"session: {e.SessionId}");
59+
if (e.RequestTimestamp is not null)
60+
sb.AppendLine($"timestamp: {e.RequestTimestamp}");
61+
if (e.RequestText is not null)
62+
sb.AppendLine($"request: {YamlQuote(e.RequestText)}");
63+
if (e.ResponseText is not null)
64+
sb.AppendLine($"response: {YamlQuote(e.ResponseText)}");
65+
if (e.FirstResponseDuration is not null)
66+
sb.AppendLine($"first_response: {e.FirstResponseDuration}");
67+
if (e.TotalDuration is not null)
68+
sb.AppendLine($"total_duration: {e.TotalDuration}");
69+
if (e.FirstResponseMs.HasValue)
70+
sb.AppendLine($"first_response_ms: {e.FirstResponseMs.Value}");
71+
if (e.TotalMs.HasValue)
72+
sb.AppendLine($"total_ms: {e.TotalMs.Value}");
73+
if (!e.Success)
74+
{
75+
sb.AppendLine("success: false");
76+
if (e.Error is not null)
77+
sb.AppendLine($"error: {YamlQuote(e.Error)}");
78+
}
79+
return sb.ToString();
80+
}
81+
82+
private static string YamlQuote(string value)
83+
{
84+
if (value.Contains('\n') || value.Contains('\r'))
85+
{
86+
// Use YAML literal block scalar for multiline
87+
var indented = value.Replace("\r\n", "\n").Replace("\r", "\n");
88+
var lines = indented.Split('\n');
89+
var sb = new StringBuilder();
90+
sb.AppendLine("|");
91+
foreach (var line in lines)
92+
sb.AppendLine($" {line}");
93+
return sb.ToString().TrimEnd();
94+
}
95+
96+
// Quote if contains special chars
97+
if (value.Contains(':') || value.Contains('#') || value.Contains('"') ||
98+
value.Contains('\'') || value.Contains('{') || value.Contains('}') ||
99+
value.Contains('[') || value.Contains(']'))
100+
return $"\"{value.Replace("\\", "\\\\").Replace("\"", "\\\"")}\"";
101+
102+
return value;
103+
}
104+
}
105+
106+
/// <summary>A single request-response exchange in the voice chat log.</summary>
107+
public sealed class ChatLogEntry
108+
{
109+
/// <summary>Session ID for the voice conversation.</summary>
110+
public string? SessionId { get; set; }
111+
112+
/// <summary>ISO 8601 timestamp when the user submitted the request.</summary>
113+
public string? RequestTimestamp { get; set; }
114+
115+
/// <summary>The user's spoken or typed input text.</summary>
116+
public string? RequestText { get; set; }
117+
118+
/// <summary>The assistant's full response text.</summary>
119+
public string? ResponseText { get; set; }
120+
121+
/// <summary>Formatted duration from request to first response chunk.</summary>
122+
public string? FirstResponseDuration { get; set; }
123+
124+
/// <summary>Formatted duration from request to final response.</summary>
125+
public string? TotalDuration { get; set; }
126+
127+
/// <summary>First response latency in milliseconds.</summary>
128+
public long? FirstResponseMs { get; set; }
129+
130+
/// <summary>Total response duration in milliseconds.</summary>
131+
public long? TotalMs { get; set; }
132+
133+
/// <summary>Whether the exchange completed successfully.</summary>
134+
public bool Success { get; set; } = true;
135+
136+
/// <summary>Error message if the exchange failed.</summary>
137+
public string? Error { get; set; }
138+
}

0 commit comments

Comments
 (0)