|
| 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