330 lines
12 KiB
C#
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");
|
|
}
|