using System; using System.Collections.Generic; using System.IO; using System.Linq; using System.Text.Json; using System.Threading; using AxCopilot.Models; namespace AxCopilot.Services; public class ChatStorageService { private static readonly string ConversationsDir = Path.Combine(Environment.GetFolderPath(Environment.SpecialFolder.ApplicationData), "AxCopilot", "conversations"); private static readonly JsonSerializerOptions JsonOpts = new JsonSerializerOptions { WriteIndented = false, PropertyNameCaseInsensitive = true }; private static readonly ReaderWriterLockSlim Lock = new ReaderWriterLockSlim(); private List? _metaCache; private bool _metaDirty = true; public ChatStorageService() { Directory.CreateDirectory(ConversationsDir); } public void Save(ChatConversation conversation) { conversation.UpdatedAt = DateTime.Now; if (string.IsNullOrEmpty(conversation.Preview) && conversation.Messages.Count > 0) { ChatMessage chatMessage = conversation.Messages.FirstOrDefault((ChatMessage m) => m.Role == "user"); if (chatMessage != null) { conversation.Preview = ((chatMessage.Content.Length > 100) ? chatMessage.Content.Substring(0, 100) : chatMessage.Content); } } string content = JsonSerializer.Serialize(conversation, JsonOpts); string filePath = GetFilePath(conversation.Id); string text = filePath + ".tmp"; Lock.EnterWriteLock(); try { CryptoService.EncryptToFile(text, content); if (File.Exists(filePath)) { File.Delete(filePath); } File.Move(text, filePath); UpdateMetaCache(conversation); } catch (Exception ex) { try { if (File.Exists(text)) { File.Delete(text); } } catch { } LogService.Warn("대화 저장 실패 (" + conversation.Id + "): " + ex.Message); throw; } finally { Lock.ExitWriteLock(); } } public ChatConversation? Load(string id) { string filePath = GetFilePath(id); Lock.EnterReadLock(); try { if (!File.Exists(filePath)) { return null; } string json = CryptoService.DecryptFromFile(filePath); return JsonSerializer.Deserialize(json, JsonOpts); } catch (Exception ex) { LogService.Warn("대화 로드 실패 (" + id + "): " + ex.Message); return null; } finally { Lock.ExitReadLock(); } } public void InvalidateMetaCache() { _metaDirty = true; } public void UpdateMetaCache(ChatConversation conv) { if (_metaCache != null) { int num = _metaCache.FindIndex((ChatConversation c) => c.Id == conv.Id); ChatConversation chatConversation = new ChatConversation { Id = conv.Id, Title = conv.Title, CreatedAt = conv.CreatedAt, UpdatedAt = conv.UpdatedAt, Pinned = conv.Pinned, Category = conv.Category, Tab = conv.Tab, SystemCommand = conv.SystemCommand, WorkFolder = conv.WorkFolder, Preview = conv.Preview, Messages = new List(), ExecutionEvents = (conv.ExecutionEvents?.ToList() ?? new List()) }; if (num >= 0) { _metaCache[num] = chatConversation; } else { _metaCache.Add(chatConversation); } } } public void RemoveFromMetaCache(string id) { _metaCache?.RemoveAll((ChatConversation c) => c.Id == id); } public List LoadAllMeta() { if (!_metaDirty && _metaCache != null) { return (from c in _metaCache orderby c.Pinned descending, c.UpdatedAt descending select c).ToList(); } List list = new List(); if (!Directory.Exists(ConversationsDir)) { _metaCache = list; _metaDirty = false; return list; } Lock.EnterReadLock(); try { string[] files = Directory.GetFiles(ConversationsDir, "*.axchat"); foreach (string text in files) { try { string json = CryptoService.DecryptFromFile(text); ChatConversation chatConversation = JsonSerializer.Deserialize(json, JsonOpts); if (chatConversation != null) { ChatConversation item = new ChatConversation { Id = chatConversation.Id, Title = chatConversation.Title, CreatedAt = chatConversation.CreatedAt, UpdatedAt = chatConversation.UpdatedAt, Pinned = chatConversation.Pinned, Category = chatConversation.Category, Tab = chatConversation.Tab, SystemCommand = chatConversation.SystemCommand, WorkFolder = chatConversation.WorkFolder, Preview = chatConversation.Preview, Messages = new List(), ExecutionEvents = (chatConversation.ExecutionEvents?.ToList() ?? new List()) }; list.Add(item); } } catch (Exception ex) { LogService.Warn("대화 메타 로드 실패 (" + Path.GetFileName(text) + "): " + ex.Message); } } } finally { Lock.ExitReadLock(); } _metaCache = list; _metaDirty = false; return (from c in list orderby c.Pinned descending, c.UpdatedAt descending select c).ToList(); } public void Delete(string id) { string filePath = GetFilePath(id); Lock.EnterWriteLock(); try { if (File.Exists(filePath)) { File.Delete(filePath); } RemoveFromMetaCache(id); } catch (Exception ex) { LogService.Warn("대화 삭제 실패 (" + id + "): " + ex.Message); } finally { Lock.ExitWriteLock(); } } public int DeleteAll() { if (!Directory.Exists(ConversationsDir)) { return 0; } Lock.EnterWriteLock(); try { string[] files = Directory.GetFiles(ConversationsDir, "*.axchat"); int num = 0; string[] array = files; foreach (string path in array) { try { File.Delete(path); num++; } catch (Exception ex) { LogService.Warn("파일 삭제 실패 (" + Path.GetFileName(path) + "): " + ex.Message); } } InvalidateMetaCache(); return num; } finally { Lock.ExitWriteLock(); } } public int PurgeExpired(int retentionDays) { if (retentionDays <= 0) { return 0; } DateTime dateTime = DateTime.Now.AddDays(-retentionDays); int num = 0; Lock.EnterWriteLock(); try { string[] files = Directory.GetFiles(ConversationsDir, "*.axchat"); foreach (string text in files) { try { string json = CryptoService.DecryptFromFile(text); ChatConversation chatConversation = JsonSerializer.Deserialize(json, JsonOpts); if (chatConversation != null && !chatConversation.Pinned && chatConversation.UpdatedAt < dateTime) { File.Delete(text); RemoveFromMetaCache(chatConversation.Id); num++; } } catch (Exception ex) { LogService.Warn("만료 대화 정리 실패 (" + Path.GetFileName(text) + "): " + ex.Message); } } } finally { Lock.ExitWriteLock(); } return num; } public int PurgeForDiskSpace(double threshold = 0.98) { try { DriveInfo driveInfo = new DriveInfo(Path.GetPathRoot(ConversationsDir) ?? "C"); if (driveInfo.TotalSize <= 0) { return 0; } double num = 1.0 - (double)driveInfo.AvailableFreeSpace / (double)driveInfo.TotalSize; if (num < threshold) { return 0; } LogService.Info($"드라이브 사용률 {num:P1} — 대화 정리 시작 (임계값: {threshold:P0})"); var list = (from f in Directory.GetFiles(ConversationsDir, "*.axchat") select new { Path = f, LastWrite = File.GetLastWriteTime(f) } into f orderby f.LastWrite select f).ToList(); int num2 = 0; Lock.EnterWriteLock(); try { foreach (var item in list) { double num3 = 1.0 - (double)driveInfo.AvailableFreeSpace / (double)driveInfo.TotalSize; if (num3 < threshold - 0.02) { break; } try { string json = CryptoService.DecryptFromFile(item.Path); ChatConversation chatConversation = JsonSerializer.Deserialize(json, JsonOpts); if (chatConversation == null || !chatConversation.Pinned) { File.Delete(item.Path); num2++; } } catch { } } } finally { Lock.ExitWriteLock(); } if (num2 > 0) { LogService.Info($"드라이브 용량 부족으로 대화 {num2}개 삭제 완료"); } return num2; } catch (Exception ex) { LogService.Warn("디스크 용량 확인 실패: " + ex.Message); return 0; } } private static string GetFilePath(string id) { return Path.Combine(ConversationsDir, id + ".axchat"); } }