Forge
csharpdeeb25a2
1using System.Globalization;
2using System.Text;
3using System.Text.Json;
4
5using CascadeIDE.Models.Editor;
6
7namespace CascadeIDE.Features.WebAiPortal.Application;
8
9/// <summary>
10/// Текст для подмешивания в веб-чат/буфер: при лимите длины — сводка и короткие строки команд моста (без JSON), плюс напоминание про json-cascade.
11/// </summary>
12public static class WebAiPortalChatMixInFormatter
13{
14 public const int DefaultMaxChatCharacters = 1_200;
15
16 public readonly record struct MixInForComposer(bool UsedCompactMixer, string TextForComposer);
17
18 private static readonly JsonSerializerOptions JsonRelaxed =
19 new() { PropertyNameCaseInsensitive = true };
20
21 private static readonly JsonSerializerOptions JsonSnakeNaming =
22 new()
23 {
24 PropertyNameCaseInsensitive = true,
25 PropertyNamingPolicy = JsonNamingPolicy.SnakeCaseLower,
26 };
27
28 private const string BridgeFooter =
29 "Мост: один блок json-cascade; command_id и параметры — как в строках ▸ (см. промпт ADR 0108).";
30
31 private sealed class SearchResultSnap
32 {
33 public string? Err { get; set; }
34 public string? Query { get; set; }
35 public List<HitSnap>? Hits { get; set; }
36 }
37
38 private sealed class HitSnap
39 {
40 public long HitId { get; set; }
41 public string? Path { get; set; }
42 public int LineStart { get; set; }
43 public int LineEnd { get; set; }
44 public string? Snippet { get; set; }
45 }
46
47 private sealed class StatusResultSnap
48 {
49 public bool DatabaseExists { get; set; }
50 public int DocumentCount { get; set; }
51 public bool DocumentCountMayBeStale { get; set; }
52 public string? IndexedAtIso { get; set; }
53 public string? WorkspaceRoot { get; set; }
54 public string? LastReindexError { get; set; }
55 public EffectiveSnap? EffectiveSettings { get; set; }
56 }
57
58 private sealed class EffectiveSnap
59 {
60 public List<string>? EffectiveExtensions { get; set; }
61 }
62
63 /// <summary>Снимок ответа <c>get_editor_content_range</c>: канонический диапазон строк + контент.</summary>
64 private sealed record EditorRangeSnap(string? FilePath, LineRange Lines, string Content);
65
66 private sealed class EditorRangeSerdeDto
67 {
68 public string? FilePath { get; set; }
69
70 public int StartLine { get; set; }
71
72 public int EndLine { get; set; }
73
74 public string? Content { get; set; }
75 }
76
77 /// <summary>
78 /// Текст для буфера/вставки в композитор. При <paramref name="preferCompact"/> и превышении лимита — компактная подсказка вместо полного ответа.
79 /// </summary>
80 public static MixInForComposer BuildForComposer(
81 bool preferCompact,
82 int maxChatCharacters,
83 string? executedCommandId,
84 string fullResponseText)
85 {
86 if (!preferCompact || fullResponseText.Length <= maxChatCharacters)
87 return new MixInForComposer(false, fullResponseText);
88
89 if (!string.IsNullOrEmpty(executedCommandId)
90 && string.Equals(executedCommandId, IdeCommands.CodebaseIndexSearch, StringComparison.Ordinal)
91 && TryParseSearchResult(fullResponseText, out var searchSnap)
92 && searchSnap is not null)
93 return new MixInForComposer(true, BuildCompactAfterCodebaseSearch(searchSnap, fullResponseText.Length));
94
95 if (!string.IsNullOrEmpty(executedCommandId)
96 && string.Equals(executedCommandId, IdeCommands.CodebaseIndexStatus, StringComparison.Ordinal)
97 && TryParseStatusResult(fullResponseText, out var statusSnap)
98 && statusSnap is not null)
99 return new MixInForComposer(true, BuildCompactAfterStatus(statusSnap, fullResponseText.Length));
100
101 if (!string.IsNullOrEmpty(executedCommandId)
102 && string.Equals(executedCommandId, IdeCommands.GetEditorContentRange, StringComparison.Ordinal))
103 {
104 if (TryParseEditorRange(fullResponseText, out var rangeSnap) && rangeSnap is not null)
105 return new MixInForComposer(true, BuildCompactAfterEditorRange(rangeSnap, fullResponseText.Length));
106 if (looksLikeHugePlainTextFallback(fullResponseText))
107 return new MixInForComposer(true, BuildCompactPlainTextSnippet(fullResponseText, IdeCommands.GetEditorContentRange));
108 }
109
110 if (!string.IsNullOrEmpty(executedCommandId)
111 && string.Equals(executedCommandId, IdeCommands.GetEditorState, StringComparison.Ordinal)
112 && TryParseEditorState(fullResponseText, out var stParsed)
113 && stParsed is { } edited)
114 return new MixInForComposer(true, BuildCompactAfterEditorState(edited, fullResponseText.Length));
115
116 return new MixInForComposer(true, BuildGenericOversizedHint(executedCommandId, fullResponseText.Length));
117 }
118
119 private static bool looksLikeHugePlainTextFallback(string text)
120 {
121 if (text.Length <= DefaultMaxChatCharacters || text.TrimStart().StartsWith('{'))
122 return false;
123 return true;
124 }
125
126 private static string BuildCompactPlainTextSnippet(string slab, string? commandId)
127 {
128 var who = commandId ?? "ответ";
129 const int preview = 620;
130 var body = NormalizeOneLinePreview(slab, preview);
131 return $"""
132Сырой текст ({who}) слишком длинный для веб-чата (~{slab.Length} симв.).
133Начало (усечено):
134---
135{body}
136---
137Узкий диапазон через мост или get_editor_state без превью.
138
139{BridgeLine(IdeCommands.GetEditorState, new Dictionary<string, object?> { ["max_preview_chars"] = 0 })}
140{BridgeFooter}
141""";
142 }
143
144 private static bool TryParseSearchResult(string json, out SearchResultSnap? snap)
145 {
146 snap = null;
147 try
148 {
149 snap = JsonSerializer.Deserialize<SearchResultSnap>(json, JsonRelaxed);
150 return snap is not null;
151 }
152 catch
153 {
154 return false;
155 }
156 }
157
158 private static bool TryParseStatusResult(string json, out StatusResultSnap? snap)
159 {
160 snap = null;
161 try
162 {
163 snap = JsonSerializer.Deserialize<StatusResultSnap>(json, JsonRelaxed);
164 return snap is not null;
165 }
166 catch
167 {
168 return false;
169 }
170 }
171
172 /// <summary>JSON вида IdeMcpEditorOrchestrator: snake_case ключи или camelCase после стандартного Serialize.</summary>
173 private static bool TryParseEditorRange(string json, out EditorRangeSnap? snap)
174 {
175 snap = null;
176 EditorRangeSerdeDto? dto = null;
177 try
178 {
179 dto = JsonSerializer.Deserialize<EditorRangeSerdeDto>(json, JsonSnakeNaming);
180 if (dto is not null && trySnapFromDto(dto, out snap))
181 return true;
182 dto = JsonSerializer.Deserialize<EditorRangeSerdeDto>(json, JsonRelaxed);
183 if (dto is not null && trySnapFromDto(dto, out snap))
184 return true;
185 }
186 catch
187 {
188 // пробуем ручной разбор ниже
189 }
190
191 try
192 {
193 using var doc = JsonDocument.Parse(json);
194 var root = doc.RootElement;
195 if (!TryGetLengthyString(root, "content", out var contentText) || string.IsNullOrEmpty(contentText))
196 return false;
197 _ = TryGetString(root, "file_path", out var fp)
198 || TryGetString(root, "FilePath", out fp);
199 var sl = TryGetInt(root, "start_line", out var s0) ? s0 : (TryGetInt(root, "StartLine", out var s1) ? s1 : 1);
200 var el = TryGetInt(root, "end_line", out var e0) ? e0 : (TryGetInt(root, "EndLine", out var e1) ? e1 : sl);
201 return tryEditorRangeFromParts(fp, sl, el, contentText, out snap);
202 }
203 catch
204 {
205 return false;
206 }
207
208 static bool trySnapFromDto(EditorRangeSerdeDto d, out EditorRangeSnap? s) =>
209 tryEditorRangeFromParts(d.FilePath, d.StartLine, d.EndLine, d.Content ?? "", out s);
210
211 static bool tryEditorRangeFromParts(string? fp, int startRaw, int endRaw, string content, out EditorRangeSnap? s)
212 {
213 s = null;
214 if (string.IsNullOrEmpty(content))
215 return false;
216 var lo = Math.Min(startRaw, endRaw);
217 var hi = Math.Max(startRaw, endRaw);
218 if (!LineNumber.TryCreate(lo, out var lnLo) || !LineNumber.TryCreate(hi, out var lnHi)
219 || !LineRange.TryCreate(lnLo, lnHi, out var lines))
220 return false;
221
222 s = new EditorRangeSnap(fp, lines, content);
223 return true;
224 }
225 }
226
227 private static bool TryGetString(JsonElement root, string name, out string? value)
228 {
229 value = null;
230 if (!root.TryGetProperty(name, out var e))
231 return false;
232 value = e.GetString();
233 return true;
234 }
235
236 private static bool TryGetInt(JsonElement root, string name, out int v)
237 {
238 v = 0;
239 if (!root.TryGetProperty(name, out var e))
240 return false;
241 return e.TryGetInt32(out v);
242 }
243
244 private static bool TryGetLengthyString(JsonElement root, string name, out string? value)
245 {
246 value = null;
247 if (!root.TryGetProperty(name, out var e))
248 return false;
249 value = e.GetString();
250 return !string.IsNullOrEmpty(value);
251 }
252
253 private static string BuildCompactAfterCodebaseSearch(SearchResultSnap s, int rawLength)
254 {
255 var hits = s.Hits ?? [];
256 var sb = new StringBuilder(capacity: 512);
257 sb.Append($"HCI search ~{rawLength} симв.: сводка; сырой JSON в чат не дублировать.\n");
258 sb.Append($"Запрос: \"{TruncateOneLine(s.Query ?? "", 140)}\"\n");
259 if (!string.IsNullOrEmpty(s.Err))
260 sb.Append($"Ошибка: {TruncateOneLine(s.Err, 200)}\n");
261 sb.Append($"Попаданий: {hits.Count}.\n");
262 sb.Append("\nСписок (потом один explain на hit_id):\n");
263
264 var n = Math.Min(hits.Count, 12);
265 for (var i = 0; i < n; i++)
266 {
267 var h = hits[i];
268 var sn = NormalizeOneLinePreview(h.Snippet, 100);
269 sb.Append($"{i + 1}) hit_id={h.HitId} ");
270 sb.Append($"{TruncateOneLine(h.Path ?? "", 90)}");
271 sb.Append($" L{h.LineStart}-{h.LineEnd}");
272 if (sn.Length > 0)
273 sb.Append($" ‹{sn}›");
274 sb.Append('\n');
275 }
276
277 if (hits.Count > n)
278 sb.Append($"… ещё {hits.Count - n} (узкий search top_n=4 path_prefix=…).\n");
279
280 var sampleHitId = hits.Count > 0 ? hits[0].HitId : 0L;
281 sb.Append("\nДальше один шаг (подставь hit_id из таблицы):\n");
282 sb.Append(BridgeLine(IdeCommands.CodebaseIndexExplain, new Dictionary<string, object?> { ["hit_id"] = sampleHitId }));
283 sb.Append("(пример по первой строке)\n\n");
284 sb.Append("Открытый файл без поиска — ");
285 sb.Append(BridgeLine(IdeCommands.GetEditorContentRange, new Dictionary<string, object?> { ["start_line"] = 1, ["end_line"] = 40 }).TrimEnd());
286 sb.Append('\n');
287 sb.Append(BridgeFooter);
288 sb.Append('\n');
289 return sb.ToString();
290 }
291
292 private static string BuildCompactAfterStatus(StatusResultSnap st, int rawLength)
293 {
294 var stale = st.DocumentCountMayBeStale ? ", возм. устарел" : "";
295 var err = string.IsNullOrEmpty(st.LastReindexError)
296 ? ""
297 : $"\nОшибка reindex: {TruncateOneLine(st.LastReindexError, 160)}";
298 var ws = TruncateOneLine(st.WorkspaceRoot ?? "—", 140);
299 var indexed = TruncateOneLine(st.IndexedAtIso ?? "—", 48);
300 var extN = st.EffectiveSettings?.EffectiveExtensions?.Count ?? 0;
301
302 return $"""
303HCI status ~{rawLength} симв.:
304БД {(st.DatabaseExists ? "ok" : "нет")}{stale}; docs {st.DocumentCount}; индекс {indexed}
305workspace {ws}; расширений FTS: {extN}{err}
306
307Следующий шаг:
308{BridgeLine(IdeCommands.CodebaseIndexSearch, new Dictionary<string, object?> { ["query"] = ".", ["top_n"] = 6 })}
309{BridgeFooter}
310""";
311 }
312
313 private static string BuildCompactAfterEditorRange(EditorRangeSnap range, int rawLength)
314 {
315 var content = range.Content ?? "";
316 const int previewLimit = 700;
317 var preview = content.Length <= previewLimit
318 ? content
319 : content[..previewLimit];
320 preview = NormalizeOneLinePreview(preview, preview.Length);
321 var path = string.IsNullOrWhiteSpace(range.FilePath) ? "(файл)" : range.FilePath!;
322 var start = range.Lines.Start.Value;
323 var end = range.Lines.End.Value;
324 var half = Math.Max(1, (end - start + 1) / 2);
325 var narrowEnd = Math.Min(end, start + half - 1);
326 narrowEnd = Math.Max(narrowEnd, start);
327
328 return $"""
329Диапазон ~{rawLength} симв.: {path}, L{start}–{end}
330Превью (одна строка): {preview}{(content.Length > previewLimit ? $" …+{content.Length - previewLimit}" : "")}
331
332Уже меньше охват:
333{BridgeLine(IdeCommands.GetEditorContentRange, new Dictionary<string, object?> { ["start_line"] = start, ["end_line"] = narrowEnd })}
334{BridgeFooter}
335""";
336 }
337
338 private static bool TryParseEditorState(string json, out EditorStateDto? dto)
339 {
340 dto = null;
341 try
342 {
343 dto = JsonSerializer.Deserialize<EditorStateDto>(json, JsonRelaxed);
344 return dto is not null;
345 }
346 catch
347 {
348 return false;
349 }
350 }
351
352 private static string BuildCompactAfterEditorState(EditorStateDto st, int rawLength)
353 {
354 var path = string.IsNullOrWhiteSpace(st.FilePath) ? "(нет пути)" : st.FilePath!;
355 var basename = TryBasename(st.FilePath, out var bn) ? bn : path;
356 var queryToken = basename.Length > 48 ? basename[..48] : basename;
357 var startLine = Math.Max(1, st.CaretLine - 10);
358 var endLine = Math.Max(st.CaretLine, st.CaretLine + 22);
359
360 var sb = new StringBuilder();
361 sb.Append($"get_editor_state ~{rawLength} симв.: сводка.\n");
362 sb.Append($"Файл: {path}; каретка {st.CaretLine}:{st.CaretColumn}; размер {st.ContentLength}.\n");
363 sb.Append("Дальше по одному (можно в этом порядке):\n");
364 sb.Append(BridgeLine(IdeCommands.CodebaseIndexStatus, []));
365 sb.Append(BridgeLine(IdeCommands.CodebaseIndexSearch, new Dictionary<string, object?> { ["query"] = queryToken, ["top_n"] = 8 }));
366 sb.Append(BridgeLine(IdeCommands.GetEditorState, new Dictionary<string, object?> { ["max_preview_chars"] = 0 }));
367 sb.Append(BridgeLine(IdeCommands.GetEditorContentRange, new Dictionary<string, object?> { ["start_line"] = startLine, ["end_line"] = endLine }));
368 sb.Append('\n');
369 sb.Append(BridgeFooter);
370 sb.Append('\n');
371 return sb.ToString();
372 }
373
374 private static string BuildGenericOversizedHint(string? commandId, int rawLength)
375 {
376 var who = string.IsNullOrEmpty(commandId) ? "ответ" : commandId;
377
378 return $"""
379Ответ IDE `{who}` ~{rawLength} симв. — в чат без сырого JSON.
380
381Порциями:
382{BridgeLine(IdeCommands.CodebaseIndexStatus, [])}{BridgeLine(IdeCommands.CodebaseIndexSearch, new Dictionary<string, object?> { ["query"] = "символ или тип", ["top_n"] = 8 })}
383Редактор без лишнего: {BridgeLine(IdeCommands.GetEditorState, new Dictionary<string, object?> { ["max_preview_chars"] = 0 }).Trim()}
384После HCI search: {BridgeLine(IdeCommands.CodebaseIndexExplain, new Dictionary<string, object?> { ["hit_id"] = 123L }).Trim()} (hit_id из таблицы попаданий)
385
386{BridgeFooter}
387""";
388 }
389
390 /// <summary>Одна строка вида <c>▸ command_id k=v …</c> (без JSON).</summary>
391 private static string BridgeLine(string commandId, Dictionary<string, object?> args)
392 {
393 var sb = new StringBuilder();
394 sb.Append("▸ ").Append(commandId);
395 foreach (var kv in args.OrderBy(static x => x.Key, StringComparer.Ordinal))
396 {
397 if (kv.Value is null)
398 continue;
399 sb.Append(' ').Append(kv.Key).Append('=');
400 AppendBridgeArg(sb, kv.Value);
401 }
402
403 sb.Append('\n');
404 return sb.ToString();
405 }
406
407 private static void AppendBridgeArg(StringBuilder sb, object value)
408 {
409 switch (value)
410 {
411 case string s:
412 if (s.AsSpan().ContainsAny(" \t\"".AsSpan()) || s.Length == 0)
413 sb.Append('"').Append(s.Replace("\"", "\\\"", StringComparison.Ordinal)).Append('"');
414 else
415 sb.Append(s);
416 break;
417 case long l:
418 sb.Append(l.ToString(CultureInfo.InvariantCulture));
419 break;
420 case int i:
421 sb.Append(i.ToString(CultureInfo.InvariantCulture));
422 break;
423 case double d:
424 sb.Append(d.ToString(CultureInfo.InvariantCulture));
425 break;
426 case bool b:
427 sb.Append(b ? "true" : "false");
428 break;
429 default:
430 sb.Append(Convert.ToString(value, CultureInfo.InvariantCulture));
431 break;
432 }
433 }
434
435 private static bool TryBasename(string? path, out string basename)
436 {
437 basename = "";
438 if (string.IsNullOrWhiteSpace(path))
439 return false;
440 try
441 {
442 basename = Path.GetFileNameWithoutExtension(path.Trim()) ?? "";
443 return basename.Length > 0;
444 }
445 catch
446 {
447 return false;
448 }
449 }
450
451 private static string TruncateOneLine(string? text, int maxChars) =>
452 NormalizeOneLinePreview(text ?? "", maxChars);
453
454 /// <summary>Однострочный препросмотр: перевод строк → пробел, схлопывание пробелов.</summary>
455 private static string NormalizeOneLinePreview(string? text, int maxChars)
456 {
457 if (string.IsNullOrEmpty(text) || maxChars <= 0)
458 return "";
459 var flattened = text.Replace("\r\n", "\n", StringComparison.Ordinal).Replace('\r', ' ').Replace('\n', ' ');
460 flattened = flattened.Replace('\t', ' ');
461 while (flattened.Contains(" ", StringComparison.Ordinal))
462 flattened = flattened.Replace(" ", " ", StringComparison.Ordinal);
463 flattened = flattened.Trim();
464 if (flattened.Length <= maxChars)
465 return flattened;
466 return flattened[..Math.Max(0, maxChars - 1)].TrimEnd() + "…";
467 }
468}
469
View only · write via MCP/CIDE