[Phase52] 7개 파일 추가 분리 — 14개 파일로 재구성

LlmService.Streaming:
- LlmService.Streaming.cs: 516 → 256줄 (Ollama/OpenAI 유지)
- LlmService.GeminiClaude.cs (신규): Gemini+Claude 스트리밍 (~260줄)

DocxSkill:
- DocxSkill.cs: 543 → 158줄 (ExecuteAsync 유지)
- DocxSkill.Builders.cs (신규): 11개 문서 빌더 헬퍼 (290줄)

ChartSkill:
- ChartSkill.cs: 537 → 174줄 (ExecuteAsync+RenderChart 유지)
- ChartSkill.Renderers.cs (신규): 7개 차트 렌더러+헬퍼+Dataset (280줄)

ScreenCaptureHandler:
- ScreenCaptureHandler.cs: 637 → 241줄 (기본 캡처 유지)
- ScreenCaptureHandler.Helpers.cs (신규): 스크롤/영역 캡처+헬퍼 (310줄)

SystemInfoHandler:
- SystemInfoHandler.cs: 509 → 352줄
- SystemInfoHandler.Helpers.cs (신규): 8개 헬퍼+StarInfoHandler (161줄)

AppSettings:
- AppSettings.cs: 564 → 309줄 (AppSettings/Launcher/CustomTheme 유지)
- AppSettings.Models.cs (신규): 14개 설정 모델 클래스 (233줄)

SkillEditorWindow:
- SkillEditorWindow.xaml.cs: 528 → 303줄
- SkillEditorWindow.PreviewSave.cs (신규): 미리보기+저장+로드 (226줄)

빌드: 경고 0, 오류 0

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-04-03 21:40:56 +09:00
parent 55befebf34
commit b750849c9f
14 changed files with 2130 additions and 2051 deletions

View File

