Some checks failed
Release Gate / gate (push) Has been cancelled
- 런처 색인에서 임시 파일, 숨김/시스템 경로, Office 임시 파일을 감시와 색인 대상에서 제외해 불필요한 재색인과 디스크 I/O를 줄인다 - AX Agent 표현 수준 저장값이 매번 rich로 덮어쓰이던 버그를 수정해 balanced/simple/rich 설정이 실제로 유지되게 한다 - 최소화/숨김 상태의 AX Agent 창은 transcript 재렌더를 지연했다가 다시 보일 때 한 번만 처리하고, 런처 인덱스 상태 타이머도 재사용하도록 바꿔 백그라운드 오버헤드를 줄인다 - 검증: dotnet build src/AxCopilot/AxCopilot.csproj -c Release -v minimal -p:OutputPath=bin\\verify\\ -p:IntermediateOutputPath=obj\\verify\\ (경고 0, 오류 0)
291 lines
13 KiB
C#
291 lines
13 KiB
C#
using System.IO;
|
|
using System.Text.Json;
|
|
using AxCopilot.Models;
|
|
using AxCopilot.Services.Agent;
|
|
|
|
namespace AxCopilot.Services;
|
|
|
|
public class SettingsService
|
|
{
|
|
private static readonly string AppDataDir = InitAppDataDir();
|
|
|
|
/// <summary>AppData 디렉터리 초기화. 기존 AxCommander 폴더가 있으면 AxCopilot으로 마이그레이션.</summary>
|
|
private static string InitAppDataDir()
|
|
{
|
|
var appData = Environment.GetFolderPath(Environment.SpecialFolder.ApplicationData);
|
|
var newDir = Path.Combine(appData, "AxCopilot");
|
|
var oldDir = Path.Combine(appData, "AxCommander");
|
|
if (!Directory.Exists(newDir) && Directory.Exists(oldDir))
|
|
try { Directory.Move(oldDir, newDir); } catch { }
|
|
return newDir;
|
|
}
|
|
|
|
public static readonly string SettingsPath = Path.Combine(AppDataDir, "settings.dat");
|
|
private static readonly string LegacyJsonPath = Path.Combine(AppDataDir, "settings.json");
|
|
|
|
private static readonly JsonSerializerOptions JsonOptions = new()
|
|
{
|
|
WriteIndented = true,
|
|
PropertyNameCaseInsensitive = true
|
|
};
|
|
|
|
private AppSettings _settings = new();
|
|
public AppSettings Settings => _settings;
|
|
|
|
public event EventHandler? SettingsChanged;
|
|
|
|
/// <summary>마이그레이션이 수행된 경우 변경 내역 문자열. null이면 마이그레이션 없음.</summary>
|
|
public string? MigrationSummary { get; private set; }
|
|
|
|
/// <summary>
|
|
/// 설정 파일의 스키마 버전 (앱 버전과 별개).
|
|
/// ★ 버전 관리 규칙:
|
|
/// - 앱 릴리스 버전은 AxCopilot.csproj → <Version> 에서만 관리합니다.
|
|
/// - 이 값은 settings.json 구조(필드 추가·이름 변경 등)가 바뀔 때만 올립니다.
|
|
/// - 올릴 때마다 MigrateIfNeeded() 에 해당 버전으로의 마이그레이션 블록을 추가합니다.
|
|
/// - 현재 매핑: 앱 v1.0.0~1.0.2 → 설정 v1.0 / 앱 v1.0.3~ → 설정 v1.1
|
|
/// </summary>
|
|
private const string CurrentSettingsVersion = "1.2";
|
|
|
|
public void Load()
|
|
{
|
|
EnsureDirectories();
|
|
|
|
// 1. 암호화된 설정 파일 우선
|
|
if (File.Exists(SettingsPath))
|
|
{
|
|
try
|
|
{
|
|
var encrypted = File.ReadAllText(SettingsPath);
|
|
var json = CryptoService.PortableDecrypt(encrypted);
|
|
if (string.IsNullOrEmpty(json))
|
|
throw new InvalidOperationException("복호화 결과가 비어 있습니다.");
|
|
_settings = JsonSerializer.Deserialize<AppSettings>(json, JsonOptions) ?? new();
|
|
ApplyLegacySigmoidAliases(json, _settings);
|
|
MigrateIfNeeded();
|
|
NormalizeRuntimeSettings();
|
|
return;
|
|
}
|
|
catch (Exception ex)
|
|
{
|
|
var backupPath = SettingsPath + ".bak";
|
|
try { File.Copy(SettingsPath, backupPath, overwrite: true); }
|
|
catch (Exception backupEx) { LogService.Warn($"settings.dat 백업 실패: {backupEx.Message}"); }
|
|
LogService.Error($"settings.dat 복호화/파싱 실패, 기본값으로 복구: {ex.Message}");
|
|
}
|
|
}
|
|
|
|
// 2. 레거시 평문 JSON 마이그레이션
|
|
if (File.Exists(LegacyJsonPath))
|
|
{
|
|
try
|
|
{
|
|
var json = File.ReadAllText(LegacyJsonPath);
|
|
_settings = JsonSerializer.Deserialize<AppSettings>(json, JsonOptions) ?? new();
|
|
ApplyLegacySigmoidAliases(json, _settings);
|
|
MigrateIfNeeded();
|
|
NormalizeRuntimeSettings();
|
|
Save(); // 암호화된 settings.dat로 저장
|
|
try { File.Delete(LegacyJsonPath); }
|
|
catch (Exception delEx) { LogService.Warn($"레거시 settings.json 삭제 실패: {delEx.Message}"); }
|
|
LogService.Info("설정 마이그레이션: settings.json → settings.dat (암호화 적용)");
|
|
return;
|
|
}
|
|
catch (Exception ex)
|
|
{
|
|
LogService.Error($"레거시 settings.json 파싱 실패: {ex.Message}");
|
|
}
|
|
}
|
|
|
|
// 3. 신규 설치
|
|
_settings = CreateDefaults();
|
|
_settings.Version = CurrentSettingsVersion;
|
|
NormalizeRuntimeSettings();
|
|
Save();
|
|
}
|
|
|
|
/// <summary>
|
|
/// 설정 파일 버전을 확인하고, 이전 버전이면 순차적으로 마이그레이션합니다.
|
|
/// 새 프로퍼티는 C# 기본값으로 자동 적용되며,
|
|
/// 구조 변경(키 이름 변경, 하위 객체 이동 등)만 여기서 처리합니다.
|
|
/// </summary>
|
|
private void MigrateIfNeeded()
|
|
{
|
|
var ver = _settings.Version ?? "1.0";
|
|
var migrated = false;
|
|
|
|
// ── 1.0 → 1.1 마이그레이션 ──────────────────────────────────────────
|
|
if (IsVersionLessThan(ver, "1.1"))
|
|
{
|
|
// 폴더 별칭 프리픽스 변경: ~xxx → cd xxx (v1.1)
|
|
foreach (var alias in _settings.Aliases.Where(a => a.Type == "folder"))
|
|
{
|
|
if (alias.Key.StartsWith("~"))
|
|
alias.Key = "cd " + alias.Key.TrimStart('~');
|
|
}
|
|
// 워크스페이스 기호 변경 알림은 도움말에서 안내
|
|
LogService.Info("설정 마이그레이션: 1.0 → 1.1 (폴더 별칭 prefix ~ → cd)");
|
|
ver = "1.1";
|
|
migrated = true;
|
|
}
|
|
|
|
// ── 1.1 → 1.2 마이그레이션 ──────────────────────────────────────────
|
|
if (IsVersionLessThan(ver, "1.2"))
|
|
{
|
|
// 파일 대화상자 연동 기능 기본값 비활성화 (v1.6.2)
|
|
// 브라우저 파일 업로드·메일 첨부 시 런처가 의도치 않게 열리는 오작동 방지
|
|
_settings.Launcher.EnableFileDialogIntegration = false;
|
|
LogService.Info("설정 마이그레이션: 1.1 → 1.2 (파일 대화상자 연동 비활성화)");
|
|
ver = "1.2";
|
|
migrated = true;
|
|
}
|
|
|
|
// ── 향후 마이그레이션은 여기에 추가 ─────────────────────────────────
|
|
// if (IsVersionLessThan(ver, "1.3")) { ... ver = "1.3"; migrated = true; }
|
|
|
|
if (migrated)
|
|
{
|
|
_settings.Version = CurrentSettingsVersion;
|
|
Save();
|
|
MigrationSummary = $"설정이 v{CurrentSettingsVersion}으로 업데이트되었습니다.\n\n"
|
|
+ "• 파일 대화상자 연동 기능이 비활성화되었습니다.\n"
|
|
+ " 웹 브라우저 파일 업로드 시 런처가 열리는 문제를 방지합니다.\n"
|
|
+ " 필요 시 설정 → 기능 → AI 기능에서 다시 활성화할 수 있습니다.";
|
|
LogService.Info($"설정 파일 마이그레이션 완료 → v{CurrentSettingsVersion}");
|
|
}
|
|
}
|
|
|
|
private static bool IsVersionLessThan(string current, string target)
|
|
{
|
|
if (Version.TryParse(current, out var cv) && Version.TryParse(target, out var tv))
|
|
return cv < tv;
|
|
return string.Compare(current, target, StringComparison.Ordinal) < 0;
|
|
}
|
|
|
|
public void Save()
|
|
{
|
|
EnsureDirectories();
|
|
NormalizeRuntimeSettings();
|
|
var json = JsonSerializer.Serialize(_settings, JsonOptions);
|
|
var encrypted = CryptoService.PortableEncrypt(json);
|
|
File.WriteAllText(SettingsPath, encrypted);
|
|
SettingsChanged?.Invoke(this, EventArgs.Empty);
|
|
}
|
|
|
|
private void NormalizeRuntimeSettings()
|
|
{
|
|
var expressionLevel = (_settings.Llm.AgentUiExpressionLevel ?? "").Trim().ToLowerInvariant();
|
|
_settings.Llm.AgentUiExpressionLevel = expressionLevel switch
|
|
{
|
|
"rich" => "rich",
|
|
"balanced" => "balanced",
|
|
"simple" => "simple",
|
|
_ => "balanced"
|
|
};
|
|
_settings.Llm.FilePermission = PermissionModeCatalog.NormalizeGlobalMode(_settings.Llm.FilePermission);
|
|
_settings.Llm.DefaultAgentPermission = PermissionModeCatalog.NormalizeGlobalMode(_settings.Llm.DefaultAgentPermission);
|
|
if (_settings.Llm.ToolPermissions != null && _settings.Llm.ToolPermissions.Count > 0)
|
|
{
|
|
var keys = _settings.Llm.ToolPermissions.Keys.ToList();
|
|
foreach (var key in keys)
|
|
_settings.Llm.ToolPermissions[key] = PermissionModeCatalog.NormalizeToolOverride(_settings.Llm.ToolPermissions[key]);
|
|
}
|
|
|
|
_settings.Llm.MaxFavoriteSlashCommands = Math.Clamp(_settings.Llm.MaxFavoriteSlashCommands <= 0 ? 10 : _settings.Llm.MaxFavoriteSlashCommands, 1, 30);
|
|
_settings.Llm.MaxRecentSlashCommands = Math.Clamp(_settings.Llm.MaxRecentSlashCommands <= 0 ? 20 : _settings.Llm.MaxRecentSlashCommands, 5, 50);
|
|
if (_settings.Llm.FavoriteSlashCommands.Count > _settings.Llm.MaxFavoriteSlashCommands)
|
|
_settings.Llm.FavoriteSlashCommands = _settings.Llm.FavoriteSlashCommands.Take(_settings.Llm.MaxFavoriteSlashCommands).ToList();
|
|
if (_settings.Llm.RecentSlashCommands.Count > _settings.Llm.MaxRecentSlashCommands)
|
|
_settings.Llm.RecentSlashCommands = _settings.Llm.RecentSlashCommands.Take(_settings.Llm.MaxRecentSlashCommands).ToList();
|
|
|
|
NormalizeLlmThresholds(_settings.Llm);
|
|
}
|
|
|
|
private static void ApplyLegacySigmoidAliases(string rawJson, AppSettings settings)
|
|
{
|
|
if (settings?.Llm == null || string.IsNullOrWhiteSpace(rawJson)) return;
|
|
if (!string.IsNullOrWhiteSpace(settings.Llm.SigmoidApiKey) &&
|
|
!string.IsNullOrWhiteSpace(settings.Llm.SigmoidModel))
|
|
return;
|
|
|
|
try
|
|
{
|
|
using var doc = JsonDocument.Parse(rawJson);
|
|
if (!doc.RootElement.TryGetProperty("llm", out var llm)) return;
|
|
|
|
var legacyApiKeyName = string.Concat("cl", "audeApiKey");
|
|
var legacyModelName = string.Concat("cl", "audeModel");
|
|
|
|
if (string.IsNullOrWhiteSpace(settings.Llm.SigmoidApiKey) &&
|
|
llm.TryGetProperty(legacyApiKeyName, out var legacyApiKey) &&
|
|
legacyApiKey.ValueKind == JsonValueKind.String)
|
|
{
|
|
settings.Llm.SigmoidApiKey = legacyApiKey.GetString() ?? "";
|
|
}
|
|
|
|
if (string.IsNullOrWhiteSpace(settings.Llm.SigmoidModel) &&
|
|
llm.TryGetProperty(legacyModelName, out var legacyModel) &&
|
|
legacyModel.ValueKind == JsonValueKind.String)
|
|
{
|
|
settings.Llm.SigmoidModel = legacyModel.GetString() ?? "";
|
|
}
|
|
}
|
|
catch { }
|
|
}
|
|
|
|
private static void NormalizeLlmThresholds(LlmSettings llm)
|
|
{
|
|
llm.ReadOnlySignatureLoopThreshold = NormalizeOptionalThreshold(llm.ReadOnlySignatureLoopThreshold, 2, 12);
|
|
llm.ReadOnlyStagnationThreshold = NormalizeOptionalThreshold(llm.ReadOnlyStagnationThreshold, 3, 20);
|
|
llm.NoProgressRecoveryThreshold = NormalizeOptionalThreshold(llm.NoProgressRecoveryThreshold, 4, 30);
|
|
llm.NoProgressAbortThreshold = NormalizeOptionalThreshold(llm.NoProgressAbortThreshold, 6, 50);
|
|
llm.NoProgressRecoveryMaxRetries = NormalizeOptionalThreshold(llm.NoProgressRecoveryMaxRetries, 1, 5);
|
|
llm.ToolExecutionTimeoutMs = NormalizeOptionalThreshold(llm.ToolExecutionTimeoutMs, 5000, 600000);
|
|
}
|
|
|
|
private static int NormalizeOptionalThreshold(int value, int min, int max)
|
|
{
|
|
if (value <= 0)
|
|
return 0;
|
|
return Math.Clamp(value, min, max);
|
|
}
|
|
|
|
private static void EnsureDirectories()
|
|
{
|
|
Directory.CreateDirectory(AppDataDir);
|
|
Directory.CreateDirectory(Path.Combine(AppDataDir, "logs"));
|
|
Directory.CreateDirectory(Path.Combine(AppDataDir, "crashes"));
|
|
Directory.CreateDirectory(Path.Combine(AppDataDir, "skills"));
|
|
}
|
|
|
|
private static AppSettings CreateDefaults()
|
|
{
|
|
var indexPaths = new List<string>
|
|
{
|
|
"%USERPROFILE%\\Desktop",
|
|
"%APPDATA%\\Microsoft\\Windows\\Start Menu"
|
|
};
|
|
|
|
// D:\Non_Documents 폴더가 존재하면 기본 인덱스 경로에 추가
|
|
const string nonDocPath = "D:\\Non_Documents";
|
|
if (Directory.Exists(nonDocPath))
|
|
indexPaths.Add(nonDocPath);
|
|
|
|
return new AppSettings
|
|
{
|
|
IndexPaths = indexPaths,
|
|
Aliases = new List<AliasEntry>
|
|
{
|
|
new() { Key = "@blog", Type = "url", Target = "https://example.com", Description = "내 블로그" },
|
|
new() { Key = "~home", Type = "folder", Target = "%USERPROFILE%", Description = "홈 폴더" },
|
|
},
|
|
// 커스텀 테마 초기값 (theme: "custom"으로 변경 시 활성화)
|
|
Launcher = new()
|
|
{
|
|
CustomTheme = new AxCopilot.Models.CustomThemeColors()
|
|
}
|
|
};
|
|
}
|
|
}
|
|
|