781 lines
30 KiB
C#
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] + "…";
|
|
}
|
|
}
|