@@ -0,0 +1,411 @@
using System.Drawing;
using System.Drawing.Imaging;
using System.IO;
using System.Runtime.InteropServices;
using System.Windows.Media.Imaging;
using AxCopilot.SDK;
using AxCopilot.Services;
using AxCopilot.Themes;
namespace AxCopilot.Handlers;
public partial class ScreenCaptureHandler
{
// ─── 스크롤 캡처 + 영역 캡처 + 헬퍼 ────────────────────────────────────
private async Task CaptureScrollAsync(string timestamp, CancellationToken ct)
{
var hwnd = WindowTracker.PreviousWindow;
if (hwnd == IntPtr.Zero || !IsWindow(hwnd))
{
NotificationService.Notify("AX Copilot", "캡처할 창이 없습니다.");
return;
}
// 런처가 완전히 사라질 때까지 대기
var launcherHwnd = GetLauncherHwnd();
if (launcherHwnd != IntPtr.Zero)
{
for (int i = 0; i < 10 && IsWindowVisible(launcherHwnd); i++)
await Task.Delay(50, ct);
}
ShowWindow(hwnd, SW_RESTORE);
SetForegroundWindow(hwnd);
await Task.Delay(200, ct);
if (!GetWindowRect(hwnd, out var rect)) return;
int w = rect.right - rect.left;
int h = rect.bottom - rect.top;
if (w <= 0 || h <= 0) return;
// 스크롤 가능한 자식 창 찾기 (웹브라우저, 텍스트뷰어 등)
var scrollTarget = FindScrollableChild(hwnd);
const int maxPages = 15;
var frames = new List<Bitmap>();
// 첫 프레임
frames.Add(CaptureWindow(hwnd, w, h, rect));
for (int i = 0; i < maxPages - 1; i++)
{
ct.ThrowIfCancellationRequested();
// Page Down 전송
if (scrollTarget != IntPtr.Zero)
SendMessage(scrollTarget, WM_VSCROLL, new IntPtr(SB_PAGEDOWN), IntPtr.Zero);
else
SendPageDown(hwnd);
await Task.Delay(ScrollDelayMs, ct);
// 현재 프레임 캡처
if (!GetWindowRect(hwnd, out var newRect)) break;
var frame = CaptureWindow(hwnd, w, h, newRect);
// 이전 프레임과 동일하면 끝 (스크롤 종료 감지)
if (frames.Count > 0 && AreSimilar(frames[^1], frame))
{
frame.Dispose();
break;
}
frames.Add(frame);
}
// 프레임들을 수직으로 이어 붙이기 (오버랩 제거)
using var stitched = StitchFrames(frames, h);
// 각 프레임 해제
foreach (var f in frames) f.Dispose();
CopyToClipboard(stitched);
NotificationService.Notify("스크롤 캡처 완료", $"{stitched.Height}px · 클립보드에 복사되었습니다");
}
// ─── 헬퍼: 창 캡처 (PrintWindow 우선, 실패 시 BitBlt 폴백) ──────────────
private static Bitmap CaptureWindow(IntPtr hwnd, int w, int h, RECT rect)
{
var bmp = new Bitmap(w, h, PixelFormat.Format32bppArgb);
using var g = Graphics.FromImage(bmp);
// PrintWindow로 창 내용 캡처 (최소화된 창도 동작)
var hdc = g.GetHdc();
bool ok = PrintWindow(hwnd, hdc, 2); // PW_RENDERFULLCONTENT = 2
g.ReleaseHdc(hdc);
if (!ok)
{
// 폴백: 화면에서 BitBlt
g.CopyFromScreen(rect.left, rect.top, 0, 0,
new System.Drawing.Size(w, h), CopyPixelOperation.SourceCopy);
}
return bmp;
}
// ─── 헬퍼: 전체 화면 범위 ─────────────────────────────────────────────────
private static System.Drawing.Rectangle GetAllScreenBounds()
{
var screens = System.Windows.Forms.Screen.AllScreens;
int minX = screens.Min(s => s.Bounds.X);
int minY = screens.Min(s => s.Bounds.Y);
int maxX = screens.Max(s => s.Bounds.Right);
int maxY = screens.Max(s => s.Bounds.Bottom);
return new System.Drawing.Rectangle(minX, minY, maxX - minX, maxY - minY);
}
// ─── 헬퍼: 스크롤 가능 자식 창 찾기 ──────────────────────────────────────
private static IntPtr FindScrollableChild(IntPtr hwnd)
{
// 공통 스크롤 가능 클래스 탐색
foreach (var cls in new[] { "Internet Explorer_Server", "Chrome_RenderWidgetHostHWND",
"MozillaWindowClass", "RichEdit20W", "RICHEDIT50W",
"TextBox", "EDIT" })
{
var child = FindWindowEx(hwnd, IntPtr.Zero, cls, null);
if (child != IntPtr.Zero) return child;
}
return IntPtr.Zero;
}
// ─── 헬퍼: Page Down 키 전송 ─────────────────────────────────────────────
private static void SendPageDown(IntPtr hwnd)
{
SendMessage(hwnd, WM_KEYDOWN, new IntPtr(VK_NEXT), IntPtr.Zero);
SendMessage(hwnd, WM_KEYUP, new IntPtr(VK_NEXT), IntPtr.Zero);
}
// ─── 헬퍼: 두 비트맵이 유사한지 비교 (스크롤 종료 감지) ─────────────────
// LockBits를 사용하여 GetPixel 대비 ~50× 빠르게 처리.
private static bool AreSimilar(Bitmap a, Bitmap b)
{
if (a.Width != b.Width || a.Height != b.Height) return false;
int startY = (int)(a.Height * 0.8);
int w = a.Width;
int h = a.Height;
var rectA = new System.Drawing.Rectangle(0, startY, w, h - startY);
var rectB = new System.Drawing.Rectangle(0, startY, w, h - startY);
var dataA = a.LockBits(rectA, ImageLockMode.ReadOnly, PixelFormat.Format32bppArgb);
var dataB = b.LockBits(rectB, ImageLockMode.ReadOnly, PixelFormat.Format32bppArgb);
try
{
int sameCount = 0;
int totalSamples = 0;
int stride = dataA.Stride;
int sampleW = w / 16 + 1;
int sampleH = (h - startY) / 8 + 1;
unsafe
{
byte* ptrA = (byte*)dataA.Scan0.ToPointer();
byte* ptrB = (byte*)dataB.Scan0.ToPointer();
for (int sy = 0; sy < sampleH; sy++)
{
int row = sy * 8;
if (row >= h - startY) break;
for (int sx = 0; sx < sampleW; sx++)
{
int col = sx * 16;
if (col >= w) break;
int idx = row * stride + col * 4;
if (Math.Abs(ptrA[idx] - ptrB[idx]) < 5 &&
Math.Abs(ptrA[idx + 1] - ptrB[idx + 1]) < 5 &&
Math.Abs(ptrA[idx + 2] - ptrB[idx + 2]) < 5)
sameCount++;
totalSamples++;
}
}
}
return totalSamples > 0 && (double)sameCount / totalSamples > 0.97;
}
finally
{
a.UnlockBits(dataA);
b.UnlockBits(dataB);
}
}
// ─── 헬퍼: 프레임 이어 붙이기 (증분만 추가) ──────────────────────────────
private static Bitmap StitchFrames(List<Bitmap> frames, int windowHeight)
{
if (frames.Count == 0)
return new Bitmap(1, 1);
if (frames.Count == 1)
return new Bitmap(frames[0]);
int w = frames[0].Width;
// 각 프레임에서 새로운 부분의 시작 Y (오버랩 제외)
var newPartStarts = new List<int>(); // 인덱스 1부터: frames[i]에서 오버랩 이후 시작 행
var newPartHeights = new List<int>(); // 새로운 부분 높이
int totalHeight = windowHeight; // 첫 프레임은 전체 사용
for (int i = 1; i < frames.Count; i++)
{
int overlap = FindOverlap(frames[i - 1], frames[i]);
int newStart = overlap > 0 ? overlap : windowHeight / 5; // 오버랩 감지 실패 시 상단 20% 제거
int newH = windowHeight - newStart;
if (newH <= 0) { newH = windowHeight / 4; newStart = windowHeight - newH; }
newPartStarts.Add(newStart);
newPartHeights.Add(newH);
totalHeight += newH;
}
var result = new Bitmap(w, totalHeight, PixelFormat.Format32bppArgb);
using var g = Graphics.FromImage(result);
// 첫 프레임: 전체 그리기
g.DrawImage(frames[0], 0, 0, w, windowHeight);
// 이후 프레임: 새로운 부분(증분)만 잘라서 붙이기
int yPos = windowHeight;
for (int i = 1; i < frames.Count; i++)
{
int srcY = newPartStarts[i - 1];
int srcH = newPartHeights[i - 1];
var srcRect = new System.Drawing.Rectangle(0, srcY, w, srcH);
var dstRect = new System.Drawing.Rectangle(0, yPos, w, srcH);
g.DrawImage(frames[i], dstRect, srcRect, System.Drawing.GraphicsUnit.Pixel);
yPos += srcH;
}
return result;
}
// ─── 헬퍼: 두 프레임 사이 오버랩 픽셀 수 계산 (다중 행 비교) ────────────
private static int FindOverlap(Bitmap prev, Bitmap next)
{
int w = Math.Min(prev.Width, next.Width);
int h = prev.Height;
if (h < 16 || w < 16) return 0;
int searchRange = (int)(h * 0.7); // 최대 70% 오버랩 탐색
const int checkRows = 8; // 오버랩 후보당 검증할 행 수
var dataPrev = prev.LockBits(
new System.Drawing.Rectangle(0, 0, prev.Width, prev.Height),
ImageLockMode.ReadOnly, PixelFormat.Format32bppArgb);
var dataNext = next.LockBits(
new System.Drawing.Rectangle(0, 0, next.Width, next.Height),
ImageLockMode.ReadOnly, PixelFormat.Format32bppArgb);
try
{
int stridePrev = dataPrev.Stride;
int strideNext = dataNext.Stride;
int bestOverlap = 0;
unsafe
{
byte* ptrPrev = (byte*)dataPrev.Scan0.ToPointer();
byte* ptrNext = (byte*)dataNext.Scan0.ToPointer();
// 큰 오버랩부터 탐색 (스크롤은 보통 1페이지 미만)
for (int overlap = searchRange; overlap > 8; overlap -= 2)
{
int prevStartY = h - overlap;
if (prevStartY < 0) continue;
int totalMatch = 0;
int totalCheck = 0;
// 오버랩 영역 내 여러 행을 검증
for (int r = 0; r < checkRows; r++)
{
int rowInOverlap = r * (overlap / checkRows);
int prevRow = prevStartY + rowInOverlap;
int nextRow = rowInOverlap;
if (prevRow >= h || nextRow >= next.Height) continue;
// 행 내 샘플 픽셀 비교
for (int x = 4; x < w - 4; x += 12)
{
int idxP = prevRow * stridePrev + x * 4;
int idxN = nextRow * strideNext + x * 4;
if (idxP + 2 >= dataPrev.Height * stridePrev) continue;
if (idxN + 2 >= dataNext.Height * strideNext) continue;
if (Math.Abs(ptrPrev[idxP] - ptrNext[idxN]) < 10 &&
Math.Abs(ptrPrev[idxP + 1] - ptrNext[idxN + 1]) < 10 &&
Math.Abs(ptrPrev[idxP + 2] - ptrNext[idxN + 2]) < 10)
totalMatch++;
totalCheck++;
}
}
if (totalCheck > 0 && (double)totalMatch / totalCheck > 0.80)
{
bestOverlap = overlap;
break;
}
}
}
return bestOverlap;
}
finally
{
prev.UnlockBits(dataPrev);
next.UnlockBits(dataNext);
}
}
// ─── 영역 선택 캡처 ──────────────────────────────────────────────────────
private async Task CaptureRegionAsync(string timestamp, CancellationToken ct)
{
// 전체 화면을 먼저 캡처 (배경으로 사용)
var bounds = GetAllScreenBounds();
using var fullBmp = new Bitmap(bounds.Width, bounds.Height, PixelFormat.Format32bppArgb);
using (var g = Graphics.FromImage(fullBmp))
g.CopyFromScreen(bounds.X, bounds.Y, 0, 0, bounds.Size, CopyPixelOperation.SourceCopy);
// WPF 오버레이 창으로 영역 선택
System.Drawing.Rectangle? selected = null;
System.Windows.Application.Current?.Dispatcher.Invoke(() =>
{
var overlay = new AxCopilot.Views.RegionSelectWindow(fullBmp, bounds);
overlay.ShowDialog();
selected = overlay.SelectedRect;
});
if (selected == null || selected.Value.Width < 4 || selected.Value.Height < 4)
{
NotificationService.Notify("AX Copilot", "영역 선택이 취소되었습니다.");
return;
}
var r = selected.Value;
using var crop = new Bitmap(r.Width, r.Height, PixelFormat.Format32bppArgb);
using (var g = Graphics.FromImage(crop))
g.DrawImage(fullBmp, new System.Drawing.Rectangle(0, 0, r.Width, r.Height), r, System.Drawing.GraphicsUnit.Pixel);
CopyToClipboard(crop);
NotificationService.Notify("영역 캡처 완료", $"{r.Width}×{r.Height} · 클립보드에 복사되었습니다");
await Task.CompletedTask;
}
// ─── 헬퍼: 클립보드에 이미지 복사 ───────────────────────────────────────
private static void CopyToClipboard(Bitmap bmp)
{
try
{
// WPF Clipboard에 BitmapSource로 복사
System.Windows.Application.Current?.Dispatcher.Invoke(() =>
{
using var ms = new MemoryStream();
bmp.Save(ms, ImageFormat.Bmp);
ms.Position = 0;
var bitmapImage = new BitmapImage();
bitmapImage.BeginInit();
bitmapImage.StreamSource = ms;
bitmapImage.CacheOption = BitmapCacheOption.OnLoad;
bitmapImage.EndInit();
bitmapImage.Freeze();
System.Windows.Clipboard.SetImage(bitmapImage);
});
}
catch (Exception ex)
{
LogService.Warn($"클립보드 이미지 복사 실패: {ex.Message}");
}
}
// ─── 헬퍼: 런처 창 핸들 조회 (캡처 시 런처 숨김 대기용) ───────────────
private static IntPtr GetLauncherHwnd()
{
try
{
IntPtr hwnd = IntPtr.Zero;
System.Windows.Application.Current?.Dispatcher.Invoke(() =>
{
var launcher = System.Windows.Application.Current.Windows
.OfType<System.Windows.Window>()
.FirstOrDefault(w => w.GetType().Name == "LauncherWindow");
if (launcher != null)
hwnd = new System.Windows.Interop.WindowInteropHelper(launcher).Handle;
});
return hwnd;
}
catch (Exception) { return IntPtr.Zero; }
}
}

