런처 Agent Compare 기능 1차 이식 및 현재 런처 구조 연결

- Agent Compare 기준으로 런처 빠른 실행 칩, 검색 히스토리 탐색, 선택 항목 미리보기 패널을 현재 런처에 이식
- 하단 위젯 바, QuickLook(F3), 화면 OCR(F4), 관련 서비스/partial 파일을 현재 LauncherWindow/LauncherViewModel 구조에 연결
- UsageRankingService 상위 항목 조회와 SearchHistoryService를 추가해 실행 상위 경로/검색 기록이 실제 런처 동작에 반영되도록 정리
- README.md, docs/DEVELOPMENT.md에 이식 범위와 검증 결과를 2026-04-05 11:58 (KST) 기준으로 기록

검증 결과
- dotnet build src/AxCopilot/AxCopilot.csproj -c Release -v minimal -p:OutputPath=bin\\verify\\ -p:IntermediateOutputPath=obj\\verify\\ 경고 0 / 오류 0
This commit is contained in:
2026-04-05 11:51:43 +09:00
parent 0336904258
commit f7cafe0cfc
17 changed files with 2518 additions and 24 deletions

View File

@@ -0,0 +1,133 @@
using System.IO;
using System.Runtime.InteropServices;
namespace AxCopilot.Services;
internal sealed class PerformanceMonitorService
{
public static readonly PerformanceMonitorService Instance = new();
[StructLayout(LayoutKind.Sequential)]
private struct FILETIME
{
public uint Low;
public uint High;
}
[StructLayout(LayoutKind.Sequential, CharSet = CharSet.Auto)]
private struct MEMORYSTATUSEX
{
public uint dwLength;
public uint dwMemoryLoad;
public ulong ullTotalPhys;
public ulong ullAvailPhys;
public ulong ullTotalPageFile;
public ulong ullAvailPageFile;
public ulong ullTotalVirtual;
public ulong ullAvailVirtual;
public ulong ullAvailExtendedVirtual;
}
[DllImport("kernel32.dll", SetLastError = true)]
private static extern bool GetSystemTimes(out FILETIME idle, out FILETIME kernel, out FILETIME user);
[DllImport("kernel32.dll", CharSet = CharSet.Auto, SetLastError = true)]
private static extern bool GlobalMemoryStatusEx(ref MEMORYSTATUSEX lpBuffer);
public double CpuPercent { get; private set; }
public double RamPercent { get; private set; }
public string RamText { get; private set; } = "";
public double DiskCPercent { get; private set; }
public string DiskCText { get; private set; } = "";
private System.Threading.Timer? _timer;
private FILETIME _prevIdle;
private FILETIME _prevKernel;
private FILETIME _prevUser;
private bool _hasPrev;
private PerformanceMonitorService() { }
public void StartPolling()
{
if (_timer != null)
return;
_timer = new System.Threading.Timer(_ => Sample(), null, 0, 2000);
}
public void StopPolling()
{
_timer?.Dispose();
_timer = null;
_hasPrev = false;
}
private void Sample()
{
SampleCpu();
SampleRam();
SampleDisk();
}
private void SampleCpu()
{
try
{
if (!GetSystemTimes(out var idle, out var kernel, out var user))
return;
if (_hasPrev)
{
var idleDelta = ToUlong(idle) - ToUlong(_prevIdle);
var kernelDelta = ToUlong(kernel) - ToUlong(_prevKernel);
var userDelta = ToUlong(user) - ToUlong(_prevUser);
var total = kernelDelta + userDelta;
var busy = total - idleDelta;
CpuPercent = total > 0 ? Math.Clamp(100.0 * busy / total, 0, 100) : 0;
}
_prevIdle = idle;
_prevKernel = kernel;
_prevUser = user;
_hasPrev = true;
}
catch { }
}
private void SampleRam()
{
try
{
var mem = new MEMORYSTATUSEX { dwLength = (uint)Marshal.SizeOf<MEMORYSTATUSEX>() };
if (!GlobalMemoryStatusEx(ref mem))
return;
RamPercent = mem.dwMemoryLoad;
var usedGb = (mem.ullTotalPhys - mem.ullAvailPhys) / (1024.0 * 1024 * 1024);
var totalGb = mem.ullTotalPhys / (1024.0 * 1024 * 1024);
RamText = $"{usedGb:F1}/{totalGb:F0}GB";
}
catch { }
}
private void SampleDisk()
{
try
{
var drive = DriveInfo.GetDrives()
.FirstOrDefault(d => d.IsReady && d.Name.StartsWith("C", StringComparison.OrdinalIgnoreCase));
if (drive == null)
return;
var usedBytes = drive.TotalSize - drive.AvailableFreeSpace;
DiskCPercent = 100.0 * usedBytes / drive.TotalSize;
var usedGb = usedBytes / (1024.0 * 1024 * 1024);
var totalGb = drive.TotalSize / (1024.0 * 1024 * 1024);
DiskCText = $"C:{usedGb:F0}/{totalGb:F0}GB";
}
catch { }
}
private static ulong ToUlong(FILETIME ft) => ((ulong)ft.High << 32) | ft.Low;
}

