| 1 | using System.Text; |
| 2 | using System.Text.Json; |
| 3 | using CascadeIDE.Models.AgentChat; |
| 4 | |
| 5 | namespace CascadeIDE.Features.Chat; |
| 6 | |
| 7 | /// <summary> |
| 8 | /// Простое локальное хранилище истории чата. |
| 9 | /// Канон: append-only NDJSON по событиям + отдельный meta.json + current.session.json. |
| 10 | /// </summary> |
| 11 | public sealed class ChatSessionStore |
| 12 | { |
| 13 | private static readonly JsonSerializerOptions Json = new(JsonSerializerDefaults.Web) |
| 14 | { |
| 15 | WriteIndented = false |
| 16 | }; |
| 17 | |
| 18 | private const string CurrentPointerFileName = "current.session.json"; |
| 19 | |
| 20 | private string _rootPath = ""; |
| 21 | |
| 22 | public ChatSessionStore(string? workspaceRootPath) => SetWorkspaceRoot(workspaceRootPath); |
| 23 | |
| 24 | /// <summary>Каталог <c>{workspace}/.cascade-ide/chat-sessions</c>.</summary> |
| 25 | public string RootPath => _rootPath; |
| 26 | |
| 27 | public void SetWorkspaceRoot(string? workspaceRootPath) |
| 28 | { |
| 29 | var root = string.IsNullOrWhiteSpace(workspaceRootPath) |
| 30 | ? Environment.CurrentDirectory |
| 31 | : workspaceRootPath.Trim(); |
| 32 | _rootPath = Path.Combine(root, ".cascade-ide", "chat-sessions"); |
| 33 | } |
| 34 | |
| 35 | /// <summary> |
| 36 | /// Возвращает стабильный id сессии: указатель → последняя meta по <see cref="ChatSessionMetadata.UpdatedAtUtc"/> → новая сессия. |
| 37 | /// </summary> |
| 38 | public async Task<Guid> ResolveOrCreateCurrentSessionIdAsync(CancellationToken ct = default) |
| 39 | { |
| 40 | Directory.CreateDirectory(_rootPath); |
| 41 | |
| 42 | if (TryReadCurrentPointer(out var pointerId) && MetaExists(pointerId)) |
| 43 | return pointerId; |
| 44 | |
| 45 | var latest = FindLatestSessionIdByMetadata(); |
| 46 | if (latest == Guid.Empty) |
| 47 | latest = FindLatestSessionIdByEventsLog(); |
| 48 | if (latest != Guid.Empty) |
| 49 | { |
| 50 | await WriteCurrentPointerAsync(latest, ct).ConfigureAwait(false); |
| 51 | await EnsureMetadataForSessionAsync(latest, ct).ConfigureAwait(false); |
| 52 | return latest; |
| 53 | } |
| 54 | |
| 55 | var id = Guid.NewGuid(); |
| 56 | var now = DateTimeOffset.UtcNow; |
| 57 | await WriteCurrentPointerAsync(id, ct).ConfigureAwait(false); |
| 58 | await SaveMetadataAsync(new ChatSessionMetadata(id, now, now, Title: "Chat session", MainThreadId: Guid.NewGuid()), ct) |
| 59 | .ConfigureAwait(false); |
| 60 | return id; |
| 61 | } |
| 62 | |
| 63 | public async Task BindCurrentSessionAsync(Guid sessionId, CancellationToken ct = default) |
| 64 | { |
| 65 | if (sessionId == Guid.Empty) |
| 66 | return; |
| 67 | Directory.CreateDirectory(_rootPath); |
| 68 | await WriteCurrentPointerAsync(sessionId, ct).ConfigureAwait(false); |
| 69 | } |
| 70 | |
| 71 | [Obsolete("Use ResolveOrCreateCurrentSessionIdAsync — иначе каждый старт создаёт новую сессию.")] |
| 72 | public Guid EnsureSessionId(Guid? preferred = null) => |
| 73 | preferred is { } id && id != Guid.Empty ? id : Guid.NewGuid(); |
| 74 | |
| 75 | public async Task<ChatSessionMetadata> LoadOrCreateMetadataAsync(Guid sessionId, CancellationToken ct = default) |
| 76 | { |
| 77 | Directory.CreateDirectory(_rootPath); |
| 78 | var path = MetaPath(sessionId); |
| 79 | if (File.Exists(path)) |
| 80 | { |
| 81 | await using var inStream = File.OpenRead(path); |
| 82 | var existing = await JsonSerializer.DeserializeAsync<ChatSessionMetadata>(inStream, Json, ct).ConfigureAwait(false); |
| 83 | if (existing is not null) |
| 84 | return existing; |
| 85 | } |
| 86 | |
| 87 | var now = DateTimeOffset.UtcNow; |
| 88 | var meta = new ChatSessionMetadata(sessionId, now, now, Title: "Chat session"); |
| 89 | await SaveMetadataAsync(meta, ct).ConfigureAwait(false); |
| 90 | return meta; |
| 91 | } |
| 92 | |
| 93 | public async Task SaveMetadataAsync(ChatSessionMetadata metadata, CancellationToken ct = default) |
| 94 | { |
| 95 | Directory.CreateDirectory(_rootPath); |
| 96 | await using var outStream = File.Create(MetaPath(metadata.SessionId)); |
| 97 | await JsonSerializer.SerializeAsync(outStream, metadata, Json, ct).ConfigureAwait(false); |
| 98 | } |
| 99 | |
| 100 | public async Task AppendEventAsync(ChatHistoryEvent ev, CancellationToken ct = default) |
| 101 | { |
| 102 | Directory.CreateDirectory(_rootPath); |
| 103 | var line = JsonSerializer.Serialize(ev, Json); |
| 104 | await File.AppendAllTextAsync(EventsPath(ev.SessionId), line + Environment.NewLine, Encoding.UTF8, ct) |
| 105 | .ConfigureAwait(false); |
| 106 | } |
| 107 | |
| 108 | public async Task<IReadOnlyList<ChatHistoryEvent>> ReadEventsAsync(Guid sessionId, CancellationToken ct = default) |
| 109 | { |
| 110 | var path = EventsPath(sessionId); |
| 111 | if (!File.Exists(path)) |
| 112 | return []; |
| 113 | |
| 114 | var result = new List<ChatHistoryEvent>(); |
| 115 | await using var stream = File.OpenRead(path); |
| 116 | using var reader = new StreamReader(stream, Encoding.UTF8, true); |
| 117 | while (true) |
| 118 | { |
| 119 | ct.ThrowIfCancellationRequested(); |
| 120 | var line = await reader.ReadLineAsync(ct).ConfigureAwait(false); |
| 121 | if (line is null) |
| 122 | break; |
| 123 | if (string.IsNullOrWhiteSpace(line)) |
| 124 | continue; |
| 125 | try |
| 126 | { |
| 127 | var ev = JsonSerializer.Deserialize<ChatHistoryEvent>(line, Json); |
| 128 | if (ev is not null) |
| 129 | result.Add(ev); |
| 130 | } |
| 131 | catch (JsonException) |
| 132 | { |
| 133 | // Пропускаем битые строки NDJSON, остальная история всё равно поднимается. |
| 134 | } |
| 135 | } |
| 136 | |
| 137 | return result; |
| 138 | } |
| 139 | |
| 140 | private bool TryReadCurrentPointer(out Guid sessionId) |
| 141 | { |
| 142 | sessionId = Guid.Empty; |
| 143 | var path = CurrentPointerPath(); |
| 144 | if (!File.Exists(path)) |
| 145 | return false; |
| 146 | |
| 147 | try |
| 148 | { |
| 149 | var json = File.ReadAllText(path); |
| 150 | var pointer = JsonSerializer.Deserialize<ChatSessionCurrentPointer>(json, Json); |
| 151 | if (pointer is null || pointer.SessionId == Guid.Empty) |
| 152 | return false; |
| 153 | sessionId = pointer.SessionId; |
| 154 | return true; |
| 155 | } |
| 156 | catch |
| 157 | { |
| 158 | return false; |
| 159 | } |
| 160 | } |
| 161 | |
| 162 | private async Task WriteCurrentPointerAsync(Guid sessionId, CancellationToken ct) |
| 163 | { |
| 164 | Directory.CreateDirectory(_rootPath); |
| 165 | await using var outStream = File.Create(CurrentPointerPath()); |
| 166 | await JsonSerializer.SerializeAsync(outStream, new ChatSessionCurrentPointer(sessionId), Json, ct) |
| 167 | .ConfigureAwait(false); |
| 168 | } |
| 169 | |
| 170 | private Guid FindLatestSessionIdByMetadata() |
| 171 | { |
| 172 | if (!Directory.Exists(_rootPath)) |
| 173 | return Guid.Empty; |
| 174 | |
| 175 | Guid bestId = Guid.Empty; |
| 176 | var bestUpdated = DateTimeOffset.MinValue; |
| 177 | foreach (var path in Directory.EnumerateFiles(_rootPath, "session-*.meta.json")) |
| 178 | { |
| 179 | if (!TryParseSessionIdFromMetaFileName(path, out var id)) |
| 180 | continue; |
| 181 | try |
| 182 | { |
| 183 | var json = File.ReadAllText(path); |
| 184 | var meta = JsonSerializer.Deserialize<ChatSessionMetadata>(json, Json); |
| 185 | if (meta is null) |
| 186 | continue; |
| 187 | var updated = meta.UpdatedAtUtc; |
| 188 | if (updated >= bestUpdated) |
| 189 | { |
| 190 | bestUpdated = updated; |
| 191 | bestId = meta.SessionId != Guid.Empty ? meta.SessionId : id; |
| 192 | } |
| 193 | } |
| 194 | catch |
| 195 | { |
| 196 | // ignore corrupt meta |
| 197 | } |
| 198 | } |
| 199 | |
| 200 | return bestId; |
| 201 | } |
| 202 | |
| 203 | private Guid FindLatestSessionIdByEventsLog() |
| 204 | { |
| 205 | if (!Directory.Exists(_rootPath)) |
| 206 | return Guid.Empty; |
| 207 | |
| 208 | Guid bestId = Guid.Empty; |
| 209 | var bestWrite = DateTime.MinValue; |
| 210 | foreach (var path in Directory.EnumerateFiles(_rootPath, "session-*.events.ndjson")) |
| 211 | { |
| 212 | if (!TryParseSessionIdFromEventsFileName(path, out var id)) |
| 213 | continue; |
| 214 | var write = File.GetLastWriteTimeUtc(path); |
| 215 | if (write >= bestWrite) |
| 216 | { |
| 217 | bestWrite = write; |
| 218 | bestId = id; |
| 219 | } |
| 220 | } |
| 221 | |
| 222 | return bestId; |
| 223 | } |
| 224 | |
| 225 | private async Task EnsureMetadataForSessionAsync(Guid sessionId, CancellationToken ct) |
| 226 | { |
| 227 | if (MetaExists(sessionId)) |
| 228 | return; |
| 229 | var events = await ReadEventsAsync(sessionId, ct).ConfigureAwait(false); |
| 230 | var mainThreadId = ChatHistoryMessageProjector.InferMainThreadId(events); |
| 231 | var now = DateTimeOffset.UtcNow; |
| 232 | await SaveMetadataAsync(new ChatSessionMetadata(sessionId, now, now, Title: "Chat session", MainThreadId: mainThreadId), ct) |
| 233 | .ConfigureAwait(false); |
| 234 | } |
| 235 | |
| 236 | private static bool TryParseSessionIdFromMetaFileName(string path, out Guid sessionId) => |
| 237 | TryParseSessionIdFromSessionFileName(path, ".meta", out sessionId); |
| 238 | |
| 239 | private static bool TryParseSessionIdFromEventsFileName(string path, out Guid sessionId) => |
| 240 | TryParseSessionIdFromSessionFileName(path, ".events", out sessionId); |
| 241 | |
| 242 | private static bool TryParseSessionIdFromSessionFileName(string path, string middleSuffix, out Guid sessionId) |
| 243 | { |
| 244 | sessionId = Guid.Empty; |
| 245 | var name = Path.GetFileNameWithoutExtension(path); |
| 246 | const string prefix = "session-"; |
| 247 | if (!name.StartsWith(prefix, StringComparison.OrdinalIgnoreCase)) |
| 248 | return false; |
| 249 | var tail = name[prefix.Length..]; |
| 250 | if (!tail.EndsWith(middleSuffix, StringComparison.OrdinalIgnoreCase)) |
| 251 | return false; |
| 252 | var hex = tail[..^middleSuffix.Length]; |
| 253 | return Guid.TryParseExact(hex, "N", out sessionId); |
| 254 | } |
| 255 | |
| 256 | private bool MetaExists(Guid sessionId) => |
| 257 | sessionId != Guid.Empty && File.Exists(MetaPath(sessionId)); |
| 258 | |
| 259 | private string CurrentPointerPath() => Path.Combine(_rootPath, CurrentPointerFileName); |
| 260 | |
| 261 | private string EventsPath(Guid sessionId) => Path.Combine(_rootPath, $"session-{sessionId:N}.events.ndjson"); |
| 262 | |
| 263 | private string MetaPath(Guid sessionId) => Path.Combine(_rootPath, $"session-{sessionId:N}.meta.json"); |
| 264 | } |
| 265 | |