| 1 | #nullable enable |
| 2 | |
| 3 | using CascadeIDE.Features.Chat.Application; |
| 4 | using CascadeIDE.Features.Workspace.Application; |
| 5 | using CascadeIDE.Models.Intercom; |
| 6 | using CascadeIDE.Services; |
| 7 | using CascadeIDE.Services.Intercom; |
| 8 | |
| 9 | namespace CascadeIDE.Features.Chat; |
| 10 | |
| 11 | /// <summary>Сборка исходящего сообщения: prose, bracket → markers, attachments @ send (ADR 0128, 0134).</summary> |
| 12 | public static class IntercomAttachmentMessageBuilder |
| 13 | { |
| 14 | public sealed record Outbound( |
| 15 | string Content, |
| 16 | IReadOnlyList<AttachmentAnchor> Attachments, |
| 17 | SenderWorkspaceContext? SenderWorkspaceContext); |
| 18 | |
| 19 | public static bool TryBuild( |
| 20 | string rawInput, |
| 21 | IReadOnlyDictionary<string, AttachmentAnchor> pendingByShortId, |
| 22 | IntercomAttachmentResolveAtSend.EditorSnapshot editor, |
| 23 | string? workspaceRoot, |
| 24 | string? solutionPath, |
| 25 | out Outbound outbound, |
| 26 | out string error, |
| 27 | string? indexDirectoryRelative = null) => |
| 28 | TryBuildCore( |
| 29 | rawInput, |
| 30 | pendingByShortId, |
| 31 | editor, |
| 32 | workspaceRoot, |
| 33 | solutionPath, |
| 34 | indexDirectoryRelative, |
| 35 | IntercomOutboundPrepareProfile.ComposerStrictBuild, |
| 36 | warnings: null, |
| 37 | out outbound, |
| 38 | out error); |
| 39 | |
| 40 | public static bool TryPrepare( |
| 41 | string rawInput, |
| 42 | IReadOnlyDictionary<string, AttachmentAnchor> pendingByShortId, |
| 43 | IntercomAttachmentResolveAtSend.EditorSnapshot editor, |
| 44 | string? workspaceRoot, |
| 45 | string? solutionPath, |
| 46 | out PreparedIntercomMessage prepared) => |
| 47 | TryPrepareWithProfile( |
| 48 | rawInput, |
| 49 | pendingByShortId, |
| 50 | editor, |
| 51 | workspaceRoot, |
| 52 | solutionPath, |
| 53 | IntercomOutboundPrepareProfile.ComposerPrepare, |
| 54 | out prepared); |
| 55 | |
| 56 | /// <summary>MCP fast-path: F/M/L/S @ send без Roslyn и без excerpt; L: строки из bracket; M/S — re-resolve при reveal.</summary> |
| 57 | public static bool TryPrepareForMcp( |
| 58 | string rawInput, |
| 59 | IReadOnlyDictionary<string, AttachmentAnchor> pendingByShortId, |
| 60 | IntercomAttachmentResolveAtSend.EditorSnapshot editor, |
| 61 | string? workspaceRoot, |
| 62 | string? solutionPath, |
| 63 | out PreparedIntercomMessage prepared) => |
| 64 | TryPrepareWithProfile( |
| 65 | rawInput, |
| 66 | pendingByShortId, |
| 67 | editor, |
| 68 | workspaceRoot, |
| 69 | solutionPath, |
| 70 | IntercomOutboundPrepareProfile.McpFastPrepare, |
| 71 | out prepared); |
| 72 | |
| 73 | private static bool TryPrepareWithProfile( |
| 74 | string rawInput, |
| 75 | IReadOnlyDictionary<string, AttachmentAnchor> pendingByShortId, |
| 76 | IntercomAttachmentResolveAtSend.EditorSnapshot editor, |
| 77 | string? workspaceRoot, |
| 78 | string? solutionPath, |
| 79 | IntercomOutboundPrepareProfile profile, |
| 80 | out PreparedIntercomMessage prepared) |
| 81 | { |
| 82 | var warnings = new List<string>(); |
| 83 | if (!TryBuildCore( |
| 84 | rawInput, |
| 85 | pendingByShortId, |
| 86 | editor, |
| 87 | workspaceRoot, |
| 88 | solutionPath, |
| 89 | indexDirectoryRelative: null, |
| 90 | profile, |
| 91 | warnings, |
| 92 | out var outbound, |
| 93 | out var error)) |
| 94 | { |
| 95 | prepared = new PreparedIntercomMessage( |
| 96 | IntercomMessagePrepareStatus.Failed, |
| 97 | outbound, |
| 98 | warnings, |
| 99 | error); |
| 100 | return false; |
| 101 | } |
| 102 | |
| 103 | if (profile.AddMcpFastPathWarning && outbound.Attachments.Count > 0) |
| 104 | warnings.Add("MCP fast-path (F/M/L/S): excerpt @ send отложен; member — single-file Roslyn, иначе re-resolve при клике."); |
| 105 | |
| 106 | return finishPrepare(outbound, warnings, out prepared); |
| 107 | } |
| 108 | |
| 109 | private static bool finishPrepare( |
| 110 | Outbound outbound, |
| 111 | List<string> warnings, |
| 112 | out PreparedIntercomMessage prepared) |
| 113 | { |
| 114 | var hasDegraded = outbound.Attachments.Any(static a => |
| 115 | string.Equals( |
| 116 | a.ResolveOutcome, |
| 117 | IntercomAttachmentRevealPlan.OutcomeMemberNotFound, |
| 118 | StringComparison.Ordinal)); |
| 119 | |
| 120 | if (hasDegraded && !warnings.Any(static w => w.Contains("re-resolve", StringComparison.OrdinalIgnoreCase))) |
| 121 | warnings.Add("Часть вложений собрана без строк Roslyn — reveal попробует re-resolve."); |
| 122 | |
| 123 | var status = hasDegraded |
| 124 | ? IntercomMessagePrepareStatus.PartialSuccess |
| 125 | : IntercomMessagePrepareStatus.Success; |
| 126 | |
| 127 | prepared = new PreparedIntercomMessage(status, outbound, warnings, null); |
| 128 | return true; |
| 129 | } |
| 130 | |
| 131 | private static bool TryBuildCore( |
| 132 | string rawInput, |
| 133 | IReadOnlyDictionary<string, AttachmentAnchor> pendingByShortId, |
| 134 | IntercomAttachmentResolveAtSend.EditorSnapshot editor, |
| 135 | string? workspaceRoot, |
| 136 | string? solutionPath, |
| 137 | string? indexDirectoryRelative, |
| 138 | IntercomOutboundPrepareProfile profile, |
| 139 | List<string>? warnings, |
| 140 | out Outbound outbound, |
| 141 | out string error) |
| 142 | { |
| 143 | outbound = new Outbound("", [], null); |
| 144 | error = ""; |
| 145 | |
| 146 | var trimmed = rawInput.Trim(); |
| 147 | if (trimmed.Length == 0) |
| 148 | { |
| 149 | error = "Пустое сообщение."; |
| 150 | return false; |
| 151 | } |
| 152 | |
| 153 | workspaceRoot = coalesceWorkspaceRoot(workspaceRoot, solutionPath); |
| 154 | |
| 155 | var resolveSession = profile.SkipMemberRoslynAtSend ? null : new IntercomAttachmentRoslynResolveSession(); |
| 156 | var segments = ChatMessageBodyPresentation.SplitSegments(trimmed); |
| 157 | var attachments = new List<AttachmentAnchor>(); |
| 158 | var rebuilt = new System.Text.StringBuilder(); |
| 159 | |
| 160 | foreach (var segment in segments) |
| 161 | { |
| 162 | if (segment.Kind == ChatMessageBodySegmentKind.Code) |
| 163 | { |
| 164 | rebuilt.Append(segment.Text); |
| 165 | continue; |
| 166 | } |
| 167 | |
| 168 | if (!tryProcessProseSegment( |
| 169 | segment.Text, |
| 170 | pendingByShortId, |
| 171 | editor, |
| 172 | workspaceRoot, |
| 173 | solutionPath, |
| 174 | indexDirectoryRelative, |
| 175 | resolveSession, |
| 176 | profile.AllowDegradedMemberResolve, |
| 177 | profile.SkipMemberRoslynAtSend, |
| 178 | warnings, |
| 179 | attachments, |
| 180 | rebuilt, |
| 181 | out error)) |
| 182 | { |
| 183 | return false; |
| 184 | } |
| 185 | } |
| 186 | |
| 187 | var content = rebuilt.ToString(); |
| 188 | IntercomAttachmentMarkers.UpdateProseOffsets(content, attachments); |
| 189 | |
| 190 | var senderContext = profile.CaptureSenderWorkspaceContext |
| 191 | ? IntercomSenderWorkspaceContextCapture.TryCapture(workspaceRoot, solutionPath) |
| 192 | : null; |
| 193 | outbound = new Outbound(content, attachments, senderContext); |
| 194 | return true; |
| 195 | } |
| 196 | |
| 197 | private static string? coalesceWorkspaceRoot(string? workspaceRoot, string? solutionPath) |
| 198 | { |
| 199 | if (!string.IsNullOrWhiteSpace(workspaceRoot)) |
| 200 | return workspaceRoot.Trim(); |
| 201 | var dir = WorkspaceDirectoryFromSolutionPath.Resolve(solutionPath ?? ""); |
| 202 | return dir.Length > 0 ? dir : null; |
| 203 | } |
| 204 | |
| 205 | private static bool tryProcessProseSegment( |
| 206 | string prose, |
| 207 | IReadOnlyDictionary<string, AttachmentAnchor> pendingByShortId, |
| 208 | IntercomAttachmentResolveAtSend.EditorSnapshot editor, |
| 209 | string? workspaceRoot, |
| 210 | string? solutionPath, |
| 211 | string? indexDirectoryRelative, |
| 212 | IntercomAttachmentRoslynResolveSession? resolveSession, |
| 213 | bool allowDegradedMemberResolve, |
| 214 | bool skipMemberRoslynAtSend, |
| 215 | List<string>? warnings, |
| 216 | List<AttachmentAnchor> attachments, |
| 217 | System.Text.StringBuilder rebuilt, |
| 218 | out string error) |
| 219 | { |
| 220 | error = ""; |
| 221 | var spans = new List<(int Start, int Length, string Inner, bool IsBracket)>(); |
| 222 | |
| 223 | foreach (System.Text.RegularExpressions.Match m in System.Text.RegularExpressions.Regex.Matches( |
| 224 | prose, |
| 225 | @"⟦a:(?<id>[0-9a-f]{8})⟧")) |
| 226 | { |
| 227 | spans.Add((m.Index, m.Length, m.Groups["id"].Value, false)); |
| 228 | } |
| 229 | |
| 230 | if (IntercomAttachmentMarkers.TryExtractBracketSpans(prose, out var brackets)) |
| 231 | { |
| 232 | foreach (var (start, length, inner) in brackets) |
| 233 | spans.Add((start, length, inner, true)); |
| 234 | } |
| 235 | |
| 236 | spans.Sort((a, b) => a.Start.CompareTo(b.Start)); |
| 237 | var last = 0; |
| 238 | foreach (var span in spans) |
| 239 | { |
| 240 | if (span.Start < last) |
| 241 | continue; |
| 242 | |
| 243 | if (span.Start > last) |
| 244 | rebuilt.Append(prose[last..span.Start]); |
| 245 | |
| 246 | if (span.IsBracket) |
| 247 | { |
| 248 | if (!BracketCodeReferenceParser.TryParse(span.Inner, out var reference, out error)) |
| 249 | return false; |
| 250 | |
| 251 | if (!IntercomAttachmentResolveAtSend.TryResolveBracketDraft( |
| 252 | reference, |
| 253 | editor.CurrentFilePath, |
| 254 | workspaceRoot, |
| 255 | solutionPath, |
| 256 | indexDirectoryRelative, |
| 257 | out var draft, |
| 258 | out error)) |
| 259 | { |
| 260 | return false; |
| 261 | } |
| 262 | |
| 263 | var shortId = IntercomAttachmentMarkers.NewShortId(); |
| 264 | if (!IntercomAttachmentResolveAtSend.TryAssignIdAndResolve( |
| 265 | draft, |
| 266 | shortId, |
| 267 | workspaceRoot, |
| 268 | solutionPath, |
| 269 | resolveSession, |
| 270 | allowDegradedMemberResolve, |
| 271 | skipMemberRoslynAtSend, |
| 272 | out var resolved, |
| 273 | out error)) |
| 274 | { |
| 275 | return false; |
| 276 | } |
| 277 | |
| 278 | if (allowDegradedMemberResolve |
| 279 | && string.Equals( |
| 280 | resolved.ResolveOutcome, |
| 281 | IntercomAttachmentRevealPlan.OutcomeMemberNotFound, |
| 282 | StringComparison.Ordinal)) |
| 283 | { |
| 284 | warnings?.Add( |
| 285 | $"Вложение «{resolved.DisplayLabel}»: member не разрешён @ send, будет re-resolve при клике."); |
| 286 | } |
| 287 | |
| 288 | attachments.Add(resolved); |
| 289 | rebuilt.Append(IntercomAttachmentMarkers.FormatMarker(shortId)); |
| 290 | } |
| 291 | else |
| 292 | { |
| 293 | if (!pendingByShortId.TryGetValue(span.Inner, out var pending)) |
| 294 | { |
| 295 | error = $"Неизвестный attach id «{span.Inner}» в черновике."; |
| 296 | return false; |
| 297 | } |
| 298 | |
| 299 | var shortId = span.Inner; |
| 300 | if (!IntercomAttachmentResolveAtSend.TryAssignIdAndResolve( |
| 301 | pending, |
| 302 | shortId, |
| 303 | workspaceRoot, |
| 304 | solutionPath, |
| 305 | resolveSession, |
| 306 | allowDegradedMemberResolve, |
| 307 | skipMemberRoslynAtSend, |
| 308 | out var resolved, |
| 309 | out error)) |
| 310 | { |
| 311 | return false; |
| 312 | } |
| 313 | |
| 314 | attachments.Add(resolved); |
| 315 | rebuilt.Append(IntercomAttachmentMarkers.FormatMarker(shortId)); |
| 316 | } |
| 317 | |
| 318 | last = span.Start + span.Length; |
| 319 | } |
| 320 | |
| 321 | if (last < prose.Length) |
| 322 | rebuilt.Append(prose[last..]); |
| 323 | |
| 324 | return true; |
| 325 | } |
| 326 | } |
| 327 | |