View File

@@ -20,7 +20,7 @@ namespace AxCopilot.Handlers;
/// 파일 저장 여부 / 경로는 설정 → 캡처 탭에서 변경 가능.
/// 기본값: 저장 안 함, 클립보드에만 복사.
/// </summary>
public class ScreenCaptureHandler : IActionHandler
public partial class ScreenCaptureHandler : IActionHandler
{
private readonly AxCopilot.Services.SettingsService _settings;
@@ -237,401 +237,4 @@ public class ScreenCaptureHandler : IActionHandler
NotificationService.Notify("창 캡처 완료", "클립보드에 복사되었습니다");
}
// ─── 스크롤 캡처 ───────────────────────────────────────────────────────────
private async Task CaptureScrollAsync(string timestamp, CancellationToken ct)
{
var hwnd = WindowTracker.PreviousWindow;
if (hwnd == IntPtr.Zero || !IsWindow(hwnd))
{
NotificationService.Notify("AX Copilot", "캡처할 창이 없습니다.");
return;
}
// 런처가 완전히 사라질 때까지 대기
var launcherHwnd = GetLauncherHwnd();
if (launcherHwnd != IntPtr.Zero)
{
for (int i = 0; i < 10 && IsWindowVisible(launcherHwnd); i++)
await Task.Delay(50, ct);
}
ShowWindow(hwnd, SW_RESTORE);
SetForegroundWindow(hwnd);
await Task.Delay(200, ct);
if (!GetWindowRect(hwnd, out var rect)) return;
int w = rect.right - rect.left;
int h = rect.bottom - rect.top;
if (w <= 0 || h <= 0) return;
// 스크롤 가능한 자식 창 찾기 (웹브라우저, 텍스트뷰어 등)
var scrollTarget = FindScrollableChild(hwnd);
const int maxPages = 15;
var frames = new List<Bitmap>();
// 첫 프레임
frames.Add(CaptureWindow(hwnd, w, h, rect));
for (int i = 0; i < maxPages - 1; i++)
{
ct.ThrowIfCancellationRequested();
// Page Down 전송
if (scrollTarget != IntPtr.Zero)
SendMessage(scrollTarget, WM_VSCROLL, new IntPtr(SB_PAGEDOWN), IntPtr.Zero);
else
SendPageDown(hwnd);
await Task.Delay(ScrollDelayMs, ct);
// 현재 프레임 캡처
if (!GetWindowRect(hwnd, out var newRect)) break;
var frame = CaptureWindow(hwnd, w, h, newRect);
// 이전 프레임과 동일하면 끝 (스크롤 종료 감지)
if (frames.Count > 0 && AreSimilar(frames[^1], frame))
{
frame.Dispose();
break;
}
frames.Add(frame);
}
// 프레임들을 수직으로 이어 붙이기 (오버랩 제거)
using var stitched = StitchFrames(frames, h);
// 각 프레임 해제
foreach (var f in frames) f.Dispose();
CopyToClipboard(stitched);
NotificationService.Notify("스크롤 캡처 완료", $"{stitched.Height}px · 클립보드에 복사되었습니다");
}
// ─── 헬퍼: 창 캡처 (PrintWindow 우선, 실패 시 BitBlt 폴백) ──────────────
private static Bitmap CaptureWindow(IntPtr hwnd, int w, int h, RECT rect)
{
var bmp = new Bitmap(w, h, PixelFormat.Format32bppArgb);
using var g = Graphics.FromImage(bmp);
// PrintWindow로 창 내용 캡처 (최소화된 창도 동작)
var hdc = g.GetHdc();
bool ok = PrintWindow(hwnd, hdc, 2); // PW_RENDERFULLCONTENT = 2
g.ReleaseHdc(hdc);
if (!ok)
{
// 폴백: 화면에서 BitBlt
g.CopyFromScreen(rect.left, rect.top, 0, 0,
new System.Drawing.Size(w, h), CopyPixelOperation.SourceCopy);
}
return bmp;
}
// ─── 헬퍼: 전체 화면 범위 ─────────────────────────────────────────────────
private static System.Drawing.Rectangle GetAllScreenBounds()
{
var screens = System.Windows.Forms.Screen.AllScreens;
int minX = screens.Min(s => s.Bounds.X);
int minY = screens.Min(s => s.Bounds.Y);
int maxX = screens.Max(s => s.Bounds.Right);
int maxY = screens.Max(s => s.Bounds.Bottom);
return new System.Drawing.Rectangle(minX, minY, maxX - minX, maxY - minY);
}
// ─── 헬퍼: 스크롤 가능 자식 창 찾기 ──────────────────────────────────────
private static IntPtr FindScrollableChild(IntPtr hwnd)
{
// 공통 스크롤 가능 클래스 탐색
foreach (var cls in new[] { "Internet Explorer_Server", "Chrome_RenderWidgetHostHWND",
"MozillaWindowClass", "RichEdit20W", "RICHEDIT50W",
"TextBox", "EDIT" })
{
var child = FindWindowEx(hwnd, IntPtr.Zero, cls, null);
if (child != IntPtr.Zero) return child;
}
return IntPtr.Zero;
}
// ─── 헬퍼: Page Down 키 전송 ─────────────────────────────────────────────
private static void SendPageDown(IntPtr hwnd)
{
SendMessage(hwnd, WM_KEYDOWN, new IntPtr(VK_NEXT), IntPtr.Zero);
SendMessage(hwnd, WM_KEYUP, new IntPtr(VK_NEXT), IntPtr.Zero);
}
// ─── 헬퍼: 두 비트맵이 유사한지 비교 (스크롤 종료 감지) ─────────────────
// LockBits를 사용하여 GetPixel 대비 ~50× 빠르게 처리.
private static bool AreSimilar(Bitmap a, Bitmap b)
{
if (a.Width != b.Width || a.Height != b.Height) return false;
int startY = (int)(a.Height * 0.8);
int w = a.Width;
int h = a.Height;
var rectA = new System.Drawing.Rectangle(0, startY, w, h - startY);
var rectB = new System.Drawing.Rectangle(0, startY, w, h - startY);
var dataA = a.LockBits(rectA, ImageLockMode.ReadOnly, PixelFormat.Format32bppArgb);
var dataB = b.LockBits(rectB, ImageLockMode.ReadOnly, PixelFormat.Format32bppArgb);
try
{
int sameCount = 0;
int totalSamples = 0;
int stride = dataA.Stride;
int sampleW = w / 16 + 1;
int sampleH = (h - startY) / 8 + 1;
unsafe
{
byte* ptrA = (byte*)dataA.Scan0.ToPointer();
byte* ptrB = (byte*)dataB.Scan0.ToPointer();
for (int sy = 0; sy < sampleH; sy++)
{
int row = sy * 8;
if (row >= h - startY) break;
for (int sx = 0; sx < sampleW; sx++)
{
int col = sx * 16;
if (col >= w) break;
int idx = row * stride + col * 4;
if (Math.Abs(ptrA[idx] - ptrB[idx]) < 5 &&
Math.Abs(ptrA[idx + 1] - ptrB[idx + 1]) < 5 &&
Math.Abs(ptrA[idx + 2] - ptrB[idx + 2]) < 5)
sameCount++;
totalSamples++;
}
}
}
return totalSamples > 0 && (double)sameCount / totalSamples > 0.97;
}
finally
{
a.UnlockBits(dataA);
b.UnlockBits(dataB);
}
}
// ─── 헬퍼: 프레임 이어 붙이기 (증분만 추가) ──────────────────────────────
private static Bitmap StitchFrames(List<Bitmap> frames, int windowHeight)
{
if (frames.Count == 0)
return new Bitmap(1, 1);
if (frames.Count == 1)
return new Bitmap(frames[0]);
int w = frames[0].Width;
// 각 프레임에서 새로운 부분의 시작 Y (오버랩 제외)
var newPartStarts = new List<int>(); // 인덱스 1부터: frames[i]에서 오버랩 이후 시작 행
var newPartHeights = new List<int>(); // 새로운 부분 높이
int totalHeight = windowHeight; // 첫 프레임은 전체 사용
for (int i = 1; i < frames.Count; i++)
{
int overlap = FindOverlap(frames[i - 1], frames[i]);
int newStart = overlap > 0 ? overlap : windowHeight / 5; // 오버랩 감지 실패 시 상단 20% 제거
int newH = windowHeight - newStart;
if (newH <= 0) { newH = windowHeight / 4; newStart = windowHeight - newH; }
newPartStarts.Add(newStart);
newPartHeights.Add(newH);
totalHeight += newH;
}
var result = new Bitmap(w, totalHeight, PixelFormat.Format32bppArgb);
using var g = Graphics.FromImage(result);
// 첫 프레임: 전체 그리기
g.DrawImage(frames[0], 0, 0, w, windowHeight);
// 이후 프레임: 새로운 부분(증분)만 잘라서 붙이기
int yPos = windowHeight;
for (int i = 1; i < frames.Count; i++)
{
int srcY = newPartStarts[i - 1];
int srcH = newPartHeights[i - 1];
var srcRect = new System.Drawing.Rectangle(0, srcY, w, srcH);
var dstRect = new System.Drawing.Rectangle(0, yPos, w, srcH);
g.DrawImage(frames[i], dstRect, srcRect, System.Drawing.GraphicsUnit.Pixel);
yPos += srcH;
}
return result;
}
// ─── 헬퍼: 두 프레임 사이 오버랩 픽셀 수 계산 (다중 행 비교) ────────────
private static int FindOverlap(Bitmap prev, Bitmap next)
{
int w = Math.Min(prev.Width, next.Width);
int h = prev.Height;
if (h < 16 || w < 16) return 0;
int searchRange = (int)(h * 0.7); // 최대 70% 오버랩 탐색
const int checkRows = 8; // 오버랩 후보당 검증할 행 수
var dataPrev = prev.LockBits(
new System.Drawing.Rectangle(0, 0, prev.Width, prev.Height),
ImageLockMode.ReadOnly, PixelFormat.Format32bppArgb);
var dataNext = next.LockBits(
new System.Drawing.Rectangle(0, 0, next.Width, next.Height),
ImageLockMode.ReadOnly, PixelFormat.Format32bppArgb);
try
{
int stridePrev = dataPrev.Stride;
int strideNext = dataNext.Stride;
int bestOverlap = 0;
unsafe
{
byte* ptrPrev = (byte*)dataPrev.Scan0.ToPointer();
byte* ptrNext = (byte*)dataNext.Scan0.ToPointer();
// 큰 오버랩부터 탐색 (스크롤은 보통 1페이지 미만)
for (int overlap = searchRange; overlap > 8; overlap -= 2)
{
int prevStartY = h - overlap;
if (prevStartY < 0) continue;
int totalMatch = 0;
int totalCheck = 0;
// 오버랩 영역 내 여러 행을 검증
for (int r = 0; r < checkRows; r++)
{
int rowInOverlap = r * (overlap / checkRows);
int prevRow = prevStartY + rowInOverlap;
int nextRow = rowInOverlap;
if (prevRow >= h || nextRow >= next.Height) continue;
// 행 내 샘플 픽셀 비교
for (int x = 4; x < w - 4; x += 12)
{
int idxP = prevRow * stridePrev + x * 4;
int idxN = nextRow * strideNext + x * 4;
if (idxP + 2 >= dataPrev.Height * stridePrev) continue;
if (idxN + 2 >= dataNext.Height * strideNext) continue;
if (Math.Abs(ptrPrev[idxP] - ptrNext[idxN]) < 10 &&
Math.Abs(ptrPrev[idxP + 1] - ptrNext[idxN + 1]) < 10 &&
Math.Abs(ptrPrev[idxP + 2] - ptrNext[idxN + 2]) < 10)
totalMatch++;
totalCheck++;
}
}
if (totalCheck > 0 && (double)totalMatch / totalCheck > 0.80)
{
bestOverlap = overlap;
break;
}
}
}
return bestOverlap;
}
finally
{
prev.UnlockBits(dataPrev);
next.UnlockBits(dataNext);
}
}
// ─── 영역 선택 캡처 ──────────────────────────────────────────────────────
private async Task CaptureRegionAsync(string timestamp, CancellationToken ct)
{
// 전체 화면을 먼저 캡처 (배경으로 사용)
var bounds = GetAllScreenBounds();
using var fullBmp = new Bitmap(bounds.Width, bounds.Height, PixelFormat.Format32bppArgb);
using (var g = Graphics.FromImage(fullBmp))
g.CopyFromScreen(bounds.X, bounds.Y, 0, 0, bounds.Size, CopyPixelOperation.SourceCopy);
// WPF 오버레이 창으로 영역 선택
System.Drawing.Rectangle? selected = null;
System.Windows.Application.Current?.Dispatcher.Invoke(() =>
{
var overlay = new AxCopilot.Views.RegionSelectWindow(fullBmp, bounds);
overlay.ShowDialog();
selected = overlay.SelectedRect;
});
if (selected == null || selected.Value.Width < 4 || selected.Value.Height < 4)
{
NotificationService.Notify("AX Copilot", "영역 선택이 취소되었습니다.");
return;
}
var r = selected.Value;
using var crop = new Bitmap(r.Width, r.Height, PixelFormat.Format32bppArgb);
using (var g = Graphics.FromImage(crop))
g.DrawImage(fullBmp, new System.Drawing.Rectangle(0, 0, r.Width, r.Height), r, System.Drawing.GraphicsUnit.Pixel);
CopyToClipboard(crop);
NotificationService.Notify("영역 캡처 완료", $"{r.Width}×{r.Height} · 클립보드에 복사되었습니다");
await Task.CompletedTask;
}
// ─── 헬퍼: 클립보드에 이미지 복사 ───────────────────────────────────────
private static void CopyToClipboard(Bitmap bmp)
{
try
{
// WPF Clipboard에 BitmapSource로 복사
System.Windows.Application.Current?.Dispatcher.Invoke(() =>
{
using var ms = new MemoryStream();
bmp.Save(ms, ImageFormat.Bmp);
ms.Position = 0;
var bitmapImage = new BitmapImage();
bitmapImage.BeginInit();
bitmapImage.StreamSource = ms;
bitmapImage.CacheOption = BitmapCacheOption.OnLoad;
bitmapImage.EndInit();
bitmapImage.Freeze();
System.Windows.Clipboard.SetImage(bitmapImage);
});
}
catch (Exception ex)
{
LogService.Warn($"클립보드 이미지 복사 실패: {ex.Message}");
}
}
// ─── 헬퍼: 런처 창 핸들 조회 (캡처 시 런처 숨김 대기용) ───────────────
private static IntPtr GetLauncherHwnd()
{
try
{
IntPtr hwnd = IntPtr.Zero;
System.Windows.Application.Current?.Dispatcher.Invoke(() =>
{
var launcher = System.Windows.Application.Current.Windows
.OfType<System.Windows.Window>()
.FirstOrDefault(w => w.GetType().Name == "LauncherWindow");
if (launcher != null)
hwnd = new System.Windows.Interop.WindowInteropHelper(launcher).Handle;
});
return hwnd;
}
catch (Exception) { return IntPtr.Zero; }
}
}

