| 1 | #nullable enable |
| 2 | |
| 3 | using System.Text.Json; |
| 4 | using CascadeIDE.Models.AgentChat; |
| 5 | using CascadeIDE.Models.Intercom; |
| 6 | using CascadeIDE.Services; |
| 7 | using CascadeIDE.Services.Intercom; |
| 8 | |
| 9 | namespace CascadeIDE.Features.Chat; |
| 10 | |
| 11 | public partial class ChatPanelViewModel |
| 12 | { |
| 13 | private IReadOnlyList<IntercomMessageRangeRelatedProjector.ExplicitRelate> _explicitMessageRangeRelates = []; |
| 14 | |
| 15 | /// <summary>Найти сообщения в активной ветке по коду (ADR 0137: inferred + explicit relate).</summary> |
| 16 | public string FindMessagesForCodeRef(string? codeRefTail) |
| 17 | { |
| 18 | var editor = BuildAttachEditorSnapshot(); |
| 19 | var workspace = ResolveAttachWorkspaceRoot(); |
| 20 | var solution = ResolveAttachSolutionPath(); |
| 21 | if (!IntercomCodeRefParser.TryParse( |
| 22 | codeRefTail, |
| 23 | editor, |
| 24 | workspace, |
| 25 | solution, |
| 26 | out var query, |
| 27 | out var parseError, |
| 28 | ResolveAttachIndexDirectoryRelative())) |
| 29 | { |
| 30 | return parseError; |
| 31 | } |
| 32 | |
| 33 | if (!TryGetActiveDetailLaneMessageIndices(out var indices)) |
| 34 | return "В активной ветке нет сообщений."; |
| 35 | |
| 36 | return IntercomCorrespondenceOperations.FormatFindResult( |
| 37 | IntercomCorrespondenceOperations.ExecuteFind( |
| 38 | query, |
| 39 | workspace, |
| 40 | IsChatOverviewMode, |
| 41 | indices.Count, |
| 42 | buildLaneMessages(indices), |
| 43 | IntercomMessageRangeRelatedProjector.ForThread(_explicitMessageRangeRelates, _activeThreadId), |
| 44 | SelectMessageByOrdinalRangeInDetailLane)); |
| 45 | } |
| 46 | |
| 47 | /// <summary> |
| 48 | /// Явно связать диапазон gutter-сообщений с кодом; пишет <see cref="ChatHistoryEventKind.MessageRangeRelated"/> (ADR 0137). |
| 49 | /// </summary> |
| 50 | public string RelateMessageRangeToCodeRef(string? relateTail) |
| 51 | { |
| 52 | if (!IntercomMessageRelateArgs.TryParse(relateTail, out var segments, out var codeRefTail, out var parseError)) |
| 53 | return parseError; |
| 54 | |
| 55 | if (IsChatOverviewMode) |
| 56 | return "Открой тему (detail): /intercom topic open или клик по карточке."; |
| 57 | |
| 58 | if (_activeThreadId == Guid.Empty) |
| 59 | return "Нет активной ветки."; |
| 60 | |
| 61 | if (!TryGetActiveDetailLaneMessageIndices(out var indices)) |
| 62 | return "В активной ветке нет сообщений."; |
| 63 | |
| 64 | var ordinalSegments = segments |
| 65 | .Select(s => new ChatHistoryMessageOrdinalSegment(s.Start, s.End)) |
| 66 | .ToList(); |
| 67 | |
| 68 | if (!IntercomMessageRangeRelatedSupport.TryValidateSegmentsInLane(ordinalSegments, indices.Count, out var rangeError)) |
| 69 | return rangeError; |
| 70 | |
| 71 | var selectResult = segments.Count == 1 |
| 72 | ? SelectMessageByOrdinalRangeInDetailLane(segments[0].Start, segments[0].End) |
| 73 | : SelectMessagesByOrdinalRangesInDetailLane(segments); |
| 74 | |
| 75 | if (!string.Equals(selectResult, "OK", StringComparison.Ordinal)) |
| 76 | return selectResult; |
| 77 | |
| 78 | var editor = BuildAttachEditorSnapshot(); |
| 79 | var workspace = ResolveAttachWorkspaceRoot(); |
| 80 | var solution = ResolveAttachSolutionPath(); |
| 81 | if (!IntercomCodeRefParser.TryResolveAnchor( |
| 82 | codeRefTail, |
| 83 | editor, |
| 84 | workspace, |
| 85 | solution, |
| 86 | out var anchor, |
| 87 | out var anchorError, |
| 88 | ResolveAttachIndexDirectoryRelative())) |
| 89 | { |
| 90 | return anchorError; |
| 91 | } |
| 92 | |
| 93 | var payload = IntercomMessageRangeRelatedSupport.CreatePayload( |
| 94 | _activeThreadId.ToString("N"), |
| 95 | ordinalSegments, |
| 96 | anchor, |
| 97 | "slash"); |
| 98 | |
| 99 | _ = PersistEventAsync(ChatHistoryEventKind.MessageRangeRelated, payload, _activeThreadId); |
| 100 | appendExplicitRelateInMemory(payload); |
| 101 | |
| 102 | var label = IntercomMessageRangeRelatedSupport.FormatOrdinalSummary(ordinalSegments); |
| 103 | return $"Связь сообщений {label} с кодом записана ({anchor.DisplayLabel ?? anchor.File})."; |
| 104 | } |
| 105 | |
| 106 | /// <summary>JSON для MCP <c>intercom.messages_for_code</c>.</summary> |
| 107 | public string FindMessagesForCodeRefFromMcp(IReadOnlyDictionary<string, JsonElement>? args) |
| 108 | { |
| 109 | var editor = BuildAttachEditorSnapshot(); |
| 110 | var workspace = ResolveAttachWorkspaceRoot(); |
| 111 | var solution = ResolveAttachSolutionPath(); |
| 112 | if (!IntercomCodeRefParser.TryParseFromMcp(args, editor, workspace, solution, out var query, out var parseError)) |
| 113 | return JsonSerializer.Serialize(new { error = "parse", message = parseError }); |
| 114 | |
| 115 | if (!TryGetActiveDetailLaneMessageIndices(out var indices)) |
| 116 | return JsonSerializer.Serialize(new { error = "empty_lane", message = "В активной ветке нет сообщений." }); |
| 117 | |
| 118 | var result = IntercomCorrespondenceOperations.ExecuteFind( |
| 119 | query, |
| 120 | workspace, |
| 121 | IsChatOverviewMode, |
| 122 | indices.Count, |
| 123 | buildLaneMessages(indices), |
| 124 | IntercomMessageRangeRelatedProjector.ForThread(_explicitMessageRangeRelates, _activeThreadId), |
| 125 | SelectMessageByOrdinalRangeInDetailLane); |
| 126 | if (result.Error is { } err) |
| 127 | return JsonSerializer.Serialize(new { error = err.Kind, message = err.Message }); |
| 128 | |
| 129 | return JsonSerializer.Serialize(new |
| 130 | { |
| 131 | query = new |
| 132 | { |
| 133 | file = query.File, |
| 134 | line_start = query.LineStart, |
| 135 | line_end = query.LineEnd, |
| 136 | member_key = query.MemberKey, |
| 137 | anchor_id = query.ResolvedAnchor?.Id, |
| 138 | }, |
| 139 | hits = result.Hits!.Select(h => new |
| 140 | { |
| 141 | ordinal = h.Ordinal, |
| 142 | message_index = h.MessageIndex, |
| 143 | message_id = h.MessageId.ToString("N"), |
| 144 | match_kind = h.MatchKind, |
| 145 | }), |
| 146 | selected_ordinal = result.SelectedOrdinal, |
| 147 | branch_message_count = result.BranchMessageCount, |
| 148 | }); |
| 149 | } |
| 150 | |
| 151 | /// <summary>JSON для MCP <c>intercom.message_relate</c>.</summary> |
| 152 | public string RelateMessageRangeToCodeRefFromMcp(IReadOnlyDictionary<string, JsonElement>? args) |
| 153 | { |
| 154 | if (args is null) |
| 155 | return JsonSerializer.Serialize(new { error = "parse", message = "Отсутствуют аргументы." }); |
| 156 | |
| 157 | if (!TryParseOrdinalSegmentsFromMcp(args, out var ordinalSegments, out var parametricSegments, out var segmentParseError)) |
| 158 | { |
| 159 | return JsonSerializer.Serialize(new { error = "parse", message = segmentParseError }); |
| 160 | } |
| 161 | |
| 162 | var editor = BuildAttachEditorSnapshot(); |
| 163 | var workspace = ResolveAttachWorkspaceRoot(); |
| 164 | var solution = ResolveAttachSolutionPath(); |
| 165 | if (!IntercomCodeRefParser.TryResolveAnchorFromMcp( |
| 166 | args, |
| 167 | editor, |
| 168 | workspace, |
| 169 | solution, |
| 170 | out var anchor, |
| 171 | out var anchorError, |
| 172 | ResolveAttachIndexDirectoryRelative())) |
| 173 | { |
| 174 | return JsonSerializer.Serialize(new { error = "parse", message = anchorError }); |
| 175 | } |
| 176 | |
| 177 | if (IsChatOverviewMode) |
| 178 | return JsonSerializer.Serialize(new { error = "overview_mode", message = "Открой detail-ветку." }); |
| 179 | |
| 180 | if (_activeThreadId == Guid.Empty) |
| 181 | return JsonSerializer.Serialize(new { error = "no_thread", message = "Нет активной ветки." }); |
| 182 | |
| 183 | if (!TryGetActiveDetailLaneMessageIndices(out var indices)) |
| 184 | { |
| 185 | return JsonSerializer.Serialize(new { error = "empty_lane", message = "В активной ветке нет сообщений." }); |
| 186 | } |
| 187 | |
| 188 | if (!IntercomMessageRangeRelatedSupport.TryValidateSegmentsInLane(ordinalSegments, indices.Count, out var rangeError)) |
| 189 | { |
| 190 | return JsonSerializer.Serialize(new { error = "range", message = rangeError }); |
| 191 | } |
| 192 | |
| 193 | var selectResult = parametricSegments.Count == 1 |
| 194 | ? SelectMessageByOrdinalRangeInDetailLane(parametricSegments[0].Start, parametricSegments[0].End) |
| 195 | : SelectMessagesByOrdinalRangesInDetailLane(parametricSegments); |
| 196 | |
| 197 | if (!string.Equals(selectResult, "OK", StringComparison.Ordinal)) |
| 198 | { |
| 199 | return JsonSerializer.Serialize(new { error = "range", message = selectResult }); |
| 200 | } |
| 201 | |
| 202 | var payload = IntercomMessageRangeRelatedSupport.CreatePayload( |
| 203 | _activeThreadId.ToString("N"), |
| 204 | ordinalSegments, |
| 205 | anchor, |
| 206 | "mcp"); |
| 207 | |
| 208 | _ = PersistEventAsync(ChatHistoryEventKind.MessageRangeRelated, payload, _activeThreadId); |
| 209 | appendExplicitRelateInMemory(payload); |
| 210 | |
| 211 | return JsonSerializer.Serialize(new |
| 212 | { |
| 213 | ok = true, |
| 214 | thread_id = payload.ThreadId, |
| 215 | start_ordinal = payload.StartOrdinal, |
| 216 | end_ordinal = payload.EndOrdinal, |
| 217 | ordinal_segments = ordinalSegments.Select(s => new |
| 218 | { |
| 219 | start_ordinal = s.StartOrdinal, |
| 220 | end_ordinal = s.EndOrdinal, |
| 221 | }), |
| 222 | code_ref = new |
| 223 | { |
| 224 | id = anchor.Id, |
| 225 | file = anchor.File, |
| 226 | member_key = anchor.MemberKey, |
| 227 | attachment_shape = anchor.AttachmentShape, |
| 228 | line_start = anchor.LineStart, |
| 229 | line_end = anchor.LineEnd, |
| 230 | }, |
| 231 | }); |
| 232 | } |
| 233 | |
| 234 | private static bool TryParseOrdinalSegmentsFromMcp( |
| 235 | IReadOnlyDictionary<string, JsonElement> args, |
| 236 | out IReadOnlyList<ChatHistoryMessageOrdinalSegment> ordinalSegments, |
| 237 | out IReadOnlyList<ParametricIntRange> parametricSegments, |
| 238 | out string error) |
| 239 | { |
| 240 | ordinalSegments = []; |
| 241 | parametricSegments = []; |
| 242 | error = ""; |
| 243 | |
| 244 | if (args.TryGetValue("range_expr", out var rangeExpr) |
| 245 | && rangeExpr.ValueKind == JsonValueKind.String |
| 246 | && !string.IsNullOrWhiteSpace(rangeExpr.GetString())) |
| 247 | { |
| 248 | if (!ParametricSegmentListParser.TryParse(rangeExpr.GetString(), out parametricSegments, out error)) |
| 249 | return false; |
| 250 | |
| 251 | ordinalSegments = ToOrdinalSegments(parametricSegments); |
| 252 | return true; |
| 253 | } |
| 254 | |
| 255 | if (args.TryGetValue("ordinal_segments", out var segmentsEl) |
| 256 | && segmentsEl.ValueKind == JsonValueKind.Array) |
| 257 | { |
| 258 | var list = new List<ChatHistoryMessageOrdinalSegment>(); |
| 259 | foreach (var item in segmentsEl.EnumerateArray()) |
| 260 | { |
| 261 | if (item.ValueKind != JsonValueKind.Object) |
| 262 | { |
| 263 | error = "ordinal_segments: каждый элемент — объект { start_ordinal, end_ordinal }."; |
| 264 | return false; |
| 265 | } |
| 266 | |
| 267 | if (!item.TryGetProperty("start_ordinal", out var startEl) |
| 268 | || !item.TryGetProperty("end_ordinal", out var endEl) |
| 269 | || !startEl.TryGetInt32(out var start) |
| 270 | || !endEl.TryGetInt32(out var end)) |
| 271 | { |
| 272 | error = "ordinal_segments: укажи start_ordinal и end_ordinal (integer ≥ 1)."; |
| 273 | return false; |
| 274 | } |
| 275 | |
| 276 | list.Add(new ChatHistoryMessageOrdinalSegment(start, end)); |
| 277 | } |
| 278 | |
| 279 | if (list.Count == 0) |
| 280 | { |
| 281 | error = "ordinal_segments не может быть пустым."; |
| 282 | return false; |
| 283 | } |
| 284 | |
| 285 | ordinalSegments = list; |
| 286 | parametricSegments = list |
| 287 | .Select(s => new ParametricIntRange(s.StartOrdinal, s.EndOrdinal)) |
| 288 | .ToList(); |
| 289 | return true; |
| 290 | } |
| 291 | |
| 292 | var startOrdinal = McpCommandJsonArgs.OptionalInt32(args, "start_ordinal"); |
| 293 | var endOrdinal = McpCommandJsonArgs.OptionalInt32(args, "end_ordinal") ?? startOrdinal; |
| 294 | if (startOrdinal is null or < 1 || endOrdinal is null or < 1 || endOrdinal < startOrdinal) |
| 295 | { |
| 296 | error = "Укажи start_ordinal (1-based) и опционально end_ordinal, либо range_expr / ordinal_segments для disjoint."; |
| 297 | return false; |
| 298 | } |
| 299 | |
| 300 | ordinalSegments = [new ChatHistoryMessageOrdinalSegment(startOrdinal.Value, endOrdinal.Value)]; |
| 301 | parametricSegments = [new ParametricIntRange(startOrdinal.Value, endOrdinal.Value)]; |
| 302 | return true; |
| 303 | } |
| 304 | |
| 305 | private static IReadOnlyList<ChatHistoryMessageOrdinalSegment> ToOrdinalSegments( |
| 306 | IReadOnlyList<ParametricIntRange> segments) => |
| 307 | segments.Select(s => new ChatHistoryMessageOrdinalSegment(s.Start, s.End)).ToList(); |
| 308 | |
| 309 | private void rebuildExplicitRelatesFromEvents(IReadOnlyList<ChatHistoryEvent> events) => |
| 310 | _explicitMessageRangeRelates = IntercomMessageRangeRelatedProjector.Project(events); |
| 311 | |
| 312 | private void appendExplicitRelateInMemory(ChatHistoryMessageRangeRelatedPayload payload) |
| 313 | { |
| 314 | if (!Guid.TryParse(payload.ThreadId, out var threadId) || threadId == Guid.Empty) |
| 315 | return; |
| 316 | |
| 317 | var list = _explicitMessageRangeRelates.ToList(); |
| 318 | list.Add(new IntercomMessageRangeRelatedProjector.ExplicitRelate( |
| 319 | threadId, |
| 320 | payload.StartOrdinal, |
| 321 | payload.EndOrdinal, |
| 322 | IntercomMessageRangeRelatedSupport.ResolveSegments(payload), |
| 323 | payload.CodeRef, |
| 324 | payload.Source)); |
| 325 | _explicitMessageRangeRelates = list; |
| 326 | } |
| 327 | |
| 328 | private IReadOnlyList<IntercomMessageCodeCorrespondenceProjector.LaneMessage> buildLaneMessages( |
| 329 | IReadOnlyList<int> indices) |
| 330 | { |
| 331 | var list = new List<IntercomMessageCodeCorrespondenceProjector.LaneMessage>(); |
| 332 | for (var i = 0; i < indices.Count; i++) |
| 333 | { |
| 334 | var messageIndex = indices[i]; |
| 335 | if (messageIndex < 0 || messageIndex >= ChatMessages.Count) |
| 336 | continue; |
| 337 | |
| 338 | var m = ChatMessages[messageIndex]; |
| 339 | list.Add(new IntercomMessageCodeCorrespondenceProjector.LaneMessage( |
| 340 | i + 1, |
| 341 | messageIndex, |
| 342 | m.MessageId, |
| 343 | m.Attachments)); |
| 344 | } |
| 345 | |
| 346 | return list; |
| 347 | } |
| 348 | } |
| 349 | |