View File

@@ -0,0 +1,108 @@
using System.IO;
using System.Text.Json;
namespace AxCopilot.Services;
/// <summary>
/// 런처 검색 히스토리를 로컬 파일로 관리합니다.
/// 위/아래 키로 이전 검색어를 다시 불러올 때 사용됩니다.
/// </summary>
internal static class SearchHistoryService
{
private const int MaxItems = 50;
private static readonly string HistoryFile = Path.Combine(
Environment.GetFolderPath(Environment.SpecialFolder.ApplicationData),
"AxCopilot",
"search_history.json");
private static readonly object SyncLock = new();
private static List<string> _history = new();
private static bool _loaded;
public static void Add(string query)
{
if (string.IsNullOrWhiteSpace(query))
return;
var trimmed = query.Trim();
if (trimmed.Length < 2)
return;
EnsureLoaded();
lock (SyncLock)
{
if (_history.Count > 0 && string.Equals(_history[0], trimmed, StringComparison.Ordinal))
return;
_history.Remove(trimmed);
_history.Insert(0, trimmed);
if (_history.Count > MaxItems)
_history.RemoveAt(_history.Count - 1);
}
_ = SaveAsync();
}
public static IReadOnlyList<string> GetAll()
{
EnsureLoaded();
lock (SyncLock)
return _history.AsReadOnly();
}
public static void Clear()
{
lock (SyncLock)
_history.Clear();
_ = SaveAsync();
}
private static void EnsureLoaded()
{
if (_loaded)
return;
lock (SyncLock)
{
if (_loaded)
return;
try
{
if (File.Exists(HistoryFile))
{
var json = File.ReadAllText(HistoryFile);
_history = JsonSerializer.Deserialize<List<string>>(json) ?? new List<string>();
}
}
catch (Exception ex)
{
LogService.Warn($"search_history.json 로드 실패: {ex.Message}");
_history = new List<string>();
}
_loaded = true;
}
}
private static async Task SaveAsync()
{
try
{
List<string> snapshot;
lock (SyncLock)
snapshot = new List<string>(_history);
Directory.CreateDirectory(Path.GetDirectoryName(HistoryFile)!);
var json = JsonSerializer.Serialize(snapshot);
await File.WriteAllTextAsync(HistoryFile, json);
}
catch (Exception ex)
{
LogService.Warn($"search_history.json 저장 실패: {ex.Message}");
}
}
}

View File