View File

@@ -0,0 +1,168 @@
using System.Net.NetworkInformation;
using System.Net.Sockets;
using AxCopilot.SDK;
using AxCopilot.Themes;
namespace AxCopilot.Handlers;
public partial class SystemInfoHandler
{
// ─── 헬퍼 ────────────────────────────────────────────────────────────────
private static LauncherItem MakeItem(string title, string subtitle, string? copyValue, string symbol) =>
new(title, subtitle, null,
copyValue != null ? new InfoAction("copy", copyValue) : null,
Symbol: symbol);
private static string? GetLocalIpAddress()
{
try
{
foreach (var iface in NetworkInterface.GetAllNetworkInterfaces())
{
if (iface.OperationalStatus != OperationalStatus.Up) continue;
if (iface.NetworkInterfaceType is NetworkInterfaceType.Loopback
or NetworkInterfaceType.Tunnel) continue;
foreach (var addr in iface.GetIPProperties().UnicastAddresses)
{
if (addr.Address.AddressFamily == AddressFamily.InterNetwork)
return addr.Address.ToString();
}
}
}
catch (Exception) { /* 무시 */ }
return null;
}
private static string? GetDefaultGateway()
{
try
{
foreach (var iface in NetworkInterface.GetAllNetworkInterfaces())
{
if (iface.OperationalStatus != OperationalStatus.Up) continue;
var gw = iface.GetIPProperties().GatewayAddresses
.FirstOrDefault(g => g.Address.AddressFamily == AddressFamily.InterNetwork);
if (gw != null) return gw.Address.ToString();
}
}
catch (Exception) { /* 무시 */ }
return null;
}
private static LauncherItem? GetBatteryItem()
{
try
{
var status = System.Windows.Forms.SystemInformation.PowerStatus;
var chargeLevel = status.BatteryLifePercent;
if (chargeLevel < 0) // 배터리 없는 데스크톱
return new LauncherItem("배터리: 해당 없음", "데스크톱 PC 또는 항상 연결됨 · Enter로 전원 설정 열기", null,
new InfoAction("ms_settings", "ms-settings:powersleep"), Symbol: Symbols.Battery);
var pct = (int)(chargeLevel * 100);
var charging = status.PowerLineStatus == System.Windows.Forms.PowerLineStatus.Online;
var timeLeft = status.BatteryLifeRemaining >= 0
? $" · 잔여: {FormatUptime(TimeSpan.FromSeconds(status.BatteryLifeRemaining))}"
: "";
var symbol = charging ? Symbols.BatteryCharging
: pct > 50 ? Symbols.Battery
: Symbols.BatteryLow;
return new LauncherItem(
$"배터리: {pct}%{(charging ? " " : "")}",
$"전원: {(charging ? "AC " : " ")}{timeLeft} · Enter로 전원 설정 열기",
null,
new InfoAction("ms_settings", "ms-settings:powersleep"),
Symbol: symbol);
}
catch (Exception) { return null; }
}
private static LauncherItem? GetVolumeItem()
{
try
{
if (waveOutGetVolume(IntPtr.Zero, out uint vol) == 0)
{
// 왼쪽 채널 0~0xFFFF → 0~100 변환
var left = (int)((vol & 0xFFFF) / 655.35);
var right = (int)(((vol >> 16) & 0xFFFF) / 655.35);
var avg = (left + right) / 2;
return new LauncherItem(
$"볼륨: {avg}%",
$"L: {left}% · R: {right}% · Enter로 사운드 설정 열기",
null,
new InfoAction("ms_settings", "ms-settings:sound"),
Symbol: avg == 0 ? Symbols.VolumeMute : Symbols.VolumeUp);
}
}
catch (Exception) { /* 무시 */ }
return null;
}
private static string GetOsVersion()
{
try
{
// registry에서 실제 Windows 버전 읽기
using var key = Microsoft.Win32.Registry.LocalMachine
.OpenSubKey(@"SOFTWARE\Microsoft\Windows NT\CurrentVersion");
if (key != null)
{
var name = key.GetValue("ProductName") as string ?? "Windows";
var build = key.GetValue("CurrentBuildNumber") as string ?? "";
var ubr = key.GetValue("UBR");
return ubr != null ? $"{name} (빌드 {build}.{ubr})" : $"{name} (빌드 {build})";
}
}
catch (Exception) { /* 무시 */ }
return Environment.OSVersion.ToString();
}
private static string GetProcessorName()
{
try
{
using var key = Microsoft.Win32.Registry.LocalMachine
.OpenSubKey(@"HARDWARE\DESCRIPTION\System\CentralProcessor\0");
return key?.GetValue("ProcessorNameString") as string ?? "";
}
catch (Exception) { return ""; }
}
private static string FormatUptime(TimeSpan t)
{
if (t.TotalDays >= 1)
return $"{(int)t.TotalDays}일 {t.Hours}시간 {t.Minutes}분";
if (t.TotalHours >= 1)
return $"{t.Hours}시간 {t.Minutes}분";
return $"{t.Minutes}분 {t.Seconds}초";
}
}
/// <summary>
/// * 단축키로 시스템 정보를 빠르게 조회합니다. SystemInfoHandler에 완전히 위임합니다.
/// </summary>
public class StarInfoHandler : IActionHandler
{
private readonly SystemInfoHandler _inner = new();
public string? Prefix => "*";
public PluginMetadata Metadata => new(
"StarInfo",
"시스템 정보 빠른 조회 — * 단축키 (info와 동일)",
"1.0",
"AX");
public Task<IEnumerable<LauncherItem>> GetItemsAsync(string query, CancellationToken ct)
=> _inner.GetItemsAsync(query, ct);
public Task ExecuteAsync(LauncherItem item, CancellationToken ct)
=> _inner.ExecuteAsync(item, ct);
}

