Forge
csharpdeeb25a2
1#nullable enable
2
3using CascadeIDE.Features.Chat.Application;
4using CascadeIDE.Features.Workspace.Application;
5using CascadeIDE.Models.Intercom;
6using CascadeIDE.Services;
7using CascadeIDE.Services.Intercom;
8
9namespace CascadeIDE.Features.Chat;
10
11/// <summary>Сборка исходящего сообщения: prose, bracket → markers, attachments @ send (ADR 0128, 0134).</summary>
12public 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
View only · write via MCP/CIDE