런처 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:
133
src/AxCopilot/Services/PerformanceMonitorService.cs
Normal file
133
src/AxCopilot/Services/PerformanceMonitorService.cs
Normal 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;
|
||||
}
|
||||
108
src/AxCopilot/Services/SearchHistoryService.cs
Normal file
108
src/AxCopilot/Services/SearchHistoryService.cs
Normal 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}");
|
||||
}
|
||||
}
|
||||
}
|
||||
115
src/AxCopilot/Services/ServerStatusService.cs
Normal file
115
src/AxCopilot/Services/ServerStatusService.cs
Normal 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;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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()
|
||||
|
||||
52
src/AxCopilot/Services/WeatherWidgetService.cs
Normal file
52
src/AxCopilot/Services/WeatherWidgetService.cs
Normal 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;
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user