Initial commit to new repository
This commit is contained in:
329
src/AxCopilot/Services/ChatStorageService.cs
Normal file
329
src/AxCopilot/Services/ChatStorageService.cs
Normal file
@@ -0,0 +1,329 @@
|
||||
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");
|
||||
}
|
||||
Reference in New Issue
Block a user