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

330 lines
12 KiB
C#

using System.IO;
using System.Linq;
using System.Text.Json;
using AxCopilot.Models;
namespace AxCopilot.Services;
/// <summary>
/// 대화 내역을 로컬에 AES-256-GCM 암호화하여 저장/로드합니다.
/// 파일 형식: conversations/{id}.axchat (암호화 바이너리)
/// 스레드 안전: ReaderWriterLockSlim으로 동시 접근 보호.
/// 원자적 쓰기: 임시 파일 → rename 패턴으로 크래시 시 데이터 손실 방지.
/// </summary>
public class ChatStorageService
{
private static readonly string ConversationsDir =
Path.Combine(Environment.GetFolderPath(Environment.SpecialFolder.ApplicationData),
"AxCopilot", "conversations");
private static readonly JsonSerializerOptions JsonOpts = new()
{
WriteIndented = false,
PropertyNameCaseInsensitive = true
};
private static readonly ReaderWriterLockSlim Lock = new();
public ChatStorageService()
{
Directory.CreateDirectory(ConversationsDir);
}
/// <summary>대화를 암호화하여 저장합니다 (원자적 쓰기).</summary>
public void Save(ChatConversation conversation)
{
conversation.UpdatedAt = DateTime.Now;
// 검색용 미리보기 자동 갱신 (첫 사용자 메시지 100자)
if (string.IsNullOrEmpty(conversation.Preview) && conversation.Messages.Count > 0)
{
var firstUser = conversation.Messages.FirstOrDefault(m => m.Role == "user");
if (firstUser != null)
conversation.Preview = firstUser.Content.Length > 100
? firstUser.Content[..100] : firstUser.Content;
}
var json = JsonSerializer.Serialize(conversation, JsonOpts);
var path = GetFilePath(conversation.Id);
var tempPath = path + ".tmp";
Lock.EnterWriteLock();
try
{
CryptoService.EncryptToFile(tempPath, json);
// 원자적 교체: 기존 파일이 있으면 덮어쓰기
if (File.Exists(path)) File.Delete(path);
File.Move(tempPath, path);
UpdateMetaCache(conversation);
}
catch (Exception ex)
{
// 임시 파일 정리
try { if (File.Exists(tempPath)) File.Delete(tempPath); } catch { }
LogService.Warn($"대화 저장 실패 ({conversation.Id}): {ex.Message}");
throw;
}
finally
{
Lock.ExitWriteLock();
}
}
/// <summary>대화를 복호화하여 로드합니다.</summary>
public ChatConversation? Load(string id)
{
var path = GetFilePath(id);
Lock.EnterReadLock();
try
{
if (!File.Exists(path)) return null;
var json = CryptoService.DecryptFromFile(path);
return JsonSerializer.Deserialize<ChatConversation>(json, JsonOpts);
}
catch (Exception ex)
{
LogService.Warn($"대화 로드 실패 ({id}): {ex.Message}");
return null;
}
finally
{
Lock.ExitReadLock();
}
}
// ── 메타 캐시 ─────────────────────────────────────────────────────────
private List<ChatConversation>? _metaCache;
private bool _metaDirty = true;
/// <summary>메타 캐시를 무효화합니다. 다음 LoadAllMeta 호출 시 디스크에서 다시 읽습니다.</summary>
public void InvalidateMetaCache() => _metaDirty = true;
/// <summary>메타 캐시에서 특정 항목만 업데이트합니다 (전체 재로드 없이).</summary>
public void UpdateMetaCache(ChatConversation conv)
{
if (_metaCache == null) return;
var existing = _metaCache.FindIndex(c => c.Id == conv.Id);
var meta = 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(),
ExecutionEvents = conv.ExecutionEvents?.ToList() ?? new()
};
if (existing >= 0)
_metaCache[existing] = meta;
else
_metaCache.Add(meta);
}
/// <summary>메타 캐시에서 항목을 제거합니다.</summary>
public void RemoveFromMetaCache(string id)
{
_metaCache?.RemoveAll(c => c.Id == id);
}
/// <summary>모든 대화의 메타 정보(메시지 미포함)를 로드합니다. 캐시를 사용합니다.</summary>
public List<ChatConversation> LoadAllMeta()
{
if (!_metaDirty && _metaCache != null)
{
return _metaCache.OrderByDescending(c => c.Pinned)
.ThenByDescending(c => c.UpdatedAt)
.ToList();
}
var result = new List<ChatConversation>();
if (!Directory.Exists(ConversationsDir))
{
_metaCache = result;
_metaDirty = false;
return result;
}
Lock.EnterReadLock();
try
{
foreach (var file in Directory.GetFiles(ConversationsDir, "*.axchat"))
{
try
{
var json = CryptoService.DecryptFromFile(file);
var conv = JsonSerializer.Deserialize<ChatConversation>(json, JsonOpts);
if (conv != null)
{
var meta = 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(),
ExecutionEvents = conv.ExecutionEvents?.ToList() ?? new()
};
result.Add(meta);
}
}
catch (Exception ex)
{
LogService.Warn($"대화 메타 로드 실패 ({Path.GetFileName(file)}): {ex.Message}");
}
}
}
finally
{
Lock.ExitReadLock();
}
_metaCache = result;
_metaDirty = false;
return result.OrderByDescending(c => c.Pinned)
.ThenByDescending(c => c.UpdatedAt)
.ToList();
}
/// <summary>특정 대화를 삭제합니다.</summary>
public void Delete(string id)
{
var path = GetFilePath(id);
Lock.EnterWriteLock();
try
{
if (File.Exists(path)) File.Delete(path);
RemoveFromMetaCache(id);
}
catch (Exception ex)
{
LogService.Warn($"대화 삭제 실패 ({id}): {ex.Message}");
}
finally
{
Lock.ExitWriteLock();
}
}
/// <summary>모든 대화를 삭제합니다.</summary>
public int DeleteAll()
{
if (!Directory.Exists(ConversationsDir)) return 0;
Lock.EnterWriteLock();
try
{
var files = Directory.GetFiles(ConversationsDir, "*.axchat");
int count = 0;
foreach (var f in files)
{
try { File.Delete(f); count++; }
catch (Exception ex) { LogService.Warn($"파일 삭제 실패 ({Path.GetFileName(f)}): {ex.Message}"); }
}
InvalidateMetaCache();
return count;
}
finally
{
Lock.ExitWriteLock();
}
}
/// <summary>보관 기간을 초과한 대화를 삭제합니다 (핀 고정 제외).</summary>
public int PurgeExpired(int retentionDays)
{
if (retentionDays <= 0) return 0;
var cutoff = DateTime.Now.AddDays(-retentionDays);
int count = 0;
Lock.EnterWriteLock();
try
{
foreach (var file in Directory.GetFiles(ConversationsDir, "*.axchat"))
{
try
{
var json = CryptoService.DecryptFromFile(file);
var conv = JsonSerializer.Deserialize<ChatConversation>(json, JsonOpts);
if (conv != null && !conv.Pinned && conv.UpdatedAt < cutoff)
{
File.Delete(file);
RemoveFromMetaCache(conv.Id);
count++;
}
}
catch (Exception ex)
{
LogService.Warn($"만료 대화 정리 실패 ({Path.GetFileName(file)}): {ex.Message}");
}
}
}
finally
{
Lock.ExitWriteLock();
}
return count;
}
/// <summary>드라이브 사용률이 98% 이상이면 오래된 대화부터 삭제합니다 (핀 고정 제외).</summary>
public int PurgeForDiskSpace(double threshold = 0.98)
{
try
{
var drive = new DriveInfo(Path.GetPathRoot(ConversationsDir) ?? "C");
if (drive.TotalSize <= 0) return 0;
var usageRatio = 1.0 - (double)drive.AvailableFreeSpace / drive.TotalSize;
if (usageRatio < threshold) return 0;
LogService.Info($"드라이브 사용률 {usageRatio:P1} — 대화 정리 시작 (임계값: {threshold:P0})");
// 오래된 순으로 정렬하여 삭제 (핀 고정 제외)
var files = Directory.GetFiles(ConversationsDir, "*.axchat")
.Select(f => new { Path = f, LastWrite = File.GetLastWriteTime(f) })
.OrderBy(f => f.LastWrite)
.ToList();
int count = 0;
Lock.EnterWriteLock();
try
{
foreach (var file in files)
{
// 사용률이 임계값 미만으로 내려가면 중단
var currentUsage = 1.0 - (double)drive.AvailableFreeSpace / drive.TotalSize;
if (currentUsage < threshold - 0.02) break; // 2% 여유 확보 후 중단
try
{
var json = CryptoService.DecryptFromFile(file.Path);
var conv = JsonSerializer.Deserialize<ChatConversation>(json, JsonOpts);
if (conv != null && conv.Pinned) continue; // 핀 고정 건너뜀
File.Delete(file.Path);
count++;
}
catch { }
}
}
finally
{
Lock.ExitWriteLock();
}
if (count > 0)
LogService.Info($"드라이브 용량 부족으로 대화 {count}개 삭제 완료");
return count;
}
catch (Exception ex)
{
LogService.Warn($"디스크 용량 확인 실패: {ex.Message}");
return 0;
}
}
private static string GetFilePath(string id) => Path.Combine(ConversationsDir, $"{id}.axchat");
}