Initial commit to new repository
This commit is contained in:
677
src/AxCopilot/Services/ChatSessionStateService.cs
Normal file
677
src/AxCopilot/Services/ChatSessionStateService.cs
Normal file
@@ -0,0 +1,677 @@
|
||||
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);
|
||||
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);
|
||||
}
|
||||
|
||||
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;
|
||||
|
||||
try { storage.Save(conv); } catch { }
|
||||
var conversationTab = NormalizeTab(conv.Tab);
|
||||
if (conv.Messages.Count > 0
|
||||
|| (conv.ExecutionEvents?.Count ?? 0) > 0
|
||||
|| (conv.AgentRunHistory?.Count ?? 0) > 0
|
||||
|| (conv.DraftQueueItems?.Count ?? 0) > 0)
|
||||
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)
|
||||
{
|
||||
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);
|
||||
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 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 orderedEvents = conversation.ExecutionEvents
|
||||
.OrderBy(evt => evt.Timestamp == default ? DateTime.MinValue : evt.Timestamp)
|
||||
.ToList();
|
||||
if (!conversation.ExecutionEvents.SequenceEqual(orderedEvents))
|
||||
{
|
||||
conversation.ExecutionEvents = orderedEvents;
|
||||
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 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] + "…";
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user