@@ -0,0 +1,115 @@
using System.Net.Http;
using AxCopilot.Models;
namespace AxCopilot.Services;
internal sealed class ServerStatusService
{
public static readonly ServerStatusService Instance = new();
private static readonly HttpClient Http = new()
{
Timeout = TimeSpan.FromMilliseconds(1500)
};
public bool OllamaOnline { get; private set; }
public bool LlmOnline { get; private set; }
public bool McpOnline { get; private set; }
public string McpName { get; private set; } = "MCP";
public event EventHandler? StatusChanged;
private System.Threading.Timer? _timer;
private string _ollamaEndpoint = "http://localhost:11434";
private string _llmEndpoint = "";
private string _llmService = "Ollama";
private string _mcpEndpoint = "";
private ServerStatusService() { }
public void Start(AppSettings? settings = null)
{
LoadEndpoints(settings);
if (_timer != null)
return;
_timer = new System.Threading.Timer(async _ => await CheckAllAsync(), null, 0, 15000);
}
public void Stop()
{
_timer?.Dispose();
_timer = null;
}
public void Refresh(AppSettings? settings = null)
{
LoadEndpoints(settings);
_ = CheckAllAsync();
}
private void LoadEndpoints(AppSettings? settings)
{
var llm = settings?.Llm;
if (llm == null)
return;
_ollamaEndpoint = llm.OllamaEndpoint?.TrimEnd('/') ?? "http://localhost:11434";
_llmService = llm.Service ?? "Ollama";
_llmEndpoint = string.Equals(_llmService, "vLLM", StringComparison.OrdinalIgnoreCase)
? (llm.VllmEndpoint?.TrimEnd('/') ?? "")
: _ollamaEndpoint;
var mcp = llm.McpServers?.FirstOrDefault(s => s.Enabled && !string.IsNullOrWhiteSpace(s.Url));
if (mcp != null)
{
McpName = mcp.Name;
_mcpEndpoint = mcp.Url?.TrimEnd('/') ?? "";
}
else
{
McpName = "MCP";
_mcpEndpoint = "";
}
}
private async Task CheckAllAsync()
{
var ollamaTask = PingAsync(_ollamaEndpoint + "/api/version");
var llmTask = string.IsNullOrEmpty(_llmEndpoint) || _llmEndpoint == _ollamaEndpoint
? ollamaTask
: PingAsync(_llmEndpoint);
var mcpTask = string.IsNullOrEmpty(_mcpEndpoint)
? Task.FromResult(false)
: PingAsync(_mcpEndpoint);
await Task.WhenAll(ollamaTask, llmTask, mcpTask).ConfigureAwait(false);
var changed = OllamaOnline != ollamaTask.Result ||
LlmOnline != llmTask.Result ||
McpOnline != mcpTask.Result;
OllamaOnline = ollamaTask.Result;
LlmOnline = llmTask.Result;
McpOnline = mcpTask.Result;
if (changed)
StatusChanged?.Invoke(this, EventArgs.Empty);
}
private static async Task<bool> PingAsync(string url)
{
if (string.IsNullOrWhiteSpace(url))
return false;
try
{
var resp = await Http.GetAsync(url).ConfigureAwait(false);
return resp.IsSuccessStatusCode || (int)resp.StatusCode < 500;
}
catch
{
return false;
}
}
}

View File

@@ -60,6 +60,23 @@ internal static class UsageRankingService
.Select(x => x.item);
}
/// <summary>
/// 실행 횟수 상위 항목을 반환합니다.
/// 빠른 실행 칩처럼 경로 자체 목록이 필요한 화면에서 사용합니다.
/// </summary>
public static IReadOnlyList<KeyValuePair<string, int>> GetTopItems(int maxCount)
{
EnsureLoaded();
lock (_lock)
{
return _counts
.OrderByDescending(x => x.Value)
.ThenBy(x => x.Key, StringComparer.OrdinalIgnoreCase)
.Take(Math.Max(0, maxCount))
.ToList();
}
}
// ─── 내부 ──────────────────────────────────────────────────────────────────
private static void EnsureLoaded()

View File

@@ -0,0 +1,52 @@
using System.Net.Http;
namespace AxCopilot.Services;
internal static class WeatherWidgetService
{
private static string? _cached;
private static DateTime _cacheTime = DateTime.MinValue;
private static bool _fetching;
private static readonly TimeSpan Ttl = TimeSpan.FromMinutes(30);
public static string CachedText => _cached ?? "--";
public static async Task RefreshAsync(bool internalMode)
{
if (internalMode)
{
_cached = "--";
return;
}
if (_cached != null && DateTime.Now - _cacheTime < Ttl)
return;
if (_fetching)
return;
_fetching = true;
try
{
using var client = new HttpClient();
client.Timeout = TimeSpan.FromSeconds(6);
var raw = await client.GetStringAsync("https://wttr.in/?format=%c+%t");
_cached = raw.Trim().Replace("+", " ");
_cacheTime = DateTime.Now;
}
catch (Exception ex)
{
LogService.Warn($"날씨 위젯 갱신 실패: {ex.Message}");
_cached ??= "--";
}
finally
{
_fetching = false;
}
}
public static void Invalidate()
{
_cached = null;
_cacheTime = DateTime.MinValue;
}
}