Files
AX-Copilot-Codex/src/AxCopilot/Services/ChatSessionStateService.cs

781 lines
30 KiB
C#

using AxCopilot.Models;
namespace AxCopilot.Services;
/// <summary>
/// 채팅 창 외부에 유지되는 세션 상태.
/// 활성 탭과 탭별 마지막 대화 ID를 관리해 창 재생성/재로딩에도 상태를 복원합니다.
/// </summary>
public sealed class ChatSessionStateService
{
private const int MaxExecutionEventHistory = 400;
private const int MaxAgentRunHistory = 12;
private readonly DraftQueueService _draftQueue = new();
public sealed class RuntimeActivity
{
public string Key { get; init; } = "";
public string Kind { get; init; } = "";
public string Title { get; set; } = "";
public string Summary { get; set; } = "";
public DateTime StartedAt { get; set; } = DateTime.Now;
}
public string ActiveTab { get; set; } = "Chat";
public ChatConversation? CurrentConversation { get; set; }
public bool IsStreaming { get; set; }
public string StatusText { get; set; } = "대기 중";
public bool IsStatusSpinning { get; set; }
public string LastCompletedSummary { get; set; } = "";
public List<RuntimeActivity> ActiveRuntimeActivities { get; } = new();
public Dictionary<string, string?> TabConversationIds { get; } = new(StringComparer.OrdinalIgnoreCase)
{
["Chat"] = null,
["Cowork"] = null,
["Code"] = null,
};
public ChatConversation EnsureCurrentConversation(string tab)
{
var normalizedTab = NormalizeTab(tab);
if (CurrentConversation == null
|| !string.Equals(NormalizeTab(CurrentConversation.Tab), normalizedTab, StringComparison.OrdinalIgnoreCase))
{
CurrentConversation = new ChatConversation { Tab = normalizedTab };
}
if (string.IsNullOrWhiteSpace(CurrentConversation.Tab)
|| !string.Equals(NormalizeTab(CurrentConversation.Tab), normalizedTab, StringComparison.OrdinalIgnoreCase))
CurrentConversation.Tab = normalizedTab;
return CurrentConversation;
}
public void Load(SettingsService settings)
{
var llm = settings.Settings.Llm;
ActiveTab = NormalizeTab(llm.LastActiveTab);
foreach (var key in TabConversationIds.Keys.ToList())
TabConversationIds[key] = null;
foreach (var kv in llm.LastConversationIds)
{
var normalized = NormalizeTab(kv.Key);
if (TabConversationIds.ContainsKey(normalized) && !string.IsNullOrWhiteSpace(kv.Value))
TabConversationIds[normalized] = kv.Value;
}
}
public void Save(SettingsService settings)
{
var llm = settings.Settings.Llm;
llm.LastActiveTab = NormalizeTab(ActiveTab);
var snapshot = new Dictionary<string, string>(StringComparer.OrdinalIgnoreCase);
foreach (var kv in TabConversationIds)
{
if (!string.IsNullOrWhiteSpace(kv.Value))
snapshot[kv.Key] = kv.Value!;
}
llm.LastConversationIds = snapshot;
settings.Save();
}
public void RememberConversation(string tab, string? conversationId)
{
TabConversationIds[NormalizeTab(tab)] = string.IsNullOrWhiteSpace(conversationId) ? null : conversationId;
}
public ChatConversation LoadOrCreateConversation(string tab, ChatStorageService storage, SettingsService settings)
{
var normalizedTab = NormalizeTab(tab);
var rememberedId = GetConversationId(normalizedTab);
var hadRememberedConversation = !string.IsNullOrWhiteSpace(rememberedId);
if (!string.IsNullOrWhiteSpace(rememberedId))
{
var loaded = storage.Load(rememberedId);
if (loaded != null)
{
if (string.IsNullOrWhiteSpace(loaded.Tab))
loaded.Tab = normalizedTab;
if (string.Equals(NormalizeTab(loaded.Tab), normalizedTab, StringComparison.OrdinalIgnoreCase))
{
var normalized = NormalizeLoadedConversation(loaded);
CurrentConversation = loaded;
if (normalized)
try { storage.Save(loaded); } catch { }
return loaded;
}
// 잘못 매핑된 탭 ID 방어: 교차 탭 대화 누적 방지
RememberConversation(normalizedTab, null);
}
else
RememberConversation(normalizedTab, null);
}
// 새 대화를 시작한 직후처럼 아직 저장되지 않은 현재 대화가 있으면,
// 최신 저장 대화로 되돌아가지 말고 그 임시 세션을 그대로 유지합니다.
if (CurrentConversation != null
&& string.Equals(NormalizeTab(CurrentConversation.Tab), normalizedTab, StringComparison.OrdinalIgnoreCase)
&& !HasPersistableContent(CurrentConversation))
{
return CurrentConversation;
}
if (!hadRememberedConversation)
{
var latestMeta = storage.LoadAllMeta()
.Where(c => string.Equals(NormalizeTab(c.Tab), normalizedTab, StringComparison.OrdinalIgnoreCase))
.OrderByDescending(c => c.Pinned)
.ThenByDescending(c => c.UpdatedAt)
.FirstOrDefault();
if (latestMeta != null)
{
var loaded = storage.Load(latestMeta.Id);
if (loaded != null)
{
loaded.Tab = normalizedTab;
var normalized = NormalizeLoadedConversation(loaded);
CurrentConversation = loaded;
RememberConversation(normalizedTab, loaded.Id);
if (normalized)
try { storage.Save(loaded); } catch { }
return loaded;
}
}
}
RememberConversation(normalizedTab, null);
return CreateFreshConversation(normalizedTab, settings);
}
public ChatConversation CreateFreshConversation(string tab, SettingsService settings)
{
var normalizedTab = NormalizeTab(tab);
var created = new ChatConversation { Tab = normalizedTab };
var workFolder = settings.Settings.Llm.WorkFolder;
if (!string.IsNullOrWhiteSpace(workFolder) && normalizedTab != "Chat")
created.WorkFolder = workFolder;
CurrentConversation = created;
return created;
}
public ChatConversation CreateBranchConversation(
ChatConversation source,
int atIndex,
int branchCount,
string? branchHint = null,
string? branchContextMessage = null,
string? branchContextRunId = null)
{
var branchLabel = string.IsNullOrWhiteSpace(branchHint)
? $"분기 {branchCount}"
: $"분기 {branchCount} · {Truncate(branchHint, 18)}";
var fork = new ChatConversation
{
Title = $"{source.Title} ({branchLabel})",
Tab = source.Tab,
Category = source.Category,
WorkFolder = source.WorkFolder,
SystemCommand = source.SystemCommand,
ParentId = source.Id,
BranchLabel = branchLabel,
BranchAtIndex = atIndex,
ConversationFailedOnlyFilter = source.ConversationFailedOnlyFilter,
ConversationRunningOnlyFilter = source.ConversationRunningOnlyFilter,
ConversationSortMode = source.ConversationSortMode,
AgentRunHistory = source.AgentRunHistory
.Select(x => new ChatAgentRunRecord
{
RunId = x.RunId,
Status = x.Status,
Summary = x.Summary,
LastIteration = x.LastIteration,
StartedAt = x.StartedAt,
UpdatedAt = x.UpdatedAt,
})
.ToList(),
};
for (var i = 0; i <= atIndex && i < source.Messages.Count; i++)
{
var m = source.Messages[i];
fork.Messages.Add(new ChatMessage
{
Role = m.Role,
Content = m.Content,
Timestamp = m.Timestamp,
MetaKind = m.MetaKind,
MetaRunId = m.MetaRunId,
Feedback = m.Feedback,
AttachedFiles = m.AttachedFiles?.ToList(),
Images = m.Images?.Select(img => new ImageAttachment
{
FileName = img.FileName,
MimeType = img.MimeType,
Base64 = img.Base64,
}).ToList(),
});
}
if (!string.IsNullOrWhiteSpace(branchContextMessage))
{
fork.Messages.Add(new ChatMessage
{
Role = "assistant",
Content = branchContextMessage.Trim(),
Timestamp = DateTime.Now,
MetaKind = "branch_context",
MetaRunId = branchContextRunId,
});
}
return fork;
}
public void SaveCurrentConversation(ChatStorageService storage, string tab)
{
var conv = CurrentConversation;
if (conv == null) return;
var conversationTab = NormalizeTab(conv.Tab);
if (!HasPersistableContent(conv))
{
RememberConversation(conversationTab, null);
return;
}
try { storage.Save(conv); } catch { }
RememberConversation(conversationTab, conv.Id);
}
public void ClearCurrentConversation(string tab)
{
CurrentConversation = null;
RememberConversation(tab, null);
}
public ChatConversation SetCurrentConversation(string tab, ChatConversation conversation, ChatStorageService? storage = null, bool remember = true)
{
var normalizedTab = NormalizeTab(tab);
conversation.Tab = normalizedTab;
NormalizeLoadedConversation(conversation);
CurrentConversation = conversation;
if (remember && !string.IsNullOrWhiteSpace(conversation.Id))
RememberConversation(normalizedTab, conversation.Id);
try { storage?.Save(conversation); } catch { }
return conversation;
}
public ChatMessage AppendMessage(string tab, ChatMessage message, ChatStorageService? storage = null, bool useForTitle = false)
{
var conv = EnsureCurrentConversation(tab);
conv.Messages.Add(message);
if (useForTitle && string.Equals(message.Role, "user", StringComparison.OrdinalIgnoreCase))
{
var userMessageCount = conv.Messages.Count(m => string.Equals(m.Role, "user", StringComparison.OrdinalIgnoreCase));
if (userMessageCount == 1 && !string.IsNullOrWhiteSpace(message.Content))
conv.Title = message.Content.Length > 30 ? message.Content[..30] + "…" : message.Content;
}
TouchConversation(storage, tab);
return message;
}
public ChatConversation UpdateConversationMetadata(
string tab,
Action<ChatConversation> apply,
ChatStorageService? storage = null,
bool ensureConversation = true)
{
var conv = ensureConversation ? EnsureCurrentConversation(tab) : (CurrentConversation ?? new ChatConversation { Tab = NormalizeTab(tab) });
apply(conv);
if (CurrentConversation == null || ensureConversation)
CurrentConversation = conv;
TouchConversation(storage, tab);
return conv;
}
public ChatConversation SaveConversationSettings(
string tab,
string? permission,
string? dataUsage,
string? outputFormat,
string? mood,
ChatStorageService? storage = null)
{
return UpdateConversationMetadata(tab, conv =>
{
conv.Permission = permission;
conv.DataUsage = dataUsage;
conv.OutputFormat = outputFormat;
conv.Mood = mood;
}, storage);
}
public bool RemoveLastAssistantMessage(string tab, ChatStorageService? storage = null)
{
var conv = CurrentConversation;
if (conv == null || conv.Messages.Count == 0)
return false;
if (!string.Equals(conv.Messages[^1].Role, "assistant", StringComparison.OrdinalIgnoreCase))
return false;
conv.Messages.RemoveAt(conv.Messages.Count - 1);
TouchConversation(storage, tab);
return true;
}
public bool UpdateUserMessageAndTrim(string tab, int userMessageIndex, string newText, ChatStorageService? storage = null)
{
var conv = CurrentConversation;
if (conv == null)
return false;
if (userMessageIndex < 0 || userMessageIndex >= conv.Messages.Count)
return false;
conv.Messages[userMessageIndex].Content = newText;
while (conv.Messages.Count > userMessageIndex + 1)
conv.Messages.RemoveAt(conv.Messages.Count - 1);
TouchConversation(storage, tab);
return true;
}
public bool UpdateMessageFeedback(string tab, ChatMessage message, string? feedback, ChatStorageService? storage = null)
{
var conv = CurrentConversation;
if (conv == null)
return false;
var index = conv.Messages.IndexOf(message);
if (index < 0)
return false;
conv.Messages[index].Feedback = string.IsNullOrWhiteSpace(feedback) ? null : feedback;
TouchConversation(storage, tab);
return true;
}
public ChatConversation AppendExecutionEvent(string tab, Agent.AgentEvent evt, ChatStorageService? storage = null)
{
var conv = EnsureCurrentConversation(tab);
conv.ExecutionEvents ??= new List<ChatExecutionEvent>();
var nextEvent = new ChatExecutionEvent
{
Timestamp = evt.Timestamp,
RunId = evt.RunId,
Type = evt.Type.ToString(),
ToolName = evt.ToolName,
Summary = evt.Summary,
FilePath = evt.FilePath,
Success = evt.Success,
StepCurrent = evt.StepCurrent,
StepTotal = evt.StepTotal,
Steps = evt.Steps?.ToList(),
ElapsedMs = evt.ElapsedMs,
InputTokens = evt.InputTokens,
OutputTokens = evt.OutputTokens,
};
if (conv.ExecutionEvents.Count > 0
&& IsNearDuplicateExecutionEvent(conv.ExecutionEvents[^1], nextEvent))
{
var existing = conv.ExecutionEvents[^1];
existing.Timestamp = nextEvent.Timestamp;
existing.ElapsedMs = Math.Max(existing.ElapsedMs, nextEvent.ElapsedMs);
existing.InputTokens = Math.Max(existing.InputTokens, nextEvent.InputTokens);
existing.OutputTokens = Math.Max(existing.OutputTokens, nextEvent.OutputTokens);
existing.StepCurrent = Math.Max(existing.StepCurrent, nextEvent.StepCurrent);
existing.StepTotal = Math.Max(existing.StepTotal, nextEvent.StepTotal);
existing.Steps = nextEvent.Steps ?? existing.Steps;
existing.Success = existing.Success && nextEvent.Success;
if (string.IsNullOrWhiteSpace(existing.FilePath))
existing.FilePath = nextEvent.FilePath;
if (!string.IsNullOrWhiteSpace(nextEvent.Summary) && nextEvent.Summary.Length >= existing.Summary.Length)
existing.Summary = nextEvent.Summary;
}
else
{
conv.ExecutionEvents.Add(nextEvent);
}
if (conv.ExecutionEvents.Count > MaxExecutionEventHistory)
conv.ExecutionEvents.RemoveRange(0, conv.ExecutionEvents.Count - MaxExecutionEventHistory);
TouchConversation(storage, tab);
return conv;
}
public ChatConversation AppendAgentRun(string tab, Agent.AgentEvent evt, string status, string summary, ChatStorageService? storage = null)
{
var conv = EnsureCurrentConversation(tab);
conv.AgentRunHistory ??= new List<ChatAgentRunRecord>();
var newRecord = new ChatAgentRunRecord
{
RunId = evt.RunId,
Status = status,
Summary = summary,
LastIteration = evt.Iteration,
StartedAt = evt.Timestamp,
UpdatedAt = DateTime.Now,
};
var existingIndex = conv.AgentRunHistory.FindIndex(x =>
!string.IsNullOrWhiteSpace(x.RunId) &&
string.Equals(x.RunId, newRecord.RunId, StringComparison.OrdinalIgnoreCase));
if (existingIndex >= 0)
{
conv.AgentRunHistory.RemoveAt(existingIndex);
conv.AgentRunHistory.Insert(0, newRecord);
}
else
conv.AgentRunHistory.Insert(0, newRecord);
if (conv.AgentRunHistory.Count > MaxAgentRunHistory)
conv.AgentRunHistory.RemoveRange(MaxAgentRunHistory, conv.AgentRunHistory.Count - MaxAgentRunHistory);
TouchConversation(storage, tab);
return conv;
}
public DraftQueueItem? EnqueueDraft(string tab, string text, string priority = "next", ChatStorageService? storage = null, string kind = "message")
{
var trimmed = text?.Trim() ?? "";
if (string.IsNullOrWhiteSpace(trimmed))
return null;
var conv = EnsureCurrentConversation(tab);
conv.DraftQueueItems ??= new List<DraftQueueItem>();
var item = _draftQueue.CreateItem(trimmed, priority, kind);
conv.DraftQueueItems.Add(item);
TouchConversation(storage, tab);
return item;
}
public DraftQueueItem? GetNextQueuedDraft(string tab)
{
var conv = EnsureCurrentConversation(tab);
return _draftQueue.GetNextQueuedItem(conv.DraftQueueItems);
}
public DraftQueueService.DraftQueueSummary GetDraftQueueSummary(string tab)
{
var conv = EnsureCurrentConversation(tab);
return _draftQueue.GetSummary(conv.DraftQueueItems);
}
public IReadOnlyList<DraftQueueItem> GetDraftQueueItems(string tab)
{
var conv = EnsureCurrentConversation(tab);
return (conv.DraftQueueItems ?? []).ToList();
}
public bool MarkDraftRunning(string tab, string draftId, ChatStorageService? storage = null)
=> UpdateDraftItem(tab, draftId, item => _draftQueue.MarkRunning(item), storage);
public bool MarkDraftCompleted(string tab, string draftId, ChatStorageService? storage = null)
=> UpdateDraftItem(tab, draftId, item => _draftQueue.MarkCompleted(item), storage);
public bool MarkDraftFailed(string tab, string draftId, string? error, ChatStorageService? storage = null)
=> UpdateDraftItem(tab, draftId, item => _draftQueue.MarkFailed(item, error), storage);
public bool ScheduleDraftRetry(string tab, string draftId, string? error, int maxAutoRetries = 3, ChatStorageService? storage = null)
{
return UpdateDraftItem(tab, draftId, item =>
{
if (item == null)
return false;
if (item.AttemptCount >= maxAutoRetries)
return _draftQueue.MarkFailed(item, error);
return _draftQueue.ScheduleRetry(item, error);
}, storage);
}
public bool ResetDraftToQueued(string tab, string draftId, ChatStorageService? storage = null)
=> UpdateDraftItem(tab, draftId, item => _draftQueue.ResetToQueued(item), storage);
public bool RemoveDraft(string tab, string draftId, ChatStorageService? storage = null)
{
if (string.IsNullOrWhiteSpace(draftId))
return false;
var conv = EnsureCurrentConversation(tab);
var removed = conv.DraftQueueItems?.RemoveAll(x => string.Equals(x.Id, draftId, StringComparison.OrdinalIgnoreCase)) ?? 0;
if (removed <= 0)
return false;
TouchConversation(storage, tab);
return true;
}
public bool ToggleExecutionHistory(string tab, ChatStorageService? storage = null)
{
var conv = EnsureCurrentConversation(tab);
conv.ShowExecutionHistory = !conv.ShowExecutionHistory;
TouchConversation(storage, tab);
return conv.ShowExecutionHistory;
}
public void SaveConversationListPreferences(string tab, bool failedOnly, bool runningOnly, bool sortByRecent, ChatStorageService? storage = null)
{
var conv = EnsureCurrentConversation(tab);
conv.ConversationFailedOnlyFilter = failedOnly;
conv.ConversationRunningOnlyFilter = runningOnly;
conv.ConversationSortMode = sortByRecent ? "recent" : "activity";
TouchConversation(storage, tab);
}
public void SetRuntimeState(bool isStreaming, string statusText, bool isStatusSpinning)
{
IsStreaming = isStreaming;
StatusText = string.IsNullOrWhiteSpace(statusText) ? "대기 중" : statusText;
IsStatusSpinning = isStatusSpinning;
}
public void UpsertRuntimeActivity(string key, string kind, string title, string summary)
{
var existing = ActiveRuntimeActivities.FirstOrDefault(x => string.Equals(x.Key, key, StringComparison.OrdinalIgnoreCase));
if (existing == null)
{
ActiveRuntimeActivities.Add(new RuntimeActivity
{
Key = key,
Kind = kind,
Title = title,
Summary = summary,
StartedAt = DateTime.Now,
});
return;
}
existing.Title = title;
existing.Summary = summary;
}
public void RemoveRuntimeActivity(string key, string? completionSummary = null)
{
ActiveRuntimeActivities.RemoveAll(x => string.Equals(x.Key, key, StringComparison.OrdinalIgnoreCase));
if (!string.IsNullOrWhiteSpace(completionSummary))
LastCompletedSummary = completionSummary;
}
public void ClearRuntimeActivities(string? completionSummary = null)
{
ActiveRuntimeActivities.Clear();
if (!string.IsNullOrWhiteSpace(completionSummary))
LastCompletedSummary = completionSummary;
}
public string? GetConversationId(string tab)
{
TabConversationIds.TryGetValue(NormalizeTab(tab), out var value);
return value;
}
private static string NormalizeTab(string? tab)
{
var normalized = (tab ?? "").Trim();
if (string.IsNullOrEmpty(normalized))
return "Chat";
if (normalized.Contains("코워크", StringComparison.OrdinalIgnoreCase))
return "Cowork";
var canonical = new string(normalized
.Where(char.IsLetterOrDigit)
.ToArray())
.ToLowerInvariant();
if (canonical is "cowork" or "coworkcode" or "coworkcodetab")
return "Cowork";
if (normalized.Contains("코드", StringComparison.OrdinalIgnoreCase)
|| canonical is "code" or "codetab")
return "Code";
return "Chat";
}
private static bool HasPersistableContent(ChatConversation conv)
{
if ((conv.Messages?.Count ?? 0) > 0) return true;
if ((conv.ExecutionEvents?.Count ?? 0) > 0) return true;
if ((conv.AgentRunHistory?.Count ?? 0) > 0) return true;
if ((conv.DraftQueueItems?.Count ?? 0) > 0) return true;
if (conv.Pinned) return true;
if (!string.IsNullOrWhiteSpace(conv.WorkFolder)) return true;
if (!string.IsNullOrWhiteSpace(conv.SystemCommand)) return true;
if (!string.IsNullOrWhiteSpace(conv.ParentId)) return true;
if (!string.IsNullOrWhiteSpace(conv.Permission)) return true;
if (!string.IsNullOrWhiteSpace(conv.DataUsage)) return true;
if (!string.IsNullOrWhiteSpace(conv.OutputFormat)) return true;
if (!string.IsNullOrWhiteSpace(conv.Mood)) return true;
if (!string.IsNullOrWhiteSpace(conv.Preview)) return true;
if (!string.Equals(conv.Category, ChatCategory.General, StringComparison.OrdinalIgnoreCase)) return true;
if (!string.Equals((conv.Title ?? "").Trim(), "새 대화", StringComparison.OrdinalIgnoreCase)) return true;
return false;
}
private static bool NormalizeLoadedConversation(ChatConversation conversation)
{
var changed = false;
conversation.Messages ??= new List<ChatMessage>();
conversation.ExecutionEvents ??= new List<ChatExecutionEvent>();
conversation.AgentRunHistory ??= new List<ChatAgentRunRecord>();
conversation.DraftQueueItems ??= new List<DraftQueueItem>();
var normalizedEvents = NormalizeExecutionEventsForResume(conversation.ExecutionEvents);
if (!conversation.ExecutionEvents.SequenceEqual(normalizedEvents))
{
conversation.ExecutionEvents = normalizedEvents;
changed = true;
}
if (conversation.ExecutionEvents.Count > MaxExecutionEventHistory)
{
conversation.ExecutionEvents = conversation.ExecutionEvents
.Skip(conversation.ExecutionEvents.Count - MaxExecutionEventHistory)
.ToList();
changed = true;
}
var orderedRuns = conversation.AgentRunHistory
.GroupBy(run => string.IsNullOrWhiteSpace(run.RunId) ? Guid.NewGuid().ToString("N") : run.RunId, StringComparer.OrdinalIgnoreCase)
.Select(group => group
.OrderByDescending(run => run.UpdatedAt == default ? run.StartedAt : run.UpdatedAt)
.First())
.OrderByDescending(run => run.UpdatedAt == default ? run.StartedAt : run.UpdatedAt)
.ToList();
if (!conversation.AgentRunHistory.SequenceEqual(orderedRuns))
{
conversation.AgentRunHistory = orderedRuns;
changed = true;
}
if (conversation.AgentRunHistory.Count > MaxAgentRunHistory)
{
conversation.AgentRunHistory = conversation.AgentRunHistory
.Take(MaxAgentRunHistory)
.ToList();
changed = true;
}
return changed;
}
private static bool IsNearDuplicateExecutionEvent(ChatExecutionEvent left, ChatExecutionEvent right)
{
if (!string.Equals(left.RunId, right.RunId, StringComparison.OrdinalIgnoreCase))
return false;
if (!string.Equals(left.Type, right.Type, StringComparison.OrdinalIgnoreCase))
return false;
if (!string.Equals(left.ToolName, right.ToolName, StringComparison.OrdinalIgnoreCase))
return false;
if (!string.Equals(left.Summary?.Trim(), right.Summary?.Trim(), StringComparison.Ordinal))
return false;
if (!string.Equals(left.FilePath ?? "", right.FilePath ?? "", StringComparison.OrdinalIgnoreCase))
return false;
if (left.Success != right.Success)
return false;
var delta = (right.Timestamp - left.Timestamp).Duration();
return delta <= TimeSpan.FromSeconds(2);
}
private static List<ChatExecutionEvent> NormalizeExecutionEventsForResume(IEnumerable<ChatExecutionEvent>? source)
{
if (source == null)
return new List<ChatExecutionEvent>();
var ordered = source
.Where(evt => evt != null)
.OrderBy(evt => evt.Timestamp == default ? DateTime.MinValue : evt.Timestamp)
.ThenBy(evt => evt.Iteration)
.ThenBy(evt => evt.ElapsedMs)
.ToList();
var result = new List<ChatExecutionEvent>(ordered.Count);
foreach (var evt in ordered)
{
if (result.Count == 0)
{
result.Add(evt);
continue;
}
var last = result[^1];
if (!IsNearDuplicateExecutionEvent(last, evt))
{
result.Add(evt);
continue;
}
last.Timestamp = evt.Timestamp;
last.ElapsedMs = Math.Max(last.ElapsedMs, evt.ElapsedMs);
last.InputTokens = Math.Max(last.InputTokens, evt.InputTokens);
last.OutputTokens = Math.Max(last.OutputTokens, evt.OutputTokens);
last.StepCurrent = Math.Max(last.StepCurrent, evt.StepCurrent);
last.StepTotal = Math.Max(last.StepTotal, evt.StepTotal);
last.Iteration = Math.Max(last.Iteration, evt.Iteration);
if (string.IsNullOrWhiteSpace(last.FilePath))
last.FilePath = evt.FilePath;
if (!string.IsNullOrWhiteSpace(evt.Summary) && evt.Summary.Length >= (last.Summary?.Length ?? 0))
last.Summary = evt.Summary;
if (evt.Steps is { Count: > 0 })
last.Steps = evt.Steps.ToList();
if (!string.IsNullOrWhiteSpace(evt.ToolInput))
last.ToolInput = evt.ToolInput;
last.Success = last.Success && evt.Success;
}
return result;
}
private void TouchConversation(ChatStorageService? storage, string tab)
{
var conv = EnsureCurrentConversation(tab);
conv.UpdatedAt = DateTime.Now;
var conversationTab = NormalizeTab(conv.Tab);
if (!string.IsNullOrWhiteSpace(conv.Id))
RememberConversation(conversationTab, conv.Id);
try { storage?.Save(conv); } catch { }
}
private bool UpdateDraftItem(string tab, string draftId, Func<DraftQueueItem, bool> update, ChatStorageService? storage)
{
if (string.IsNullOrWhiteSpace(draftId))
return false;
var conv = EnsureCurrentConversation(tab);
var item = conv.DraftQueueItems?.FirstOrDefault(x => string.Equals(x.Id, draftId, StringComparison.OrdinalIgnoreCase));
if (item == null)
return false;
if (!update(item))
return false;
TouchConversation(storage, tab);
return true;
}
private static string Truncate(string? text, int max)
{
var value = text?.Trim() ?? "";
if (string.IsNullOrEmpty(value) || value.Length <= max)
return value;
return value[..max] + "…";
}
}