View File

@@ -18,7 +18,7 @@ namespace AxCopilot.Handlers;
/// info uptime → 시스템 가동 시간
/// info volume → 볼륨 수준
/// </summary>
public class SystemInfoHandler : IActionHandler
public partial class SystemInfoHandler : IActionHandler
{
public string? Prefix => "info";
@@ -348,162 +348,4 @@ public class SystemInfoHandler : IActionHandler
// "shell" → 셸 명령 실행 (Payload = 명령)
// "ms_settings" → ms-settings: URI 열기 (Payload = URI)
// ─── 헬퍼 ────────────────────────────────────────────────────────────────
private static LauncherItem MakeItem(string title, string subtitle, string? copyValue, string symbol) =>
new(title, subtitle, null,
copyValue != null ? new InfoAction("copy", copyValue) : null,
Symbol: symbol);
private static string? GetLocalIpAddress()
{
try
{
foreach (var iface in NetworkInterface.GetAllNetworkInterfaces())
{
if (iface.OperationalStatus != OperationalStatus.Up) continue;
if (iface.NetworkInterfaceType is NetworkInterfaceType.Loopback
or NetworkInterfaceType.Tunnel) continue;
foreach (var addr in iface.GetIPProperties().UnicastAddresses)
{
if (addr.Address.AddressFamily == AddressFamily.InterNetwork)
return addr.Address.ToString();
}
}
}
catch (Exception) { /* 무시 */ }
return null;
}
private static string? GetDefaultGateway()
{
try
{
foreach (var iface in NetworkInterface.GetAllNetworkInterfaces())
{
if (iface.OperationalStatus != OperationalStatus.Up) continue;
var gw = iface.GetIPProperties().GatewayAddresses
.FirstOrDefault(g => g.Address.AddressFamily == AddressFamily.InterNetwork);
if (gw != null) return gw.Address.ToString();
}
}
catch (Exception) { /* 무시 */ }
return null;
}
private static LauncherItem? GetBatteryItem()
{
try
{
var status = System.Windows.Forms.SystemInformation.PowerStatus;
var chargeLevel = status.BatteryLifePercent;
if (chargeLevel < 0) // 배터리 없는 데스크톱
return new LauncherItem("배터리: 해당 없음", "데스크톱 PC 또는 항상 연결됨 · Enter로 전원 설정 열기", null,
new InfoAction("ms_settings", "ms-settings:powersleep"), Symbol: Symbols.Battery);
var pct = (int)(chargeLevel * 100);
var charging = status.PowerLineStatus == System.Windows.Forms.PowerLineStatus.Online;
var timeLeft = status.BatteryLifeRemaining >= 0
? $" · 잔여: {FormatUptime(TimeSpan.FromSeconds(status.BatteryLifeRemaining))}"
: "";
var symbol = charging ? Symbols.BatteryCharging
: pct > 50 ? Symbols.Battery
: Symbols.BatteryLow;
return new LauncherItem(
$"배터리: {pct}%{(charging ? " " : "")}",
$"전원: {(charging ? "AC " : " ")}{timeLeft} · Enter로 전원 설정 열기",
null,
new InfoAction("ms_settings", "ms-settings:powersleep"),
Symbol: symbol);
}
catch (Exception) { return null; }
}
private static LauncherItem? GetVolumeItem()
{
try
{
if (waveOutGetVolume(IntPtr.Zero, out uint vol) == 0)
{
// 왼쪽 채널 0~0xFFFF → 0~100 변환
var left = (int)((vol & 0xFFFF) / 655.35);
var right = (int)(((vol >> 16) & 0xFFFF) / 655.35);
var avg = (left + right) / 2;
return new LauncherItem(
$"볼륨: {avg}%",
$"L: {left}% · R: {right}% · Enter로 사운드 설정 열기",
null,
new InfoAction("ms_settings", "ms-settings:sound"),
Symbol: avg == 0 ? Symbols.VolumeMute : Symbols.VolumeUp);
}
}
catch (Exception) { /* 무시 */ }
return null;
}
private static string GetOsVersion()
{
try
{
// registry에서 실제 Windows 버전 읽기
using var key = Microsoft.Win32.Registry.LocalMachine
.OpenSubKey(@"SOFTWARE\Microsoft\Windows NT\CurrentVersion");
if (key != null)
{
var name = key.GetValue("ProductName") as string ?? "Windows";
var build = key.GetValue("CurrentBuildNumber") as string ?? "";
var ubr = key.GetValue("UBR");
return ubr != null ? $"{name} (빌드 {build}.{ubr})" : $"{name} (빌드 {build})";
}
}
catch (Exception) { /* 무시 */ }
return Environment.OSVersion.ToString();
}
private static string GetProcessorName()
{
try
{
using var key = Microsoft.Win32.Registry.LocalMachine
.OpenSubKey(@"HARDWARE\DESCRIPTION\System\CentralProcessor\0");
return key?.GetValue("ProcessorNameString") as string ?? "";
}
catch (Exception) { return ""; }
}
private static string FormatUptime(TimeSpan t)
{
if (t.TotalDays >= 1)
return $"{(int)t.TotalDays}일 {t.Hours}시간 {t.Minutes}분";
if (t.TotalHours >= 1)
return $"{t.Hours}시간 {t.Minutes}분";
return $"{t.Minutes}분 {t.Seconds}초";
}
}
/// <summary>
/// * 단축키로 시스템 정보를 빠르게 조회합니다. SystemInfoHandler에 완전히 위임합니다.
/// </summary>
public class StarInfoHandler : IActionHandler
{
private readonly SystemInfoHandler _inner = new();
public string? Prefix => "*";
public PluginMetadata Metadata => new(
"StarInfo",
"시스템 정보 빠른 조회 — * 단축키 (info와 동일)",
"1.0",
"AX");
public Task<IEnumerable<LauncherItem>> GetItemsAsync(string query, CancellationToken ct)
=> _inner.GetItemsAsync(query, ct);
public Task ExecuteAsync(LauncherItem item, CancellationToken ct)
=> _inner.ExecuteAsync(item, ct);
}