From b750849c9f623adbe9a7c08e15c5f081eda0afac Mon Sep 17 00:00:00 2001 From: lacvet Date: Fri, 3 Apr 2026 21:40:56 +0900 Subject: [PATCH] =?UTF-8?q?[Phase52]=207=EA=B0=9C=20=ED=8C=8C=EC=9D=BC=20?= =?UTF-8?q?=EC=B6=94=EA=B0=80=20=EB=B6=84=EB=A6=AC=20=E2=80=94=2014?= =?UTF-8?q?=EA=B0=9C=20=ED=8C=8C=EC=9D=BC=EB=A1=9C=20=EC=9E=AC=EA=B5=AC?= =?UTF-8?q?=EC=84=B1?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- .../Handlers/ScreenCaptureHandler.Helpers.cs | 411 ++++++++++++++++++ .../Handlers/ScreenCaptureHandler.cs | 399 +---------------- .../Handlers/SystemInfoHandler.Helpers.cs | 168 +++++++ src/AxCopilot/Handlers/SystemInfoHandler.cs | 160 +------ src/AxCopilot/Models/AppSettings.Models.cs | 262 +++++++++++ src/AxCopilot/Models/AppSettings.cs | 256 ----------- .../Services/Agent/ChartSkill.Renderers.cs | 373 ++++++++++++++++ src/AxCopilot/Services/Agent/ChartSkill.cs | 365 +--------------- .../Services/Agent/DocxSkill.Builders.cs | 397 +++++++++++++++++ src/AxCopilot/Services/Agent/DocxSkill.cs | 387 +---------------- .../Services/LlmService.GeminiClaude.cs | 277 ++++++++++++ .../Services/LlmService.Streaming.cs | 263 ----------- .../Views/SkillEditorWindow.PreviewSave.cs | 238 ++++++++++ src/AxCopilot/Views/SkillEditorWindow.xaml.cs | 225 ---------- 14 files changed, 2130 insertions(+), 2051 deletions(-) create mode 100644 src/AxCopilot/Handlers/ScreenCaptureHandler.Helpers.cs create mode 100644 src/AxCopilot/Handlers/SystemInfoHandler.Helpers.cs create mode 100644 src/AxCopilot/Models/AppSettings.Models.cs create mode 100644 src/AxCopilot/Services/Agent/ChartSkill.Renderers.cs create mode 100644 src/AxCopilot/Services/Agent/DocxSkill.Builders.cs create mode 100644 src/AxCopilot/Services/LlmService.GeminiClaude.cs create mode 100644 src/AxCopilot/Views/SkillEditorWindow.PreviewSave.cs diff --git a/src/AxCopilot/Handlers/ScreenCaptureHandler.Helpers.cs b/src/AxCopilot/Handlers/ScreenCaptureHandler.Helpers.cs new file mode 100644 index 0000000..6766c7d --- /dev/null +++ b/src/AxCopilot/Handlers/ScreenCaptureHandler.Helpers.cs @@ -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(); + + // 첫 프레임 + 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 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(); // 인덱스 1부터: frames[i]에서 오버랩 이후 시작 행 + var newPartHeights = new List(); // 새로운 부분 높이 + + 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() + .FirstOrDefault(w => w.GetType().Name == "LauncherWindow"); + if (launcher != null) + hwnd = new System.Windows.Interop.WindowInteropHelper(launcher).Handle; + }); + return hwnd; + } + catch (Exception) { return IntPtr.Zero; } + } +} diff --git a/src/AxCopilot/Handlers/ScreenCaptureHandler.cs b/src/AxCopilot/Handlers/ScreenCaptureHandler.cs index f83be1c..5b2d630 100644 --- a/src/AxCopilot/Handlers/ScreenCaptureHandler.cs +++ b/src/AxCopilot/Handlers/ScreenCaptureHandler.cs @@ -20,7 +20,7 @@ namespace AxCopilot.Handlers; /// 파일 저장 여부 / 경로는 설정 → 캡처 탭에서 변경 가능. /// 기본값: 저장 안 함, 클립보드에만 복사. /// -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(); - - // 첫 프레임 - 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 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(); // 인덱스 1부터: frames[i]에서 오버랩 이후 시작 행 - var newPartHeights = new List(); // 새로운 부분 높이 - - 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() - .FirstOrDefault(w => w.GetType().Name == "LauncherWindow"); - if (launcher != null) - hwnd = new System.Windows.Interop.WindowInteropHelper(launcher).Handle; - }); - return hwnd; - } - catch (Exception) { return IntPtr.Zero; } - } } diff --git a/src/AxCopilot/Handlers/SystemInfoHandler.Helpers.cs b/src/AxCopilot/Handlers/SystemInfoHandler.Helpers.cs new file mode 100644 index 0000000..6c00ee7 --- /dev/null +++ b/src/AxCopilot/Handlers/SystemInfoHandler.Helpers.cs @@ -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}초"; + } +} + +/// +/// * 단축키로 시스템 정보를 빠르게 조회합니다. SystemInfoHandler에 완전히 위임합니다. +/// +public class StarInfoHandler : IActionHandler +{ + private readonly SystemInfoHandler _inner = new(); + + public string? Prefix => "*"; + + public PluginMetadata Metadata => new( + "StarInfo", + "시스템 정보 빠른 조회 — * 단축키 (info와 동일)", + "1.0", + "AX"); + + public Task> GetItemsAsync(string query, CancellationToken ct) + => _inner.GetItemsAsync(query, ct); + + public Task ExecuteAsync(LauncherItem item, CancellationToken ct) + => _inner.ExecuteAsync(item, ct); +} diff --git a/src/AxCopilot/Handlers/SystemInfoHandler.cs b/src/AxCopilot/Handlers/SystemInfoHandler.cs index 3fc3049..a14fcdc 100644 --- a/src/AxCopilot/Handlers/SystemInfoHandler.cs +++ b/src/AxCopilot/Handlers/SystemInfoHandler.cs @@ -18,7 +18,7 @@ namespace AxCopilot.Handlers; /// info uptime → 시스템 가동 시간 /// info volume → 볼륨 수준 /// -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}초"; - } -} - -/// -/// * 단축키로 시스템 정보를 빠르게 조회합니다. SystemInfoHandler에 완전히 위임합니다. -/// -public class StarInfoHandler : IActionHandler -{ - private readonly SystemInfoHandler _inner = new(); - - public string? Prefix => "*"; - - public PluginMetadata Metadata => new( - "StarInfo", - "시스템 정보 빠른 조회 — * 단축키 (info와 동일)", - "1.0", - "AX"); - - public Task> GetItemsAsync(string query, CancellationToken ct) - => _inner.GetItemsAsync(query, ct); - - public Task ExecuteAsync(LauncherItem item, CancellationToken ct) - => _inner.ExecuteAsync(item, ct); } diff --git a/src/AxCopilot/Models/AppSettings.Models.cs b/src/AxCopilot/Models/AppSettings.Models.cs new file mode 100644 index 0000000..75ac1ce --- /dev/null +++ b/src/AxCopilot/Models/AppSettings.Models.cs @@ -0,0 +1,262 @@ +using System.Text.Json.Serialization; + +namespace AxCopilot.Models; + +// 기타 설정 모델 클래스 + +public class WorkspaceProfile +{ + [JsonPropertyName("name")] + public string Name { get; set; } = ""; + + [JsonPropertyName("windows")] + public List Windows { get; set; } = new(); + + [JsonPropertyName("createdAt")] + public DateTime CreatedAt { get; set; } = DateTime.Now; +} + +public class WindowSnapshot +{ + [JsonPropertyName("exe")] + public string Exe { get; set; } = ""; + + [JsonPropertyName("title")] + public string Title { get; set; } = ""; + + [JsonPropertyName("rect")] + public WindowRect Rect { get; set; } = new(); + + [JsonPropertyName("showCmd")] + public string ShowCmd { get; set; } = "Normal"; // Normal | Minimized | Maximized + + [JsonPropertyName("monitor")] + public int Monitor { get; set; } = 0; +} + +public class WindowRect +{ + [JsonPropertyName("x")] + public int X { get; set; } + + [JsonPropertyName("y")] + public int Y { get; set; } + + [JsonPropertyName("width")] + public int Width { get; set; } + + [JsonPropertyName("height")] + public int Height { get; set; } +} + +public class AliasEntry +{ + [JsonPropertyName("key")] + public string Key { get; set; } = ""; + + [JsonPropertyName("type")] + public string Type { get; set; } = "url"; // url | folder | app | batch | api | clipboard + + [JsonPropertyName("target")] + public string Target { get; set; } = ""; + + [JsonPropertyName("description")] + public string? Description { get; set; } + + [JsonPropertyName("showWindow")] + public bool ShowWindow { get; set; } = false; + + [JsonPropertyName("adapter")] + public string? Adapter { get; set; } + + [JsonPropertyName("query")] + public string? Query { get; set; } +} + +public class ClipboardTransformer +{ + [JsonPropertyName("key")] + public string Key { get; set; } = ""; + + [JsonPropertyName("type")] + public string Type { get; set; } = "regex"; // regex | script + + [JsonPropertyName("pattern")] + public string? Pattern { get; set; } + + [JsonPropertyName("replace")] + public string? Replace { get; set; } + + [JsonPropertyName("command")] + public string? Command { get; set; } + + [JsonPropertyName("timeout")] + public int Timeout { get; set; } = 5000; + + [JsonPropertyName("description")] + public string? Description { get; set; } +} + +public class ApiAdapter +{ + [JsonPropertyName("id")] + public string Id { get; set; } = ""; + + [JsonPropertyName("baseUrl")] + public string BaseUrl { get; set; } = ""; + + [JsonPropertyName("credentialKey")] + public string CredentialKey { get; set; } = ""; +} + +public class PluginEntry +{ + [JsonPropertyName("path")] + public string Path { get; set; } = ""; + + [JsonPropertyName("enabled")] + public bool Enabled { get; set; } = true; +} + +// ─── 스니펫 ─────────────────────────────────────────────────────────────────── + +public class SnippetEntry +{ + [JsonPropertyName("key")] + public string Key { get; set; } = ""; // 트리거 키워드 (예: addr, sig) + + [JsonPropertyName("name")] + public string Name { get; set; } = ""; // 표시 이름 + + [JsonPropertyName("content")] + public string Content { get; set; } = ""; // 확장될 전체 텍스트 +} + +// ─── 퀵링크 ────────────────────────────────────────────────────────────────── + +/// +/// 파라미터 퀵링크 항목. +/// 예: keyword="maps", urlTemplate="https://map.naver.com/p/search/{0}" +/// 사용: ql maps 강남역 → URL에 "강남역" 치환 후 브라우저 열기 +/// +public class QuickLinkEntry +{ + [JsonPropertyName("keyword")] + public string Keyword { get; set; } = ""; // 트리거 키워드 + + [JsonPropertyName("name")] + public string Name { get; set; } = ""; // 표시 이름 + + [JsonPropertyName("urlTemplate")] + public string UrlTemplate { get; set; } = ""; // {0}, {1} 또는 {query} 플레이스홀더 + + [JsonPropertyName("description")] + public string Description { get; set; } = ""; // 설명 +} + +// ─── AI 스니펫 템플릿 ───────────────────────────────────────────────────────── + +/// +/// AI 스니펫 템플릿 항목. +/// 예: keyword="email", prompt="다음 상황에 맞는 업무 이메일 작성: {0}" +/// 사용: ai email 프로젝트 일정 변경 안내 → AI가 이메일 초안 생성 +/// +public class AiSnippetTemplate +{ + [JsonPropertyName("keyword")] + public string Keyword { get; set; } = ""; + + [JsonPropertyName("name")] + public string Name { get; set; } = ""; + + [JsonPropertyName("prompt")] + public string Prompt { get; set; } = ""; // {0}, {query} 플레이스홀더 지원 +} + +// ─── 클립보드 히스토리 ──────────────────────────────────────────────────────── + +public class ClipboardHistorySettings +{ + [JsonPropertyName("enabled")] + public bool Enabled { get; set; } = true; + + [JsonPropertyName("maxItems")] + public int MaxItems { get; set; } = 50; + + [JsonPropertyName("excludePatterns")] + public List ExcludePatterns { get; set; } = new() + { + @"^\d{4}[\s\-]?\d{4}[\s\-]?\d{4}[\s\-]?\d{4}$", // 카드번호 + @"^(?:\d{1,3}\.){3}\d{1,3}$" // IP 주소 + }; +} + +// ─── 시스템 명령 ────────────────────────────────────────────────────────────── + +public class SystemCommandSettings +{ + [JsonPropertyName("showLock")] public bool ShowLock { get; set; } = true; + [JsonPropertyName("showSleep")] public bool ShowSleep { get; set; } = true; + [JsonPropertyName("showRestart")] public bool ShowRestart { get; set; } = true; + [JsonPropertyName("showShutdown")] public bool ShowShutdown { get; set; } = true; + [JsonPropertyName("showHibernate")] public bool ShowHibernate { get; set; } = false; + [JsonPropertyName("showLogout")] public bool ShowLogout { get; set; } = true; + [JsonPropertyName("showRecycleBin")] public bool ShowRecycleBin { get; set; } = true; + + /// + /// 시스템 명령 별칭. key = 기본 명령어(lock/sleep 등), value = 사용자 정의 별칭 목록. + /// 예: { "lock": ["잠금", "l"] } → /잠금, /l 로도 실행 가능 + /// + [JsonPropertyName("commandAliases")] + public Dictionary> CommandAliases { get; set; } = new(); +} + +// ─── 스크린 캡처 설정 ────────────────────────────────────────────────────────── + +public class ScreenCaptureSettings +{ + /// 캡처 명령어 프리픽스. 기본값 "cap". + [JsonPropertyName("prefix")] + public string Prefix { get; set; } = "cap"; + + /// 런처를 열지 않고 글로벌 단축키로 캡처하는 기능 활성화 여부. + [JsonPropertyName("globalHotkeyEnabled")] + public bool GlobalHotkeyEnabled { get; set; } = false; + + /// 글로벌 캡처 단축키 문자열. 기본값 "PrintScreen". + [JsonPropertyName("globalHotkey")] + public string GlobalHotkey { get; set; } = "PrintScreen"; + + /// 글로벌 캡처 단축키 실행 모드. screen|window|region. + [JsonPropertyName("globalHotkeyMode")] + public string GlobalHotkeyMode { get; set; } = "screen"; + + /// 스크롤 캡처 프레임 간 대기 시간(ms). 기본값 120. + [JsonPropertyName("scrollDelayMs")] + public int ScrollDelayMs { get; set; } = 120; +} + +// ─── 잠금 해제 알림 설정 ─────────────────────────────────────────────────────── + +public class ReminderSettings +{ + /// 기능 활성화 여부. 기본값 false. + [JsonPropertyName("enabled")] + public bool Enabled { get; set; } = false; + + /// 팝업 표시 위치. top-left | top-right | bottom-left | bottom-right + [JsonPropertyName("corner")] + public string Corner { get; set; } = "bottom-right"; + + /// 알림 간격(분). 30 | 60 | 120 | 180 | 240 + [JsonPropertyName("intervalMinutes")] + public int IntervalMinutes { get; set; } = 60; + + /// 팝업 자동 닫힘 시간(초). 기본값 15. (5/10/15/20/30/60/120/180) + [JsonPropertyName("displaySeconds")] + public int DisplaySeconds { get; set; } = 15; + + /// 알림 콘텐츠 카테고리 활성화 목록. + [JsonPropertyName("enabledCategories")] + public List EnabledCategories { get; set; } = new() { "motivational" }; +} diff --git a/src/AxCopilot/Models/AppSettings.cs b/src/AxCopilot/Models/AppSettings.cs index ba4acba..b78bd9a 100644 --- a/src/AxCopilot/Models/AppSettings.cs +++ b/src/AxCopilot/Models/AppSettings.cs @@ -306,259 +306,3 @@ public class CustomThemeColors public int ItemCornerRadius { get; set; } = 10; } -public class WorkspaceProfile -{ - [JsonPropertyName("name")] - public string Name { get; set; } = ""; - - [JsonPropertyName("windows")] - public List Windows { get; set; } = new(); - - [JsonPropertyName("createdAt")] - public DateTime CreatedAt { get; set; } = DateTime.Now; -} - -public class WindowSnapshot -{ - [JsonPropertyName("exe")] - public string Exe { get; set; } = ""; - - [JsonPropertyName("title")] - public string Title { get; set; } = ""; - - [JsonPropertyName("rect")] - public WindowRect Rect { get; set; } = new(); - - [JsonPropertyName("showCmd")] - public string ShowCmd { get; set; } = "Normal"; // Normal | Minimized | Maximized - - [JsonPropertyName("monitor")] - public int Monitor { get; set; } = 0; -} - -public class WindowRect -{ - [JsonPropertyName("x")] - public int X { get; set; } - - [JsonPropertyName("y")] - public int Y { get; set; } - - [JsonPropertyName("width")] - public int Width { get; set; } - - [JsonPropertyName("height")] - public int Height { get; set; } -} - -public class AliasEntry -{ - [JsonPropertyName("key")] - public string Key { get; set; } = ""; - - [JsonPropertyName("type")] - public string Type { get; set; } = "url"; // url | folder | app | batch | api | clipboard - - [JsonPropertyName("target")] - public string Target { get; set; } = ""; - - [JsonPropertyName("description")] - public string? Description { get; set; } - - [JsonPropertyName("showWindow")] - public bool ShowWindow { get; set; } = false; - - [JsonPropertyName("adapter")] - public string? Adapter { get; set; } - - [JsonPropertyName("query")] - public string? Query { get; set; } -} - -public class ClipboardTransformer -{ - [JsonPropertyName("key")] - public string Key { get; set; } = ""; - - [JsonPropertyName("type")] - public string Type { get; set; } = "regex"; // regex | script - - [JsonPropertyName("pattern")] - public string? Pattern { get; set; } - - [JsonPropertyName("replace")] - public string? Replace { get; set; } - - [JsonPropertyName("command")] - public string? Command { get; set; } - - [JsonPropertyName("timeout")] - public int Timeout { get; set; } = 5000; - - [JsonPropertyName("description")] - public string? Description { get; set; } -} - -public class ApiAdapter -{ - [JsonPropertyName("id")] - public string Id { get; set; } = ""; - - [JsonPropertyName("baseUrl")] - public string BaseUrl { get; set; } = ""; - - [JsonPropertyName("credentialKey")] - public string CredentialKey { get; set; } = ""; -} - -public class PluginEntry -{ - [JsonPropertyName("path")] - public string Path { get; set; } = ""; - - [JsonPropertyName("enabled")] - public bool Enabled { get; set; } = true; -} - -// ─── 스니펫 ─────────────────────────────────────────────────────────────────── - -public class SnippetEntry -{ - [JsonPropertyName("key")] - public string Key { get; set; } = ""; // 트리거 키워드 (예: addr, sig) - - [JsonPropertyName("name")] - public string Name { get; set; } = ""; // 표시 이름 - - [JsonPropertyName("content")] - public string Content { get; set; } = ""; // 확장될 전체 텍스트 -} - -// ─── 퀵링크 ────────────────────────────────────────────────────────────────── - -/// -/// 파라미터 퀵링크 항목. -/// 예: keyword="maps", urlTemplate="https://map.naver.com/p/search/{0}" -/// 사용: ql maps 강남역 → URL에 "강남역" 치환 후 브라우저 열기 -/// -public class QuickLinkEntry -{ - [JsonPropertyName("keyword")] - public string Keyword { get; set; } = ""; // 트리거 키워드 - - [JsonPropertyName("name")] - public string Name { get; set; } = ""; // 표시 이름 - - [JsonPropertyName("urlTemplate")] - public string UrlTemplate { get; set; } = ""; // {0}, {1} 또는 {query} 플레이스홀더 - - [JsonPropertyName("description")] - public string Description { get; set; } = ""; // 설명 -} - -// ─── AI 스니펫 템플릿 ───────────────────────────────────────────────────────── - -/// -/// AI 스니펫 템플릿 항목. -/// 예: keyword="email", prompt="다음 상황에 맞는 업무 이메일 작성: {0}" -/// 사용: ai email 프로젝트 일정 변경 안내 → AI가 이메일 초안 생성 -/// -public class AiSnippetTemplate -{ - [JsonPropertyName("keyword")] - public string Keyword { get; set; } = ""; - - [JsonPropertyName("name")] - public string Name { get; set; } = ""; - - [JsonPropertyName("prompt")] - public string Prompt { get; set; } = ""; // {0}, {query} 플레이스홀더 지원 -} - -// ─── 클립보드 히스토리 ──────────────────────────────────────────────────────── - -public class ClipboardHistorySettings -{ - [JsonPropertyName("enabled")] - public bool Enabled { get; set; } = true; - - [JsonPropertyName("maxItems")] - public int MaxItems { get; set; } = 50; - - [JsonPropertyName("excludePatterns")] - public List ExcludePatterns { get; set; } = new() - { - @"^\d{4}[\s\-]?\d{4}[\s\-]?\d{4}[\s\-]?\d{4}$", // 카드번호 - @"^(?:\d{1,3}\.){3}\d{1,3}$" // IP 주소 - }; -} - -// ─── 시스템 명령 ────────────────────────────────────────────────────────────── - -public class SystemCommandSettings -{ - [JsonPropertyName("showLock")] public bool ShowLock { get; set; } = true; - [JsonPropertyName("showSleep")] public bool ShowSleep { get; set; } = true; - [JsonPropertyName("showRestart")] public bool ShowRestart { get; set; } = true; - [JsonPropertyName("showShutdown")] public bool ShowShutdown { get; set; } = true; - [JsonPropertyName("showHibernate")] public bool ShowHibernate { get; set; } = false; - [JsonPropertyName("showLogout")] public bool ShowLogout { get; set; } = true; - [JsonPropertyName("showRecycleBin")] public bool ShowRecycleBin { get; set; } = true; - - /// - /// 시스템 명령 별칭. key = 기본 명령어(lock/sleep 등), value = 사용자 정의 별칭 목록. - /// 예: { "lock": ["잠금", "l"] } → /잠금, /l 로도 실행 가능 - /// - [JsonPropertyName("commandAliases")] - public Dictionary> CommandAliases { get; set; } = new(); -} - -// ─── 스크린 캡처 설정 ────────────────────────────────────────────────────────── - -public class ScreenCaptureSettings -{ - /// 캡처 명령어 프리픽스. 기본값 "cap". - [JsonPropertyName("prefix")] - public string Prefix { get; set; } = "cap"; - - /// 런처를 열지 않고 글로벌 단축키로 캡처하는 기능 활성화 여부. - [JsonPropertyName("globalHotkeyEnabled")] - public bool GlobalHotkeyEnabled { get; set; } = false; - - /// 글로벌 캡처 단축키 문자열. 기본값 "PrintScreen". - [JsonPropertyName("globalHotkey")] - public string GlobalHotkey { get; set; } = "PrintScreen"; - - /// 글로벌 캡처 단축키 실행 모드. screen|window|region. - [JsonPropertyName("globalHotkeyMode")] - public string GlobalHotkeyMode { get; set; } = "screen"; - - /// 스크롤 캡처 프레임 간 대기 시간(ms). 기본값 120. - [JsonPropertyName("scrollDelayMs")] - public int ScrollDelayMs { get; set; } = 120; -} - -// ─── 잠금 해제 알림 설정 ─────────────────────────────────────────────────────── - -public class ReminderSettings -{ - /// 기능 활성화 여부. 기본값 false. - [JsonPropertyName("enabled")] - public bool Enabled { get; set; } = false; - - /// 팝업 표시 위치. top-left | top-right | bottom-left | bottom-right - [JsonPropertyName("corner")] - public string Corner { get; set; } = "bottom-right"; - - /// 알림 간격(분). 30 | 60 | 120 | 180 | 240 - [JsonPropertyName("intervalMinutes")] - public int IntervalMinutes { get; set; } = 60; - - /// 팝업 자동 닫힘 시간(초). 기본값 15. (5/10/15/20/30/60/120/180) - [JsonPropertyName("displaySeconds")] - public int DisplaySeconds { get; set; } = 15; - - /// 알림 콘텐츠 카테고리 활성화 목록. - [JsonPropertyName("enabledCategories")] - public List EnabledCategories { get; set; } = new() { "motivational" }; -} diff --git a/src/AxCopilot/Services/Agent/ChartSkill.Renderers.cs b/src/AxCopilot/Services/Agent/ChartSkill.Renderers.cs new file mode 100644 index 0000000..45c2ef4 --- /dev/null +++ b/src/AxCopilot/Services/Agent/ChartSkill.Renderers.cs @@ -0,0 +1,373 @@ +using System.Text; +using System.Text.Json; + +namespace AxCopilot.Services.Agent; + +public partial class ChartSkill +{ + // ─── 차트 렌더러 + 헬퍼 ────────────────────────────────────────────────── + + // ─── Bar Chart ─────────────────────────────────────────────────────── + + private static string RenderBarChart(List labels, List datasets, string unit, bool horizontal) + { + var maxVal = datasets.SelectMany(d => d.Values).DefaultIfEmpty(1).Max(); + if (maxVal <= 0) maxVal = 1; + var sb = new StringBuilder(); + + if (horizontal) + { + sb.AppendLine("
"); + for (int i = 0; i < labels.Count; i++) + { + var val = datasets.Count > 0 && i < datasets[0].Values.Count ? datasets[0].Values[i] : 0; + var pct = (int)(val / maxVal * 100); + var color = datasets.Count > 0 ? datasets[0].Color : Palette[0]; + sb.AppendLine($"
{Escape(labels[i])}"); + sb.AppendLine($"
"); + sb.AppendLine($"{val:G}{unit}
"); + } + sb.AppendLine("
"); + } + else + { + sb.AppendLine("
"); + sb.AppendLine("
"); + for (int i = 0; i < labels.Count; i++) + { + sb.AppendLine("
"); + foreach (var ds in datasets) + { + var val = i < ds.Values.Count ? ds.Values[i] : 0; + var pct = (int)(val / maxVal * 100); + sb.AppendLine($"
"); + } + sb.AppendLine($"
{Escape(labels[i])}
"); + sb.AppendLine("
"); + } + sb.AppendLine("
"); + } + + return sb.ToString(); + } + + // ─── Stacked Bar ───────────────────────────────────────────────────── + + private static string RenderStackedBar(List labels, List datasets, string unit) + { + var sb = new StringBuilder(); + sb.AppendLine("
"); + for (int i = 0; i < labels.Count; i++) + { + var total = datasets.Sum(ds => i < ds.Values.Count ? ds.Values[i] : 0); + sb.AppendLine($"
{Escape(labels[i])}"); + sb.AppendLine("
"); + foreach (var ds in datasets) + { + var val = i < ds.Values.Count ? ds.Values[i] : 0; + var pct = total > 0 ? (int)(val / total * 100) : 0; + sb.AppendLine($"
"); + } + sb.AppendLine($"
{total:G}{unit}
"); + } + sb.AppendLine("
"); + return sb.ToString(); + } + + // ─── Line / Area Chart (SVG) ───────────────────────────────────────── + + private static string RenderLineChart(List labels, List datasets, string unit, bool isArea) + { + var allVals = datasets.SelectMany(d => d.Values).ToList(); + var maxVal = allVals.DefaultIfEmpty(1).Max(); + var minVal = allVals.DefaultIfEmpty(0).Min(); + if (maxVal <= minVal) maxVal = minVal + 1; + + int w = 600, h = 300, padL = 50, padR = 20, padT = 20, padB = 40; + var chartW = w - padL - padR; + var chartH = h - padT - padB; + var n = labels.Count; + + var sb = new StringBuilder(); + sb.AppendLine($""); + + // Y축 그리드 + for (int i = 0; i <= 4; i++) + { + var y = padT + chartH - (chartH * i / 4.0); + var val = minVal + (maxVal - minVal) * i / 4.0; + sb.AppendLine($""); + sb.AppendLine($"{val:G3}{unit}"); + } + + // X축 라벨 + for (int i = 0; i < n; i++) + { + var x = padL + (n > 1 ? chartW * i / (double)(n - 1) : chartW / 2.0); + sb.AppendLine($"{Escape(labels[i])}"); + } + + // 데이터셋 + foreach (var ds in datasets) + { + var points = new List<(double x, double y)>(); + for (int i = 0; i < Math.Min(n, ds.Values.Count); i++) + { + var x = padL + (n > 1 ? chartW * i / (double)(n - 1) : chartW / 2.0); + var y = padT + chartH - ((ds.Values[i] - minVal) / (maxVal - minVal) * chartH); + points.Add((x, y)); + } + + var pathData = string.Join(" ", points.Select((p, i) => $"{(i == 0 ? "M" : "L")}{p.x:F1},{p.y:F1}")); + + if (isArea && points.Count > 1) + { + var areaPath = pathData + $" L{points.Last().x:F1},{padT + chartH} L{points.First().x:F1},{padT + chartH} Z"; + sb.AppendLine($""); + } + + sb.AppendLine($""); + + // 데이터 포인트 + foreach (var (px, py) in points) + sb.AppendLine($""); + } + + sb.AppendLine(""); + return sb.ToString(); + } + + // ─── Pie / Donut Chart (SVG) ───────────────────────────────────────── + + private static string RenderPieChart(List labels, List datasets, bool isDonut) + { + var values = datasets.Count > 0 ? datasets[0].Values : new List(); + var total = values.Sum(); + if (total <= 0) total = 1; + + int cx = 150, cy = 150, r = 120; + var sb = new StringBuilder(); + sb.AppendLine($"
"); + sb.AppendLine($""); + + double startAngle = -90; + for (int i = 0; i < Math.Min(values.Count, labels.Count); i++) + { + var pct = values[i] / total; + var angle = pct * 360; + var endAngle = startAngle + angle; + + var x1 = cx + r * Math.Cos(startAngle * Math.PI / 180); + var y1 = cy + r * Math.Sin(startAngle * Math.PI / 180); + var x2 = cx + r * Math.Cos(endAngle * Math.PI / 180); + var y2 = cy + r * Math.Sin(endAngle * Math.PI / 180); + var largeArc = angle > 180 ? 1 : 0; + + var color = i < Palette.Length ? Palette[i] : Palette[i % Palette.Length]; + sb.AppendLine($""); + startAngle = endAngle; + } + + if (isDonut) + sb.AppendLine($""); + + sb.AppendLine(""); + + // 범례 + sb.AppendLine("
"); + for (int i = 0; i < Math.Min(values.Count, labels.Count); i++) + { + var color = i < Palette.Length ? Palette[i] : Palette[i % Palette.Length]; + var pct = values[i] / total * 100; + sb.AppendLine($"
{Escape(labels[i])} ({pct:F1}%)
"); + } + sb.AppendLine("
"); + + return sb.ToString(); + } + + // ─── Progress Chart ────────────────────────────────────────────────── + + private static string RenderProgressChart(List labels, List datasets, string unit) + { + var values = datasets.Count > 0 ? datasets[0].Values : new List(); + var sb = new StringBuilder(); + for (int i = 0; i < Math.Min(labels.Count, values.Count); i++) + { + var pct = Math.Clamp(values[i], 0, 100); + var color = i < Palette.Length ? Palette[i] : Palette[i % Palette.Length]; + if (datasets.Count > 0 && !string.IsNullOrEmpty(datasets[0].Color)) + color = datasets[0].Color; + sb.AppendLine($"
{Escape(labels[i])}{values[i]:G}{unit}
"); + sb.AppendLine($"
"); + } + return sb.ToString(); + } + + // ─── Comparison Chart ──────────────────────────────────────────────── + + private static string RenderComparisonChart(List labels, List datasets, string unit) + { + var sb = new StringBuilder(); + sb.AppendLine(""); + sb.AppendLine(""); + foreach (var ds in datasets) + sb.AppendLine($""); + sb.AppendLine(""); + for (int i = 0; i < labels.Count; i++) + { + sb.Append($""); + foreach (var ds in datasets) + { + var val = i < ds.Values.Count ? ds.Values[i] : 0; + sb.Append($""); + } + sb.AppendLine(""); + } + sb.AppendLine("
항목{Escape(ds.Name)}
{Escape(labels[i])}{val:G}{unit}
"); + return sb.ToString(); + } + + // ─── Radar Chart (SVG) ─────────────────────────────────────────────── + + private static string RenderRadarChart(List labels, List datasets) + { + int cx = 150, cy = 150, r = 110; + var n = labels.Count; + if (n < 3) return "

레이더 차트는 최소 3개 항목이 필요합니다.

"; + + var sb = new StringBuilder(); + sb.AppendLine($""); + + // 그리드 + for (int level = 1; level <= 4; level++) + { + var lr = r * level / 4.0; + var points = string.Join(" ", Enumerable.Range(0, n).Select(i => + { + var angle = (360.0 / n * i - 90) * Math.PI / 180; + return $"{cx + lr * Math.Cos(angle):F1},{cy + lr * Math.Sin(angle):F1}"; + })); + sb.AppendLine($""); + } + + // 축선 + 라벨 + for (int i = 0; i < n; i++) + { + var angle = (360.0 / n * i - 90) * Math.PI / 180; + var x = cx + r * Math.Cos(angle); + var y = cy + r * Math.Sin(angle); + sb.AppendLine($""); + var lx = cx + (r + 16) * Math.Cos(angle); + var ly = cy + (r + 16) * Math.Sin(angle); + sb.AppendLine($"{Escape(labels[i])}"); + } + + // 데이터 + var maxVal = datasets.SelectMany(d => d.Values).DefaultIfEmpty(1).Max(); + if (maxVal <= 0) maxVal = 1; + foreach (var ds in datasets) + { + var points = string.Join(" ", Enumerable.Range(0, n).Select(i => + { + var val = i < ds.Values.Count ? ds.Values[i] : 0; + var dr = r * val / maxVal; + var angle = (360.0 / n * i - 90) * Math.PI / 180; + return $"{cx + dr * Math.Cos(angle):F1},{cy + dr * Math.Sin(angle):F1}"; + })); + sb.AppendLine($""); + } + + sb.AppendLine(""); + return sb.ToString(); + } + + // ─── Helpers ───────────────────────────────────────────────────────── + + private static List ParseStringArray(JsonElement parent, string prop) + { + if (!parent.TryGetProperty(prop, out var arr) || arr.ValueKind != JsonValueKind.Array) + return new(); + return arr.EnumerateArray().Select(e => e.GetString() ?? "").ToList(); + } + + private List ParseDatasets(JsonElement chart) + { + if (!chart.TryGetProperty("datasets", out var dsArr) || dsArr.ValueKind != JsonValueKind.Array) + { + // datasets 없으면 values 배열에서 단일 데이터셋 생성 + if (chart.TryGetProperty("values", out var vals) && vals.ValueKind == JsonValueKind.Array) + { + return new() + { + new Dataset + { + Name = "Data", + Values = vals.EnumerateArray().Select(v => v.TryGetDouble(out var d) ? d : 0).ToList(), + Color = Palette[0], + } + }; + } + return new(); + } + + var list = new List(); + int colorIdx = 0; + foreach (var ds in dsArr.EnumerateArray()) + { + var name = ds.TryGetProperty("name", out var n) ? n.GetString() ?? $"Series{colorIdx + 1}" : $"Series{colorIdx + 1}"; + var color = ds.TryGetProperty("color", out var c) ? c.GetString() ?? Palette[colorIdx % Palette.Length] : Palette[colorIdx % Palette.Length]; + var values = new List(); + if (ds.TryGetProperty("values", out var v) && v.ValueKind == JsonValueKind.Array) + values = v.EnumerateArray().Select(e => e.TryGetDouble(out var d) ? d : 0).ToList(); + list.Add(new Dataset { Name = name, Values = values, Color = color }); + colorIdx++; + } + return list; + } + + private static string Escape(string s) => + s.Replace("&", "&").Replace("<", "<").Replace(">", ">").Replace("\"", """); + + private static string FormatSize(long bytes) => + bytes switch { < 1024 => $"{bytes}B", < 1048576 => $"{bytes / 1024.0:F1}KB", _ => $"{bytes / 1048576.0:F1}MB" }; + + private sealed class Dataset + { + public string Name { get; init; } = ""; + public List Values { get; init; } = new(); + public string Color { get; init; } = "#4B5EFC"; + } + + // ─── Chart CSS ─────────────────────────────────────────────────────── + + private const string ChartCss = @" +/* Vertical Bar Chart */ +.vbar-chart { margin: 16px 0; } +.vbar-bars { display: flex; align-items: flex-end; gap: 8px; height: 220px; padding: 0 8px; border-bottom: 2px solid #E5E7EB; } +.vbar-group { flex: 1; display: flex; gap: 3px; align-items: flex-end; position: relative; } +.vbar-bar { flex: 1; min-width: 18px; border-radius: 4px 4px 0 0; transition: opacity 0.2s; cursor: default; } +.vbar-bar:hover { opacity: 0.8; } +.vbar-label { text-align: center; font-size: 11px; color: #6B7280; margin-top: 6px; position: absolute; bottom: -24px; left: 0; right: 0; } + +/* Horizontal Bar Chart */ +.hbar-chart { margin: 12px 0; } +.hbar-row { display: flex; align-items: center; gap: 10px; margin-bottom: 8px; } +.hbar-label { min-width: 80px; text-align: right; font-size: 12px; color: #374151; font-weight: 500; } +.hbar-track { flex: 1; height: 22px; background: #F3F4F6; border-radius: 6px; overflow: hidden; } +.hbar-fill { height: 100%; border-radius: 6px; transition: width 0.6s ease; } +.hbar-value { min-width: 50px; font-size: 12px; color: #6B7280; font-weight: 600; } + +/* Line/Area Chart */ +.line-chart-svg { width: 100%; max-width: 600px; height: auto; } + +/* Legend */ +.chart-legend { display: flex; gap: 16px; flex-wrap: wrap; margin-top: 12px; padding-top: 8px; border-top: 1px solid #F3F4F6; } +.legend-item { display: flex; align-items: center; gap: 6px; font-size: 12px; color: #374151; } +.legend-dot { width: 10px; height: 10px; border-radius: 50%; display: inline-block; } + +/* Pie Legend */ +.pie-legend { display: flex; flex-direction: column; gap: 6px; } +.pie-legend-item { display: flex; align-items: center; gap: 8px; font-size: 13px; color: #374151; } +"; +} diff --git a/src/AxCopilot/Services/Agent/ChartSkill.cs b/src/AxCopilot/Services/Agent/ChartSkill.cs index 28bae7d..f449dba 100644 --- a/src/AxCopilot/Services/Agent/ChartSkill.cs +++ b/src/AxCopilot/Services/Agent/ChartSkill.cs @@ -8,7 +8,7 @@ namespace AxCopilot.Services.Agent; /// CSS/SVG 기반 차트를 HTML 파일로 생성하는 스킬. /// bar, line, pie(donut), radar, area 차트를 지원하며 TemplateService 무드 스타일을 적용합니다. /// -public class ChartSkill : IAgentTool +public partial class ChartSkill : IAgentTool { public string Name => "chart_create"; public string Description => @@ -171,367 +171,4 @@ public class ChartSkill : IAgentTool return sb.ToString(); } - // ─── Bar Chart ─────────────────────────────────────────────────────── - - private static string RenderBarChart(List labels, List datasets, string unit, bool horizontal) - { - var maxVal = datasets.SelectMany(d => d.Values).DefaultIfEmpty(1).Max(); - if (maxVal <= 0) maxVal = 1; - var sb = new StringBuilder(); - - if (horizontal) - { - sb.AppendLine("
"); - for (int i = 0; i < labels.Count; i++) - { - var val = datasets.Count > 0 && i < datasets[0].Values.Count ? datasets[0].Values[i] : 0; - var pct = (int)(val / maxVal * 100); - var color = datasets.Count > 0 ? datasets[0].Color : Palette[0]; - sb.AppendLine($"
{Escape(labels[i])}"); - sb.AppendLine($"
"); - sb.AppendLine($"{val:G}{unit}
"); - } - sb.AppendLine("
"); - } - else - { - sb.AppendLine("
"); - sb.AppendLine("
"); - for (int i = 0; i < labels.Count; i++) - { - sb.AppendLine("
"); - foreach (var ds in datasets) - { - var val = i < ds.Values.Count ? ds.Values[i] : 0; - var pct = (int)(val / maxVal * 100); - sb.AppendLine($"
"); - } - sb.AppendLine($"
{Escape(labels[i])}
"); - sb.AppendLine("
"); - } - sb.AppendLine("
"); - } - - return sb.ToString(); - } - - // ─── Stacked Bar ───────────────────────────────────────────────────── - - private static string RenderStackedBar(List labels, List datasets, string unit) - { - var sb = new StringBuilder(); - sb.AppendLine("
"); - for (int i = 0; i < labels.Count; i++) - { - var total = datasets.Sum(ds => i < ds.Values.Count ? ds.Values[i] : 0); - sb.AppendLine($"
{Escape(labels[i])}"); - sb.AppendLine("
"); - foreach (var ds in datasets) - { - var val = i < ds.Values.Count ? ds.Values[i] : 0; - var pct = total > 0 ? (int)(val / total * 100) : 0; - sb.AppendLine($"
"); - } - sb.AppendLine($"
{total:G}{unit}
"); - } - sb.AppendLine("
"); - return sb.ToString(); - } - - // ─── Line / Area Chart (SVG) ───────────────────────────────────────── - - private static string RenderLineChart(List labels, List datasets, string unit, bool isArea) - { - var allVals = datasets.SelectMany(d => d.Values).ToList(); - var maxVal = allVals.DefaultIfEmpty(1).Max(); - var minVal = allVals.DefaultIfEmpty(0).Min(); - if (maxVal <= minVal) maxVal = minVal + 1; - - int w = 600, h = 300, padL = 50, padR = 20, padT = 20, padB = 40; - var chartW = w - padL - padR; - var chartH = h - padT - padB; - var n = labels.Count; - - var sb = new StringBuilder(); - sb.AppendLine($""); - - // Y축 그리드 - for (int i = 0; i <= 4; i++) - { - var y = padT + chartH - (chartH * i / 4.0); - var val = minVal + (maxVal - minVal) * i / 4.0; - sb.AppendLine($""); - sb.AppendLine($"{val:G3}{unit}"); - } - - // X축 라벨 - for (int i = 0; i < n; i++) - { - var x = padL + (n > 1 ? chartW * i / (double)(n - 1) : chartW / 2.0); - sb.AppendLine($"{Escape(labels[i])}"); - } - - // 데이터셋 - foreach (var ds in datasets) - { - var points = new List<(double x, double y)>(); - for (int i = 0; i < Math.Min(n, ds.Values.Count); i++) - { - var x = padL + (n > 1 ? chartW * i / (double)(n - 1) : chartW / 2.0); - var y = padT + chartH - ((ds.Values[i] - minVal) / (maxVal - minVal) * chartH); - points.Add((x, y)); - } - - var pathData = string.Join(" ", points.Select((p, i) => $"{(i == 0 ? "M" : "L")}{p.x:F1},{p.y:F1}")); - - if (isArea && points.Count > 1) - { - var areaPath = pathData + $" L{points.Last().x:F1},{padT + chartH} L{points.First().x:F1},{padT + chartH} Z"; - sb.AppendLine($""); - } - - sb.AppendLine($""); - - // 데이터 포인트 - foreach (var (px, py) in points) - sb.AppendLine($""); - } - - sb.AppendLine(""); - return sb.ToString(); - } - - // ─── Pie / Donut Chart (SVG) ───────────────────────────────────────── - - private static string RenderPieChart(List labels, List datasets, bool isDonut) - { - var values = datasets.Count > 0 ? datasets[0].Values : new List(); - var total = values.Sum(); - if (total <= 0) total = 1; - - int cx = 150, cy = 150, r = 120; - var sb = new StringBuilder(); - sb.AppendLine($"
"); - sb.AppendLine($""); - - double startAngle = -90; - for (int i = 0; i < Math.Min(values.Count, labels.Count); i++) - { - var pct = values[i] / total; - var angle = pct * 360; - var endAngle = startAngle + angle; - - var x1 = cx + r * Math.Cos(startAngle * Math.PI / 180); - var y1 = cy + r * Math.Sin(startAngle * Math.PI / 180); - var x2 = cx + r * Math.Cos(endAngle * Math.PI / 180); - var y2 = cy + r * Math.Sin(endAngle * Math.PI / 180); - var largeArc = angle > 180 ? 1 : 0; - - var color = i < Palette.Length ? Palette[i] : Palette[i % Palette.Length]; - sb.AppendLine($""); - startAngle = endAngle; - } - - if (isDonut) - sb.AppendLine($""); - - sb.AppendLine(""); - - // 범례 - sb.AppendLine("
"); - for (int i = 0; i < Math.Min(values.Count, labels.Count); i++) - { - var color = i < Palette.Length ? Palette[i] : Palette[i % Palette.Length]; - var pct = values[i] / total * 100; - sb.AppendLine($"
{Escape(labels[i])} ({pct:F1}%)
"); - } - sb.AppendLine("
"); - - return sb.ToString(); - } - - // ─── Progress Chart ────────────────────────────────────────────────── - - private static string RenderProgressChart(List labels, List datasets, string unit) - { - var values = datasets.Count > 0 ? datasets[0].Values : new List(); - var sb = new StringBuilder(); - for (int i = 0; i < Math.Min(labels.Count, values.Count); i++) - { - var pct = Math.Clamp(values[i], 0, 100); - var color = i < Palette.Length ? Palette[i] : Palette[i % Palette.Length]; - if (datasets.Count > 0 && !string.IsNullOrEmpty(datasets[0].Color)) - color = datasets[0].Color; - sb.AppendLine($"
{Escape(labels[i])}{values[i]:G}{unit}
"); - sb.AppendLine($"
"); - } - return sb.ToString(); - } - - // ─── Comparison Chart ──────────────────────────────────────────────── - - private static string RenderComparisonChart(List labels, List datasets, string unit) - { - var sb = new StringBuilder(); - sb.AppendLine(""); - sb.AppendLine(""); - foreach (var ds in datasets) - sb.AppendLine($""); - sb.AppendLine(""); - for (int i = 0; i < labels.Count; i++) - { - sb.Append($""); - foreach (var ds in datasets) - { - var val = i < ds.Values.Count ? ds.Values[i] : 0; - sb.Append($""); - } - sb.AppendLine(""); - } - sb.AppendLine("
항목{Escape(ds.Name)}
{Escape(labels[i])}{val:G}{unit}
"); - return sb.ToString(); - } - - // ─── Radar Chart (SVG) ─────────────────────────────────────────────── - - private static string RenderRadarChart(List labels, List datasets) - { - int cx = 150, cy = 150, r = 110; - var n = labels.Count; - if (n < 3) return "

레이더 차트는 최소 3개 항목이 필요합니다.

"; - - var sb = new StringBuilder(); - sb.AppendLine($""); - - // 그리드 - for (int level = 1; level <= 4; level++) - { - var lr = r * level / 4.0; - var points = string.Join(" ", Enumerable.Range(0, n).Select(i => - { - var angle = (360.0 / n * i - 90) * Math.PI / 180; - return $"{cx + lr * Math.Cos(angle):F1},{cy + lr * Math.Sin(angle):F1}"; - })); - sb.AppendLine($""); - } - - // 축선 + 라벨 - for (int i = 0; i < n; i++) - { - var angle = (360.0 / n * i - 90) * Math.PI / 180; - var x = cx + r * Math.Cos(angle); - var y = cy + r * Math.Sin(angle); - sb.AppendLine($""); - var lx = cx + (r + 16) * Math.Cos(angle); - var ly = cy + (r + 16) * Math.Sin(angle); - sb.AppendLine($"{Escape(labels[i])}"); - } - - // 데이터 - var maxVal = datasets.SelectMany(d => d.Values).DefaultIfEmpty(1).Max(); - if (maxVal <= 0) maxVal = 1; - foreach (var ds in datasets) - { - var points = string.Join(" ", Enumerable.Range(0, n).Select(i => - { - var val = i < ds.Values.Count ? ds.Values[i] : 0; - var dr = r * val / maxVal; - var angle = (360.0 / n * i - 90) * Math.PI / 180; - return $"{cx + dr * Math.Cos(angle):F1},{cy + dr * Math.Sin(angle):F1}"; - })); - sb.AppendLine($""); - } - - sb.AppendLine(""); - return sb.ToString(); - } - - // ─── Helpers ───────────────────────────────────────────────────────── - - private static List ParseStringArray(JsonElement parent, string prop) - { - if (!parent.TryGetProperty(prop, out var arr) || arr.ValueKind != JsonValueKind.Array) - return new(); - return arr.EnumerateArray().Select(e => e.GetString() ?? "").ToList(); - } - - private List ParseDatasets(JsonElement chart) - { - if (!chart.TryGetProperty("datasets", out var dsArr) || dsArr.ValueKind != JsonValueKind.Array) - { - // datasets 없으면 values 배열에서 단일 데이터셋 생성 - if (chart.TryGetProperty("values", out var vals) && vals.ValueKind == JsonValueKind.Array) - { - return new() - { - new Dataset - { - Name = "Data", - Values = vals.EnumerateArray().Select(v => v.TryGetDouble(out var d) ? d : 0).ToList(), - Color = Palette[0], - } - }; - } - return new(); - } - - var list = new List(); - int colorIdx = 0; - foreach (var ds in dsArr.EnumerateArray()) - { - var name = ds.TryGetProperty("name", out var n) ? n.GetString() ?? $"Series{colorIdx + 1}" : $"Series{colorIdx + 1}"; - var color = ds.TryGetProperty("color", out var c) ? c.GetString() ?? Palette[colorIdx % Palette.Length] : Palette[colorIdx % Palette.Length]; - var values = new List(); - if (ds.TryGetProperty("values", out var v) && v.ValueKind == JsonValueKind.Array) - values = v.EnumerateArray().Select(e => e.TryGetDouble(out var d) ? d : 0).ToList(); - list.Add(new Dataset { Name = name, Values = values, Color = color }); - colorIdx++; - } - return list; - } - - private static string Escape(string s) => - s.Replace("&", "&").Replace("<", "<").Replace(">", ">").Replace("\"", """); - - private static string FormatSize(long bytes) => - bytes switch { < 1024 => $"{bytes}B", < 1048576 => $"{bytes / 1024.0:F1}KB", _ => $"{bytes / 1048576.0:F1}MB" }; - - private sealed class Dataset - { - public string Name { get; init; } = ""; - public List Values { get; init; } = new(); - public string Color { get; init; } = "#4B5EFC"; - } - - // ─── Chart CSS ─────────────────────────────────────────────────────── - - private const string ChartCss = @" -/* Vertical Bar Chart */ -.vbar-chart { margin: 16px 0; } -.vbar-bars { display: flex; align-items: flex-end; gap: 8px; height: 220px; padding: 0 8px; border-bottom: 2px solid #E5E7EB; } -.vbar-group { flex: 1; display: flex; gap: 3px; align-items: flex-end; position: relative; } -.vbar-bar { flex: 1; min-width: 18px; border-radius: 4px 4px 0 0; transition: opacity 0.2s; cursor: default; } -.vbar-bar:hover { opacity: 0.8; } -.vbar-label { text-align: center; font-size: 11px; color: #6B7280; margin-top: 6px; position: absolute; bottom: -24px; left: 0; right: 0; } - -/* Horizontal Bar Chart */ -.hbar-chart { margin: 12px 0; } -.hbar-row { display: flex; align-items: center; gap: 10px; margin-bottom: 8px; } -.hbar-label { min-width: 80px; text-align: right; font-size: 12px; color: #374151; font-weight: 500; } -.hbar-track { flex: 1; height: 22px; background: #F3F4F6; border-radius: 6px; overflow: hidden; } -.hbar-fill { height: 100%; border-radius: 6px; transition: width 0.6s ease; } -.hbar-value { min-width: 50px; font-size: 12px; color: #6B7280; font-weight: 600; } - -/* Line/Area Chart */ -.line-chart-svg { width: 100%; max-width: 600px; height: auto; } - -/* Legend */ -.chart-legend { display: flex; gap: 16px; flex-wrap: wrap; margin-top: 12px; padding-top: 8px; border-top: 1px solid #F3F4F6; } -.legend-item { display: flex; align-items: center; gap: 6px; font-size: 12px; color: #374151; } -.legend-dot { width: 10px; height: 10px; border-radius: 50%; display: inline-block; } - -/* Pie Legend */ -.pie-legend { display: flex; flex-direction: column; gap: 6px; } -.pie-legend-item { display: flex; align-items: center; gap: 8px; font-size: 13px; color: #374151; } -"; } diff --git a/src/AxCopilot/Services/Agent/DocxSkill.Builders.cs b/src/AxCopilot/Services/Agent/DocxSkill.Builders.cs new file mode 100644 index 0000000..ef7c273 --- /dev/null +++ b/src/AxCopilot/Services/Agent/DocxSkill.Builders.cs @@ -0,0 +1,397 @@ +using System.Text.Json; +using DocumentFormat.OpenXml; +using DocumentFormat.OpenXml.Packaging; +using DocumentFormat.OpenXml.Wordprocessing; + +namespace AxCopilot.Services.Agent; + +public partial class DocxSkill +{ + // ─── 문서 빌더 헬퍼 ────────────────────────────────────────────────────── + + // ═══════════════════════════════════════════════════ + // 제목/소제목/본문 단락 생성 + // ═══════════════════════════════════════════════════ + + private static Paragraph CreateTitleParagraph(string text) + { + var para = new Paragraph(); + para.ParagraphProperties = new ParagraphProperties + { + Justification = new Justification { Val = JustificationValues.Center }, + SpacingBetweenLines = new SpacingBetweenLines { After = "100" }, + }; + var run = new Run(new Text(text)); + run.RunProperties = new RunProperties + { + Bold = new Bold(), + FontSize = new FontSize { Val = "44" }, // 22pt + Color = new Color { Val = "1F3864" }, + }; + para.Append(run); + return para; + } + + private static Paragraph CreateHeadingParagraph(string text, int level) + { + var para = new Paragraph(); + var fontSize = level <= 1 ? "32" : "26"; // 16pt / 13pt + var color = level <= 1 ? "2E74B5" : "404040"; + + para.ParagraphProperties = new ParagraphProperties + { + SpacingBetweenLines = new SpacingBetweenLines { Before = level <= 1 ? "360" : "240", After = "120" }, + }; + + // 레벨1 소제목에 하단 테두리 추가 + if (level <= 1) + { + para.ParagraphProperties.ParagraphBorders = new ParagraphBorders( + new BottomBorder { Val = BorderValues.Single, Size = 4, Color = "B4C6E7", Space = 1 }); + } + + var run = new Run(new Text(text)); + run.RunProperties = new RunProperties + { + Bold = new Bold(), + FontSize = new FontSize { Val = fontSize }, + Color = new Color { Val = color }, + }; + para.Append(run); + return para; + } + + private static Paragraph CreateBodyParagraph(string text) + { + var para = new Paragraph(); + para.ParagraphProperties = new ParagraphProperties + { + SpacingBetweenLines = new SpacingBetweenLines { Line = "360" }, // 1.5배 줄간격 + }; + + // 인라인 서식 파싱: **bold**, *italic*, `code` + AppendFormattedRuns(para, text); + return para; + } + + /// **bold**, *italic*, `code` 인라인 서식을 Run으로 변환 + private static void AppendFormattedRuns(Paragraph para, string text) + { + // 패턴: **bold** | *italic* | `code` | 일반텍스트 + var regex = new System.Text.RegularExpressions.Regex( + @"\*\*(.+?)\*\*|\*(.+?)\*|`(.+?)`"); + int lastIndex = 0; + + foreach (System.Text.RegularExpressions.Match match in regex.Matches(text)) + { + // 매치 전 일반 텍스트 + if (match.Index > lastIndex) + para.Append(CreateRun(text[lastIndex..match.Index])); + + if (match.Groups[1].Success) // **bold** + { + var run = CreateRun(match.Groups[1].Value); + run.RunProperties ??= new RunProperties(); + run.RunProperties.Bold = new Bold(); + para.Append(run); + } + else if (match.Groups[2].Success) // *italic* + { + var run = CreateRun(match.Groups[2].Value); + run.RunProperties ??= new RunProperties(); + run.RunProperties.Italic = new Italic(); + para.Append(run); + } + else if (match.Groups[3].Success) // `code` + { + var run = CreateRun(match.Groups[3].Value); + run.RunProperties ??= new RunProperties(); + run.RunProperties.RunFonts = new RunFonts { Ascii = "Consolas", HighAnsi = "Consolas" }; + run.RunProperties.FontSize = new FontSize { Val = "20" }; + run.RunProperties.Shading = new Shading + { + Val = ShadingPatternValues.Clear, + Fill = "F2F2F2", + Color = "auto" + }; + para.Append(run); + } + + lastIndex = match.Index + match.Length; + } + + // 나머지 텍스트 + if (lastIndex < text.Length) + para.Append(CreateRun(text[lastIndex..])); + + // 빈 텍스트인 경우 빈 Run 추가 + if (lastIndex == 0 && text.Length == 0) + para.Append(CreateRun("")); + } + + private static Run CreateRun(string text) + { + var run = new Run(new Text(text) { Space = SpaceProcessingModeValues.Preserve }); + run.RunProperties = new RunProperties + { + FontSize = new FontSize { Val = "22" }, // 11pt + }; + return run; + } + + // ═══════════════════════════════════════════════════ + // 테이블 생성 + // ═══════════════════════════════════════════════════ + + private static Table CreateTable(JsonElement section) + { + var headers = section.TryGetProperty("headers", out var hArr) ? hArr : default; + var rows = section.TryGetProperty("rows", out var rArr) ? rArr : default; + var tableStyle = section.TryGetProperty("style", out var ts) ? ts.GetString() ?? "striped" : "striped"; + + var table = new Table(); + + // 테이블 속성 — 테두리 + 전체 너비 + var tblProps = new TableProperties( + new TableBorders( + new TopBorder { Val = BorderValues.Single, Size = 4, Color = "D9D9D9" }, + new BottomBorder { Val = BorderValues.Single, Size = 4, Color = "D9D9D9" }, + new LeftBorder { Val = BorderValues.Single, Size = 4, Color = "D9D9D9" }, + new RightBorder { Val = BorderValues.Single, Size = 4, Color = "D9D9D9" }, + new InsideHorizontalBorder { Val = BorderValues.Single, Size = 4, Color = "D9D9D9" }, + new InsideVerticalBorder { Val = BorderValues.Single, Size = 4, Color = "D9D9D9" } + ), + new TableWidth { Width = "5000", Type = TableWidthUnitValues.Pct } + ); + table.AppendChild(tblProps); + + // 헤더 행 + if (headers.ValueKind == JsonValueKind.Array) + { + var headerRow = new TableRow(); + foreach (var h in headers.EnumerateArray()) + { + var cell = new TableCell(); + cell.TableCellProperties = new TableCellProperties + { + Shading = new Shading { Val = ShadingPatternValues.Clear, Fill = "2E74B5", Color = "auto" }, + TableCellVerticalAlignment = new TableCellVerticalAlignment { Val = TableVerticalAlignmentValues.Center }, + }; + var para = new Paragraph(new Run(new Text(h.GetString() ?? "")) + { + RunProperties = new RunProperties + { + Bold = new Bold(), + FontSize = new FontSize { Val = "20" }, + Color = new Color { Val = "FFFFFF" }, + } + }); + para.ParagraphProperties = new ParagraphProperties + { + SpacingBetweenLines = new SpacingBetweenLines { Before = "40", After = "40" }, + }; + cell.Append(para); + headerRow.Append(cell); + } + table.Append(headerRow); + } + + // 데이터 행 + if (rows.ValueKind == JsonValueKind.Array) + { + int rowIdx = 0; + foreach (var row in rows.EnumerateArray()) + { + var dataRow = new TableRow(); + foreach (var cellVal in row.EnumerateArray()) + { + var cell = new TableCell(); + + // striped 스타일: 짝수행에 배경색 + if (tableStyle == "striped" && rowIdx % 2 == 0) + { + cell.TableCellProperties = new TableCellProperties + { + Shading = new Shading { Val = ShadingPatternValues.Clear, Fill = "F2F7FB", Color = "auto" }, + }; + } + + var para = new Paragraph(new Run(new Text(cellVal.ToString()) { Space = SpaceProcessingModeValues.Preserve }) + { + RunProperties = new RunProperties { FontSize = new FontSize { Val = "20" } } + }); + para.ParagraphProperties = new ParagraphProperties + { + SpacingBetweenLines = new SpacingBetweenLines { Before = "20", After = "20" }, + }; + cell.Append(para); + dataRow.Append(cell); + } + table.Append(dataRow); + rowIdx++; + } + } + + return table; + } + + // ═══════════════════════════════════════════════════ + // 리스트 (번호/불릿) + // ═══════════════════════════════════════════════════ + + private static void AppendList(Body body, JsonElement section) + { + var items = section.TryGetProperty("items", out var arr) ? arr : default; + var listStyle = section.TryGetProperty("style", out var ls) ? ls.GetString() ?? "bullet" : "bullet"; + + if (items.ValueKind != JsonValueKind.Array) return; + + int idx = 1; + foreach (var item in items.EnumerateArray()) + { + var text = item.GetString() ?? item.ToString(); + var prefix = listStyle == "number" ? $"{idx}. " : "• "; + + var para = new Paragraph(); + para.ParagraphProperties = new ParagraphProperties + { + Indentation = new Indentation { Left = "720" }, // 0.5 inch + SpacingBetweenLines = new SpacingBetweenLines { Line = "320" }, + }; + + var prefixRun = new Run(new Text(prefix) { Space = SpaceProcessingModeValues.Preserve }); + prefixRun.RunProperties = new RunProperties + { + FontSize = new FontSize { Val = "22" }, + Bold = listStyle == "number" ? new Bold() : null, + }; + para.Append(prefixRun); + + var textRun = new Run(new Text(text) { Space = SpaceProcessingModeValues.Preserve }); + textRun.RunProperties = new RunProperties { FontSize = new FontSize { Val = "22" } }; + para.Append(textRun); + + body.Append(para); + idx++; + } + } + + // ═══════════════════════════════════════════════════ + // 페이지 나누기 + // ═══════════════════════════════════════════════════ + + private static Paragraph CreatePageBreak() + { + var para = new Paragraph(); + var run = new Run(new Break { Type = BreakValues.Page }); + para.Append(run); + return para; + } + + // ═══════════════════════════════════════════════════ + // 머리글/바닥글 + // ═══════════════════════════════════════════════════ + + private static void AddHeaderFooter(MainDocumentPart mainPart, Body body, + string? headerText, string? footerText, bool showPageNumbers) + { + // 머리글 + if (!string.IsNullOrEmpty(headerText)) + { + var headerPart = mainPart.AddNewPart(); + var header = new Header(); + var para = new Paragraph(new Run(new Text(headerText)) + { + RunProperties = new RunProperties + { + FontSize = new FontSize { Val = "18" }, // 9pt + Color = new Color { Val = "808080" }, + } + }); + para.ParagraphProperties = new ParagraphProperties + { + Justification = new Justification { Val = JustificationValues.Right }, + }; + header.Append(para); + headerPart.Header = header; + + // SectionProperties에 머리글 연결 + var secProps = body.GetFirstChild() ?? body.AppendChild(new SectionProperties()); + secProps.Append(new HeaderReference + { + Type = HeaderFooterValues.Default, + Id = mainPart.GetIdOfPart(headerPart) + }); + } + + // 바닥글 + if (!string.IsNullOrEmpty(footerText) || showPageNumbers) + { + var footerPart = mainPart.AddNewPart(); + var footer = new Footer(); + var para = new Paragraph(); + para.ParagraphProperties = new ParagraphProperties + { + Justification = new Justification { Val = JustificationValues.Center }, + }; + + var displayText = footerText ?? "AX Copilot"; + + if (showPageNumbers) + { + // 바닥글 텍스트 + 페이지 번호 + if (displayText.Contains("{page}")) + { + var parts = displayText.Split("{page}"); + para.Append(CreateFooterRun(parts[0])); + para.Append(CreatePageNumberRun()); + if (parts.Length > 1) + para.Append(CreateFooterRun(parts[1])); + } + else + { + para.Append(CreateFooterRun(displayText + " · ")); + para.Append(CreatePageNumberRun()); + } + } + else + { + para.Append(CreateFooterRun(displayText)); + } + + footer.Append(para); + footerPart.Footer = footer; + + var secProps = body.GetFirstChild() ?? body.AppendChild(new SectionProperties()); + secProps.Append(new FooterReference + { + Type = HeaderFooterValues.Default, + Id = mainPart.GetIdOfPart(footerPart) + }); + } + } + + private static Run CreateFooterRun(string text) => + new(new Text(text) { Space = SpaceProcessingModeValues.Preserve }) + { + RunProperties = new RunProperties + { + FontSize = new FontSize { Val = "16" }, + Color = new Color { Val = "999999" }, + } + }; + + private static Run CreatePageNumberRun() + { + var run = new Run(); + run.RunProperties = new RunProperties + { + FontSize = new FontSize { Val = "16" }, + Color = new Color { Val = "999999" }, + }; + run.Append(new FieldChar { FieldCharType = FieldCharValues.Begin }); + run.Append(new FieldCode(" PAGE ") { Space = SpaceProcessingModeValues.Preserve }); + run.Append(new FieldChar { FieldCharType = FieldCharValues.End }); + return run; + } +} diff --git a/src/AxCopilot/Services/Agent/DocxSkill.cs b/src/AxCopilot/Services/Agent/DocxSkill.cs index 20482a5..dba71bb 100644 --- a/src/AxCopilot/Services/Agent/DocxSkill.cs +++ b/src/AxCopilot/Services/Agent/DocxSkill.cs @@ -10,7 +10,7 @@ namespace AxCopilot.Services.Agent; /// Word (.docx) 문서를 생성하는 내장 스킬. /// 테이블, 텍스트 스타일링, 머리글/바닥글, 페이지 나누기 등 고급 기능을 지원합니다. /// -public class DocxSkill : IAgentTool +public partial class DocxSkill : IAgentTool { public string Name => "docx_create"; public string Description => "Create a rich Word (.docx) document. " + @@ -155,389 +155,4 @@ public class DocxSkill : IAgentTool } } - // ═══════════════════════════════════════════════════ - // 제목/소제목/본문 단락 생성 - // ═══════════════════════════════════════════════════ - - private static Paragraph CreateTitleParagraph(string text) - { - var para = new Paragraph(); - para.ParagraphProperties = new ParagraphProperties - { - Justification = new Justification { Val = JustificationValues.Center }, - SpacingBetweenLines = new SpacingBetweenLines { After = "100" }, - }; - var run = new Run(new Text(text)); - run.RunProperties = new RunProperties - { - Bold = new Bold(), - FontSize = new FontSize { Val = "44" }, // 22pt - Color = new Color { Val = "1F3864" }, - }; - para.Append(run); - return para; - } - - private static Paragraph CreateHeadingParagraph(string text, int level) - { - var para = new Paragraph(); - var fontSize = level <= 1 ? "32" : "26"; // 16pt / 13pt - var color = level <= 1 ? "2E74B5" : "404040"; - - para.ParagraphProperties = new ParagraphProperties - { - SpacingBetweenLines = new SpacingBetweenLines { Before = level <= 1 ? "360" : "240", After = "120" }, - }; - - // 레벨1 소제목에 하단 테두리 추가 - if (level <= 1) - { - para.ParagraphProperties.ParagraphBorders = new ParagraphBorders( - new BottomBorder { Val = BorderValues.Single, Size = 4, Color = "B4C6E7", Space = 1 }); - } - - var run = new Run(new Text(text)); - run.RunProperties = new RunProperties - { - Bold = new Bold(), - FontSize = new FontSize { Val = fontSize }, - Color = new Color { Val = color }, - }; - para.Append(run); - return para; - } - - private static Paragraph CreateBodyParagraph(string text) - { - var para = new Paragraph(); - para.ParagraphProperties = new ParagraphProperties - { - SpacingBetweenLines = new SpacingBetweenLines { Line = "360" }, // 1.5배 줄간격 - }; - - // 인라인 서식 파싱: **bold**, *italic*, `code` - AppendFormattedRuns(para, text); - return para; - } - - /// **bold**, *italic*, `code` 인라인 서식을 Run으로 변환 - private static void AppendFormattedRuns(Paragraph para, string text) - { - // 패턴: **bold** | *italic* | `code` | 일반텍스트 - var regex = new System.Text.RegularExpressions.Regex( - @"\*\*(.+?)\*\*|\*(.+?)\*|`(.+?)`"); - int lastIndex = 0; - - foreach (System.Text.RegularExpressions.Match match in regex.Matches(text)) - { - // 매치 전 일반 텍스트 - if (match.Index > lastIndex) - para.Append(CreateRun(text[lastIndex..match.Index])); - - if (match.Groups[1].Success) // **bold** - { - var run = CreateRun(match.Groups[1].Value); - run.RunProperties ??= new RunProperties(); - run.RunProperties.Bold = new Bold(); - para.Append(run); - } - else if (match.Groups[2].Success) // *italic* - { - var run = CreateRun(match.Groups[2].Value); - run.RunProperties ??= new RunProperties(); - run.RunProperties.Italic = new Italic(); - para.Append(run); - } - else if (match.Groups[3].Success) // `code` - { - var run = CreateRun(match.Groups[3].Value); - run.RunProperties ??= new RunProperties(); - run.RunProperties.RunFonts = new RunFonts { Ascii = "Consolas", HighAnsi = "Consolas" }; - run.RunProperties.FontSize = new FontSize { Val = "20" }; - run.RunProperties.Shading = new Shading - { - Val = ShadingPatternValues.Clear, - Fill = "F2F2F2", - Color = "auto" - }; - para.Append(run); - } - - lastIndex = match.Index + match.Length; - } - - // 나머지 텍스트 - if (lastIndex < text.Length) - para.Append(CreateRun(text[lastIndex..])); - - // 빈 텍스트인 경우 빈 Run 추가 - if (lastIndex == 0 && text.Length == 0) - para.Append(CreateRun("")); - } - - private static Run CreateRun(string text) - { - var run = new Run(new Text(text) { Space = SpaceProcessingModeValues.Preserve }); - run.RunProperties = new RunProperties - { - FontSize = new FontSize { Val = "22" }, // 11pt - }; - return run; - } - - // ═══════════════════════════════════════════════════ - // 테이블 생성 - // ═══════════════════════════════════════════════════ - - private static Table CreateTable(JsonElement section) - { - var headers = section.TryGetProperty("headers", out var hArr) ? hArr : default; - var rows = section.TryGetProperty("rows", out var rArr) ? rArr : default; - var tableStyle = section.TryGetProperty("style", out var ts) ? ts.GetString() ?? "striped" : "striped"; - - var table = new Table(); - - // 테이블 속성 — 테두리 + 전체 너비 - var tblProps = new TableProperties( - new TableBorders( - new TopBorder { Val = BorderValues.Single, Size = 4, Color = "D9D9D9" }, - new BottomBorder { Val = BorderValues.Single, Size = 4, Color = "D9D9D9" }, - new LeftBorder { Val = BorderValues.Single, Size = 4, Color = "D9D9D9" }, - new RightBorder { Val = BorderValues.Single, Size = 4, Color = "D9D9D9" }, - new InsideHorizontalBorder { Val = BorderValues.Single, Size = 4, Color = "D9D9D9" }, - new InsideVerticalBorder { Val = BorderValues.Single, Size = 4, Color = "D9D9D9" } - ), - new TableWidth { Width = "5000", Type = TableWidthUnitValues.Pct } - ); - table.AppendChild(tblProps); - - // 헤더 행 - if (headers.ValueKind == JsonValueKind.Array) - { - var headerRow = new TableRow(); - foreach (var h in headers.EnumerateArray()) - { - var cell = new TableCell(); - cell.TableCellProperties = new TableCellProperties - { - Shading = new Shading { Val = ShadingPatternValues.Clear, Fill = "2E74B5", Color = "auto" }, - TableCellVerticalAlignment = new TableCellVerticalAlignment { Val = TableVerticalAlignmentValues.Center }, - }; - var para = new Paragraph(new Run(new Text(h.GetString() ?? "")) - { - RunProperties = new RunProperties - { - Bold = new Bold(), - FontSize = new FontSize { Val = "20" }, - Color = new Color { Val = "FFFFFF" }, - } - }); - para.ParagraphProperties = new ParagraphProperties - { - SpacingBetweenLines = new SpacingBetweenLines { Before = "40", After = "40" }, - }; - cell.Append(para); - headerRow.Append(cell); - } - table.Append(headerRow); - } - - // 데이터 행 - if (rows.ValueKind == JsonValueKind.Array) - { - int rowIdx = 0; - foreach (var row in rows.EnumerateArray()) - { - var dataRow = new TableRow(); - foreach (var cellVal in row.EnumerateArray()) - { - var cell = new TableCell(); - - // striped 스타일: 짝수행에 배경색 - if (tableStyle == "striped" && rowIdx % 2 == 0) - { - cell.TableCellProperties = new TableCellProperties - { - Shading = new Shading { Val = ShadingPatternValues.Clear, Fill = "F2F7FB", Color = "auto" }, - }; - } - - var para = new Paragraph(new Run(new Text(cellVal.ToString()) { Space = SpaceProcessingModeValues.Preserve }) - { - RunProperties = new RunProperties { FontSize = new FontSize { Val = "20" } } - }); - para.ParagraphProperties = new ParagraphProperties - { - SpacingBetweenLines = new SpacingBetweenLines { Before = "20", After = "20" }, - }; - cell.Append(para); - dataRow.Append(cell); - } - table.Append(dataRow); - rowIdx++; - } - } - - return table; - } - - // ═══════════════════════════════════════════════════ - // 리스트 (번호/불릿) - // ═══════════════════════════════════════════════════ - - private static void AppendList(Body body, JsonElement section) - { - var items = section.TryGetProperty("items", out var arr) ? arr : default; - var listStyle = section.TryGetProperty("style", out var ls) ? ls.GetString() ?? "bullet" : "bullet"; - - if (items.ValueKind != JsonValueKind.Array) return; - - int idx = 1; - foreach (var item in items.EnumerateArray()) - { - var text = item.GetString() ?? item.ToString(); - var prefix = listStyle == "number" ? $"{idx}. " : "• "; - - var para = new Paragraph(); - para.ParagraphProperties = new ParagraphProperties - { - Indentation = new Indentation { Left = "720" }, // 0.5 inch - SpacingBetweenLines = new SpacingBetweenLines { Line = "320" }, - }; - - var prefixRun = new Run(new Text(prefix) { Space = SpaceProcessingModeValues.Preserve }); - prefixRun.RunProperties = new RunProperties - { - FontSize = new FontSize { Val = "22" }, - Bold = listStyle == "number" ? new Bold() : null, - }; - para.Append(prefixRun); - - var textRun = new Run(new Text(text) { Space = SpaceProcessingModeValues.Preserve }); - textRun.RunProperties = new RunProperties { FontSize = new FontSize { Val = "22" } }; - para.Append(textRun); - - body.Append(para); - idx++; - } - } - - // ═══════════════════════════════════════════════════ - // 페이지 나누기 - // ═══════════════════════════════════════════════════ - - private static Paragraph CreatePageBreak() - { - var para = new Paragraph(); - var run = new Run(new Break { Type = BreakValues.Page }); - para.Append(run); - return para; - } - - // ═══════════════════════════════════════════════════ - // 머리글/바닥글 - // ═══════════════════════════════════════════════════ - - private static void AddHeaderFooter(MainDocumentPart mainPart, Body body, - string? headerText, string? footerText, bool showPageNumbers) - { - // 머리글 - if (!string.IsNullOrEmpty(headerText)) - { - var headerPart = mainPart.AddNewPart(); - var header = new Header(); - var para = new Paragraph(new Run(new Text(headerText)) - { - RunProperties = new RunProperties - { - FontSize = new FontSize { Val = "18" }, // 9pt - Color = new Color { Val = "808080" }, - } - }); - para.ParagraphProperties = new ParagraphProperties - { - Justification = new Justification { Val = JustificationValues.Right }, - }; - header.Append(para); - headerPart.Header = header; - - // SectionProperties에 머리글 연결 - var secProps = body.GetFirstChild() ?? body.AppendChild(new SectionProperties()); - secProps.Append(new HeaderReference - { - Type = HeaderFooterValues.Default, - Id = mainPart.GetIdOfPart(headerPart) - }); - } - - // 바닥글 - if (!string.IsNullOrEmpty(footerText) || showPageNumbers) - { - var footerPart = mainPart.AddNewPart(); - var footer = new Footer(); - var para = new Paragraph(); - para.ParagraphProperties = new ParagraphProperties - { - Justification = new Justification { Val = JustificationValues.Center }, - }; - - var displayText = footerText ?? "AX Copilot"; - - if (showPageNumbers) - { - // 바닥글 텍스트 + 페이지 번호 - if (displayText.Contains("{page}")) - { - var parts = displayText.Split("{page}"); - para.Append(CreateFooterRun(parts[0])); - para.Append(CreatePageNumberRun()); - if (parts.Length > 1) - para.Append(CreateFooterRun(parts[1])); - } - else - { - para.Append(CreateFooterRun(displayText + " · ")); - para.Append(CreatePageNumberRun()); - } - } - else - { - para.Append(CreateFooterRun(displayText)); - } - - footer.Append(para); - footerPart.Footer = footer; - - var secProps = body.GetFirstChild() ?? body.AppendChild(new SectionProperties()); - secProps.Append(new FooterReference - { - Type = HeaderFooterValues.Default, - Id = mainPart.GetIdOfPart(footerPart) - }); - } - } - - private static Run CreateFooterRun(string text) => - new(new Text(text) { Space = SpaceProcessingModeValues.Preserve }) - { - RunProperties = new RunProperties - { - FontSize = new FontSize { Val = "16" }, - Color = new Color { Val = "999999" }, - } - }; - - private static Run CreatePageNumberRun() - { - var run = new Run(); - run.RunProperties = new RunProperties - { - FontSize = new FontSize { Val = "16" }, - Color = new Color { Val = "999999" }, - }; - run.Append(new FieldChar { FieldCharType = FieldCharValues.Begin }); - run.Append(new FieldCode(" PAGE ") { Space = SpaceProcessingModeValues.Preserve }); - run.Append(new FieldChar { FieldCharType = FieldCharValues.End }); - return run; - } } diff --git a/src/AxCopilot/Services/LlmService.GeminiClaude.cs b/src/AxCopilot/Services/LlmService.GeminiClaude.cs new file mode 100644 index 0000000..a997c17 --- /dev/null +++ b/src/AxCopilot/Services/LlmService.GeminiClaude.cs @@ -0,0 +1,277 @@ +using System.IO; +using System.Net.Http; +using System.Runtime.CompilerServices; +using System.Text; +using System.Text.Json; +using AxCopilot.Models; + +namespace AxCopilot.Services; + +public partial class LlmService +{ + // ─── Gemini + Claude 스트리밍 ────────────────────────────────────────── + + // ═══════════════════════════════════════════════════════════════════════ + // Gemini + // ═══════════════════════════════════════════════════════════════════════ + + private async Task SendGeminiAsync(List messages, CancellationToken ct) + { + var llm = _settings.Settings.Llm; + var apiKey = ResolveApiKeyForService("gemini"); + if (string.IsNullOrEmpty(apiKey)) + throw new InvalidOperationException("Gemini API 키가 설정되지 않았습니다. 설정 > AX Agent에서 API 키를 입력하세요."); + + var model = ResolveModel(); + var body = BuildGeminiBody(messages); + var url = $"https://generativelanguage.googleapis.com/v1beta/models/{model}:generateContent?key={apiKey}"; + var resp = await PostJsonWithRetryAsync(url, body, ct); + return SafeParseJson(resp, root => + { + TryParseGeminiUsage(root); + var candidates = root.GetProperty("candidates"); + if (candidates.GetArrayLength() == 0) return "(빈 응답)"; + var parts = candidates[0].GetProperty("content").GetProperty("parts"); + if (parts.GetArrayLength() == 0) return "(빈 응답)"; + return parts[0].GetProperty("text").GetString() ?? ""; + }, "Gemini 응답"); + } + + private async IAsyncEnumerable StreamGeminiAsync( + List messages, + [EnumeratorCancellation] CancellationToken ct) + { + var llm = _settings.Settings.Llm; + var apiKey = ResolveApiKeyForService("gemini"); + if (string.IsNullOrEmpty(apiKey)) + throw new InvalidOperationException("Gemini API 키가 설정되지 않았습니다."); + + var model = ResolveModel(); + var body = BuildGeminiBody(messages); + var url = $"https://generativelanguage.googleapis.com/v1beta/models/{model}:streamGenerateContent?alt=sse&key={apiKey}"; + + using var req = new HttpRequestMessage(HttpMethod.Post, url) { Content = JsonContent(body) }; + using var resp = await SendWithErrorClassificationAsync(req, ct); + + using var stream = await resp.Content.ReadAsStreamAsync(ct); + using var reader = new StreamReader(stream); + + while (!reader.EndOfStream && !ct.IsCancellationRequested) + { + var line = await ReadLineWithTimeoutAsync(reader, ct); + if (line == null) break; + if (string.IsNullOrEmpty(line) || !line.StartsWith("data: ")) continue; + var data = line["data: ".Length..]; + string? parsed = null; + try + { + using var doc = JsonDocument.Parse(data); + TryParseGeminiUsage(doc.RootElement); + var candidates = doc.RootElement.GetProperty("candidates"); + if (candidates.GetArrayLength() == 0) continue; + var sb = new StringBuilder(); + var parts = candidates[0].GetProperty("content").GetProperty("parts"); + foreach (var part in parts.EnumerateArray()) + { + if (part.TryGetProperty("text", out var t)) + { + var text = t.GetString(); + if (!string.IsNullOrEmpty(text)) sb.Append(text); + } + } + if (sb.Length > 0) parsed = sb.ToString(); + } + catch (JsonException ex) + { + LogService.Warn($"Gemini 스트리밍 JSON 파싱 오류: {ex.Message}"); + } + if (parsed != null) yield return parsed; + } + } + + private object BuildGeminiBody(List messages) + { + var llm = _settings.Settings.Llm; + var contents = new List(); + + object? systemInstruction = null; + if (!string.IsNullOrEmpty(_systemPrompt)) + { + systemInstruction = new { parts = new[] { new { text = _systemPrompt } } }; + } + + foreach (var m in messages) + { + if (m.Role == "system") continue; + var parts = new List { new { text = m.Content } }; + if (m.Images?.Count > 0) + { + foreach (var img in m.Images) + parts.Add(new { inlineData = new { mimeType = img.MimeType, data = img.Base64 } }); + } + contents.Add(new + { + role = m.Role == "assistant" ? "model" : "user", + parts + }); + } + + if (systemInstruction != null) + return new + { + systemInstruction, + contents, + generationConfig = new { temperature = llm.Temperature, maxOutputTokens = llm.MaxContextTokens } + }; + + return new + { + contents, + generationConfig = new { temperature = llm.Temperature, maxOutputTokens = llm.MaxContextTokens } + }; + } + + // ═══════════════════════════════════════════════════════════════════════ + // Claude (Anthropic Messages API) + // ═══════════════════════════════════════════════════════════════════════ + + private async Task SendClaudeAsync(List messages, CancellationToken ct) + { + var llm = _settings.Settings.Llm; + var apiKey = llm.ApiKey; + if (string.IsNullOrEmpty(apiKey)) + throw new InvalidOperationException("Claude API 키가 설정되지 않았습니다. 설정 > AX Agent에서 API 키를 입력하세요."); + + var body = BuildClaudeBody(messages, stream: false); + var json = JsonSerializer.Serialize(body); + using var req = new HttpRequestMessage(HttpMethod.Post, "https://api.anthropic.com/v1/messages"); + req.Content = new StringContent(json, Encoding.UTF8, "application/json"); + req.Headers.Add("x-api-key", apiKey); + req.Headers.Add("anthropic-version", "2023-06-01"); + + using var resp = await _http.SendAsync(req, ct); + if (!resp.IsSuccessStatusCode) + { + var errBody = await resp.Content.ReadAsStringAsync(ct); + throw new HttpRequestException(ClassifyHttpError(resp, errBody)); + } + + var respJson = await resp.Content.ReadAsStringAsync(ct); + return SafeParseJson(respJson, root => + { + TryParseClaudeUsage(root); + var content = root.GetProperty("content"); + if (content.GetArrayLength() == 0) return "(빈 응답)"; + return content[0].GetProperty("text").GetString() ?? ""; + }, "Claude 응답"); + } + + private async IAsyncEnumerable StreamClaudeAsync( + List messages, + [EnumeratorCancellation] CancellationToken ct) + { + var llm = _settings.Settings.Llm; + var apiKey = llm.ApiKey; + if (string.IsNullOrEmpty(apiKey)) + throw new InvalidOperationException("Claude API 키가 설정되지 않았습니다."); + + var body = BuildClaudeBody(messages, stream: true); + var json = JsonSerializer.Serialize(body); + using var req = new HttpRequestMessage(HttpMethod.Post, "https://api.anthropic.com/v1/messages"); + req.Content = new StringContent(json, Encoding.UTF8, "application/json"); + req.Headers.Add("x-api-key", apiKey); + req.Headers.Add("anthropic-version", "2023-06-01"); + + using var resp = await _http.SendAsync(req, HttpCompletionOption.ResponseHeadersRead, ct); + if (!resp.IsSuccessStatusCode) + { + var errBody = await resp.Content.ReadAsStringAsync(ct); + throw new HttpRequestException(ClassifyHttpError(resp, errBody)); + } + + using var stream = await resp.Content.ReadAsStreamAsync(ct); + using var reader = new StreamReader(stream); + + while (!reader.EndOfStream && !ct.IsCancellationRequested) + { + var line = await ReadLineWithTimeoutAsync(reader, ct); + if (line == null) break; + if (string.IsNullOrEmpty(line) || !line.StartsWith("data: ")) continue; + var data = line["data: ".Length..]; + + string? text = null; + try + { + using var doc = JsonDocument.Parse(data); + var type = doc.RootElement.GetProperty("type").GetString(); + if (type == "content_block_delta") + { + var delta = doc.RootElement.GetProperty("delta"); + if (delta.TryGetProperty("text", out var t)) + text = t.GetString(); + } + else if (type is "message_start" or "message_delta") + { + // message_start: usage in .message.usage, message_delta: usage in .usage + if (doc.RootElement.TryGetProperty("message", out var msg) && + msg.TryGetProperty("usage", out var u1)) + TryParseClaudeUsageFromElement(u1); + else if (doc.RootElement.TryGetProperty("usage", out var u2)) + TryParseClaudeUsageFromElement(u2); + } + } + catch (JsonException ex) + { + LogService.Warn($"Claude 스트리밍 JSON 파싱 오류: {ex.Message}"); + } + if (!string.IsNullOrEmpty(text)) yield return text; + } + } + + private object BuildClaudeBody(List messages, bool stream) + { + var llm = _settings.Settings.Llm; + var msgs = new List(); + + foreach (var m in messages) + { + if (m.Role == "system") continue; + if (m.Images?.Count > 0) + { + // Claude Vision: content를 배열로 변환 (이미지 + 텍스트) + var contentParts = new List(); + foreach (var img in m.Images) + contentParts.Add(new { type = "image", source = new { type = "base64", media_type = img.MimeType, data = img.Base64 } }); + contentParts.Add(new { type = "text", text = m.Content }); + msgs.Add(new { role = m.Role, content = contentParts }); + } + else + { + msgs.Add(new { role = m.Role, content = m.Content }); + } + } + + var activeModel = ResolveModel(); + if (!string.IsNullOrEmpty(_systemPrompt)) + { + return new + { + model = activeModel, + max_tokens = llm.MaxContextTokens, + temperature = llm.Temperature, + system = _systemPrompt, + messages = msgs, + stream + }; + } + + return new + { + model = activeModel, + max_tokens = llm.MaxContextTokens, + temperature = llm.Temperature, + messages = msgs, + stream + }; + } +} diff --git a/src/AxCopilot/Services/LlmService.Streaming.cs b/src/AxCopilot/Services/LlmService.Streaming.cs index e892b35..3448907 100644 --- a/src/AxCopilot/Services/LlmService.Streaming.cs +++ b/src/AxCopilot/Services/LlmService.Streaming.cs @@ -250,267 +250,4 @@ public partial class LlmService }; } - // ═══════════════════════════════════════════════════════════════════════ - // Gemini - // ═══════════════════════════════════════════════════════════════════════ - - private async Task SendGeminiAsync(List messages, CancellationToken ct) - { - var llm = _settings.Settings.Llm; - var apiKey = ResolveApiKeyForService("gemini"); - if (string.IsNullOrEmpty(apiKey)) - throw new InvalidOperationException("Gemini API 키가 설정되지 않았습니다. 설정 > AX Agent에서 API 키를 입력하세요."); - - var model = ResolveModel(); - var body = BuildGeminiBody(messages); - var url = $"https://generativelanguage.googleapis.com/v1beta/models/{model}:generateContent?key={apiKey}"; - var resp = await PostJsonWithRetryAsync(url, body, ct); - return SafeParseJson(resp, root => - { - TryParseGeminiUsage(root); - var candidates = root.GetProperty("candidates"); - if (candidates.GetArrayLength() == 0) return "(빈 응답)"; - var parts = candidates[0].GetProperty("content").GetProperty("parts"); - if (parts.GetArrayLength() == 0) return "(빈 응답)"; - return parts[0].GetProperty("text").GetString() ?? ""; - }, "Gemini 응답"); - } - - private async IAsyncEnumerable StreamGeminiAsync( - List messages, - [EnumeratorCancellation] CancellationToken ct) - { - var llm = _settings.Settings.Llm; - var apiKey = ResolveApiKeyForService("gemini"); - if (string.IsNullOrEmpty(apiKey)) - throw new InvalidOperationException("Gemini API 키가 설정되지 않았습니다."); - - var model = ResolveModel(); - var body = BuildGeminiBody(messages); - var url = $"https://generativelanguage.googleapis.com/v1beta/models/{model}:streamGenerateContent?alt=sse&key={apiKey}"; - - using var req = new HttpRequestMessage(HttpMethod.Post, url) { Content = JsonContent(body) }; - using var resp = await SendWithErrorClassificationAsync(req, ct); - - using var stream = await resp.Content.ReadAsStreamAsync(ct); - using var reader = new StreamReader(stream); - - while (!reader.EndOfStream && !ct.IsCancellationRequested) - { - var line = await ReadLineWithTimeoutAsync(reader, ct); - if (line == null) break; - if (string.IsNullOrEmpty(line) || !line.StartsWith("data: ")) continue; - var data = line["data: ".Length..]; - string? parsed = null; - try - { - using var doc = JsonDocument.Parse(data); - TryParseGeminiUsage(doc.RootElement); - var candidates = doc.RootElement.GetProperty("candidates"); - if (candidates.GetArrayLength() == 0) continue; - var sb = new StringBuilder(); - var parts = candidates[0].GetProperty("content").GetProperty("parts"); - foreach (var part in parts.EnumerateArray()) - { - if (part.TryGetProperty("text", out var t)) - { - var text = t.GetString(); - if (!string.IsNullOrEmpty(text)) sb.Append(text); - } - } - if (sb.Length > 0) parsed = sb.ToString(); - } - catch (JsonException ex) - { - LogService.Warn($"Gemini 스트리밍 JSON 파싱 오류: {ex.Message}"); - } - if (parsed != null) yield return parsed; - } - } - - private object BuildGeminiBody(List messages) - { - var llm = _settings.Settings.Llm; - var contents = new List(); - - object? systemInstruction = null; - if (!string.IsNullOrEmpty(_systemPrompt)) - { - systemInstruction = new { parts = new[] { new { text = _systemPrompt } } }; - } - - foreach (var m in messages) - { - if (m.Role == "system") continue; - var parts = new List { new { text = m.Content } }; - if (m.Images?.Count > 0) - { - foreach (var img in m.Images) - parts.Add(new { inlineData = new { mimeType = img.MimeType, data = img.Base64 } }); - } - contents.Add(new - { - role = m.Role == "assistant" ? "model" : "user", - parts - }); - } - - if (systemInstruction != null) - return new - { - systemInstruction, - contents, - generationConfig = new { temperature = llm.Temperature, maxOutputTokens = llm.MaxContextTokens } - }; - - return new - { - contents, - generationConfig = new { temperature = llm.Temperature, maxOutputTokens = llm.MaxContextTokens } - }; - } - - // ═══════════════════════════════════════════════════════════════════════ - // Claude (Anthropic Messages API) - // ═══════════════════════════════════════════════════════════════════════ - - private async Task SendClaudeAsync(List messages, CancellationToken ct) - { - var llm = _settings.Settings.Llm; - var apiKey = llm.ApiKey; - if (string.IsNullOrEmpty(apiKey)) - throw new InvalidOperationException("Claude API 키가 설정되지 않았습니다. 설정 > AX Agent에서 API 키를 입력하세요."); - - var body = BuildClaudeBody(messages, stream: false); - var json = JsonSerializer.Serialize(body); - using var req = new HttpRequestMessage(HttpMethod.Post, "https://api.anthropic.com/v1/messages"); - req.Content = new StringContent(json, Encoding.UTF8, "application/json"); - req.Headers.Add("x-api-key", apiKey); - req.Headers.Add("anthropic-version", "2023-06-01"); - - using var resp = await _http.SendAsync(req, ct); - if (!resp.IsSuccessStatusCode) - { - var errBody = await resp.Content.ReadAsStringAsync(ct); - throw new HttpRequestException(ClassifyHttpError(resp, errBody)); - } - - var respJson = await resp.Content.ReadAsStringAsync(ct); - return SafeParseJson(respJson, root => - { - TryParseClaudeUsage(root); - var content = root.GetProperty("content"); - if (content.GetArrayLength() == 0) return "(빈 응답)"; - return content[0].GetProperty("text").GetString() ?? ""; - }, "Claude 응답"); - } - - private async IAsyncEnumerable StreamClaudeAsync( - List messages, - [EnumeratorCancellation] CancellationToken ct) - { - var llm = _settings.Settings.Llm; - var apiKey = llm.ApiKey; - if (string.IsNullOrEmpty(apiKey)) - throw new InvalidOperationException("Claude API 키가 설정되지 않았습니다."); - - var body = BuildClaudeBody(messages, stream: true); - var json = JsonSerializer.Serialize(body); - using var req = new HttpRequestMessage(HttpMethod.Post, "https://api.anthropic.com/v1/messages"); - req.Content = new StringContent(json, Encoding.UTF8, "application/json"); - req.Headers.Add("x-api-key", apiKey); - req.Headers.Add("anthropic-version", "2023-06-01"); - - using var resp = await _http.SendAsync(req, HttpCompletionOption.ResponseHeadersRead, ct); - if (!resp.IsSuccessStatusCode) - { - var errBody = await resp.Content.ReadAsStringAsync(ct); - throw new HttpRequestException(ClassifyHttpError(resp, errBody)); - } - - using var stream = await resp.Content.ReadAsStreamAsync(ct); - using var reader = new StreamReader(stream); - - while (!reader.EndOfStream && !ct.IsCancellationRequested) - { - var line = await ReadLineWithTimeoutAsync(reader, ct); - if (line == null) break; - if (string.IsNullOrEmpty(line) || !line.StartsWith("data: ")) continue; - var data = line["data: ".Length..]; - - string? text = null; - try - { - using var doc = JsonDocument.Parse(data); - var type = doc.RootElement.GetProperty("type").GetString(); - if (type == "content_block_delta") - { - var delta = doc.RootElement.GetProperty("delta"); - if (delta.TryGetProperty("text", out var t)) - text = t.GetString(); - } - else if (type is "message_start" or "message_delta") - { - // message_start: usage in .message.usage, message_delta: usage in .usage - if (doc.RootElement.TryGetProperty("message", out var msg) && - msg.TryGetProperty("usage", out var u1)) - TryParseClaudeUsageFromElement(u1); - else if (doc.RootElement.TryGetProperty("usage", out var u2)) - TryParseClaudeUsageFromElement(u2); - } - } - catch (JsonException ex) - { - LogService.Warn($"Claude 스트리밍 JSON 파싱 오류: {ex.Message}"); - } - if (!string.IsNullOrEmpty(text)) yield return text; - } - } - - private object BuildClaudeBody(List messages, bool stream) - { - var llm = _settings.Settings.Llm; - var msgs = new List(); - - foreach (var m in messages) - { - if (m.Role == "system") continue; - if (m.Images?.Count > 0) - { - // Claude Vision: content를 배열로 변환 (이미지 + 텍스트) - var contentParts = new List(); - foreach (var img in m.Images) - contentParts.Add(new { type = "image", source = new { type = "base64", media_type = img.MimeType, data = img.Base64 } }); - contentParts.Add(new { type = "text", text = m.Content }); - msgs.Add(new { role = m.Role, content = contentParts }); - } - else - { - msgs.Add(new { role = m.Role, content = m.Content }); - } - } - - var activeModel = ResolveModel(); - if (!string.IsNullOrEmpty(_systemPrompt)) - { - return new - { - model = activeModel, - max_tokens = llm.MaxContextTokens, - temperature = llm.Temperature, - system = _systemPrompt, - messages = msgs, - stream - }; - } - - return new - { - model = activeModel, - max_tokens = llm.MaxContextTokens, - temperature = llm.Temperature, - messages = msgs, - stream - }; - } } diff --git a/src/AxCopilot/Views/SkillEditorWindow.PreviewSave.cs b/src/AxCopilot/Views/SkillEditorWindow.PreviewSave.cs new file mode 100644 index 0000000..73aa14b --- /dev/null +++ b/src/AxCopilot/Views/SkillEditorWindow.PreviewSave.cs @@ -0,0 +1,238 @@ +using System.IO; +using System.Windows; +using System.Windows.Controls; +using System.Windows.Input; +using System.Windows.Media; +using AxCopilot.Services; +using AxCopilot.Services.Agent; + +namespace AxCopilot.Views; + +public partial class SkillEditorWindow +{ + // ─── 미리보기 + 저장 + 편집 모드 로드 ──────────────────────────────────── + + private void BtnPreview_Click(object sender, MouseButtonEventArgs e) + { + var content = GenerateSkillContent(); + + var previewWin = new Window + { + Title = "스킬 파일 미리보기", + Width = 640, + Height = 520, + WindowStyle = WindowStyle.None, + AllowsTransparency = true, + Background = Brushes.Transparent, + WindowStartupLocation = WindowStartupLocation.CenterOwner, + Owner = this, + }; + + var bgBrush = TryFindResource("LauncherBackground") as Brush ?? Brushes.White; + var fgBrush = TryFindResource("PrimaryText") as Brush ?? Brushes.White; + var subBrush = TryFindResource("SecondaryText") as Brush ?? Brushes.Gray; + var itemBg = TryFindResource("ItemBackground") as Brush ?? Brushes.Transparent; + var borderBrush = TryFindResource("BorderColor") as Brush ?? Brushes.Gray; + + var outerBorder = new Border + { + Background = bgBrush, + CornerRadius = new CornerRadius(12), + BorderBrush = borderBrush, + BorderThickness = new Thickness(1), + Effect = new System.Windows.Media.Effects.DropShadowEffect + { + BlurRadius = 20, ShadowDepth = 4, Opacity = 0.3, Color = Colors.Black, + }, + }; + + var grid = new Grid(); + grid.RowDefinitions.Add(new RowDefinition { Height = new GridLength(44) }); + grid.RowDefinitions.Add(new RowDefinition { Height = new GridLength(1, GridUnitType.Star) }); + grid.RowDefinitions.Add(new RowDefinition { Height = GridLength.Auto }); + + // 타이틀바 + var titleBar = new Border + { + Background = itemBg, + CornerRadius = new CornerRadius(12, 12, 0, 0), + }; + titleBar.MouseLeftButtonDown += (_, _) => previewWin.DragMove(); + var titleText = new TextBlock + { + Text = "미리보기 — .skill.md", + FontSize = 14, + FontWeight = FontWeights.SemiBold, + Foreground = fgBrush, + VerticalAlignment = VerticalAlignment.Center, + Margin = new Thickness(16, 0, 0, 0), + }; + titleBar.Child = titleText; + Grid.SetRow(titleBar, 0); + + // 콘텐츠 + var textBox = new TextBox + { + Text = content, + FontFamily = ThemeResourceHelper.ConsolasCode, + FontSize = 12.5, + IsReadOnly = true, + AcceptsReturn = true, + TextWrapping = TextWrapping.Wrap, + VerticalScrollBarVisibility = ScrollBarVisibility.Auto, + Background = itemBg, + Foreground = fgBrush, + BorderThickness = new Thickness(0), + Padding = new Thickness(16, 12, 16, 12), + Margin = new Thickness(8, 8, 8, 0), + }; + Grid.SetRow(textBox, 1); + + // 하단 + var bottomBar = new Border + { + Padding = new Thickness(16, 10, 16, 10), + CornerRadius = new CornerRadius(0, 0, 12, 12), + }; + var closeBtn = new Border + { + CornerRadius = new CornerRadius(8), + Padding = new Thickness(18, 8, 18, 8), + Background = itemBg, + Cursor = Cursors.Hand, + HorizontalAlignment = HorizontalAlignment.Right, + }; + closeBtn.Child = new TextBlock + { + Text = "닫기", + FontSize = 12.5, + Foreground = subBrush, + }; + closeBtn.MouseLeftButtonUp += (_, _) => previewWin.Close(); + closeBtn.MouseEnter += (s, _) => + { + if (s is Border b) b.Background = new SolidColorBrush(Color.FromArgb(0x18, 0xFF, 0xFF, 0xFF)); + }; + closeBtn.MouseLeave += (s, _) => + { + if (s is Border b) b.Background = itemBg; + }; + bottomBar.Child = closeBtn; + Grid.SetRow(bottomBar, 2); + + grid.Children.Add(titleBar); + grid.Children.Add(textBox); + grid.Children.Add(bottomBar); + outerBorder.Child = grid; + previewWin.Content = outerBorder; + previewWin.ShowDialog(); + } + + // ─── 저장 ───────────────────────────────────────────────────────────── + + private void BtnSave_Click(object sender, MouseButtonEventArgs e) + { + // 유효성 검사 + var name = TxtName.Text.Trim(); + if (string.IsNullOrWhiteSpace(name)) + { + StatusText.Text = "⚠ 이름을 입력하세요."; + StatusText.Foreground = new SolidColorBrush(Color.FromRgb(0xF8, 0x71, 0x71)); + TxtName.Focus(); + return; + } + + // 영문 + 하이픈 + 숫자만 허용 + if (!System.Text.RegularExpressions.Regex.IsMatch(name, @"^[a-zA-Z][a-zA-Z0-9\-]*$")) + { + StatusText.Text = "⚠ 이름은 영문으로 시작하며 영문, 숫자, 하이픈만 사용 가능합니다."; + StatusText.Foreground = new SolidColorBrush(Color.FromRgb(0xF8, 0x71, 0x71)); + TxtName.Focus(); + return; + } + + if (string.IsNullOrWhiteSpace(TxtInstructions.Text)) + { + StatusText.Text = "⚠ 지시사항을 입력하세요."; + StatusText.Foreground = new SolidColorBrush(Color.FromRgb(0xF8, 0x71, 0x71)); + TxtInstructions.Focus(); + return; + } + + var content = GenerateSkillContent(); + + // 저장 경로 결정 + string savePath; + if (_editingSkill != null) + { + // 편집 모드: 기존 파일 덮어쓰기 + savePath = _editingSkill.FilePath; + } + else + { + // 새 스킬: 사용자 폴더에 저장 + var userFolder = Path.Combine( + Environment.GetFolderPath(Environment.SpecialFolder.ApplicationData), + "AxCopilot", "skills"); + if (!Directory.Exists(userFolder)) + Directory.CreateDirectory(userFolder); + + savePath = Path.Combine(userFolder, $"{name}.skill.md"); + + // 파일 이름 충돌 시 숫자 추가 + if (File.Exists(savePath)) + { + var counter = 2; + while (File.Exists(Path.Combine(userFolder, $"{name}_{counter}.skill.md"))) + counter++; + savePath = Path.Combine(userFolder, $"{name}_{counter}.skill.md"); + } + } + + try + { + File.WriteAllText(savePath, content, System.Text.Encoding.UTF8); + SkillService.LoadSkills(); + + StatusText.Text = $"✓ 저장 완료: {Path.GetFileName(savePath)}"; + StatusText.Foreground = new SolidColorBrush(Color.FromRgb(0x34, 0xD3, 0x99)); + + // 편집 결과 반환 + DialogResult = true; + } + catch (Exception ex) + { + CustomMessageBox.Show($"저장 실패: {ex.Message}", "스킬 저장"); + } + } + + // ─── 편집 모드 로드 ─────────────────────────────────────────────────── + + private void LoadSkill(SkillDefinition skill) + { + TitleText.Text = "스킬 편집"; + TxtName.Text = skill.Name; + TxtLabel.Text = skill.Label; + TxtDescription.Text = skill.Description; + TxtInstructions.Text = skill.SystemPrompt; + + // 아이콘 선택 + _selectedIcon = IconCandidates.Contains(skill.Icon) ? skill.Icon : IconCandidates[0]; + BuildIconSelector(); + + // 런타임 요구사항 + for (int i = 0; i < CmbRequires.Items.Count; i++) + { + if (CmbRequires.Items[i] is ComboBoxItem item + && item.Tag is string tag + && string.Equals(tag, skill.Requires, StringComparison.OrdinalIgnoreCase)) + { + CmbRequires.SelectedIndex = i; + break; + } + } + + // 도구 체크리스트 (BuildToolChecklist에서 이미 _editingSkill 기반으로 설정됨) + UpdatePreview(); + } +} diff --git a/src/AxCopilot/Views/SkillEditorWindow.xaml.cs b/src/AxCopilot/Views/SkillEditorWindow.xaml.cs index 5c44849..2d3e092 100644 --- a/src/AxCopilot/Views/SkillEditorWindow.xaml.cs +++ b/src/AxCopilot/Views/SkillEditorWindow.xaml.cs @@ -300,229 +300,4 @@ public partial class SkillEditorWindow : Window return string.Join("\n", yaml) + instructions; } - // ─── 미리보기 버튼 ─────────────────────────────────────────────────── - - private void BtnPreview_Click(object sender, MouseButtonEventArgs e) - { - var content = GenerateSkillContent(); - - var previewWin = new Window - { - Title = "스킬 파일 미리보기", - Width = 640, - Height = 520, - WindowStyle = WindowStyle.None, - AllowsTransparency = true, - Background = Brushes.Transparent, - WindowStartupLocation = WindowStartupLocation.CenterOwner, - Owner = this, - }; - - var bgBrush = TryFindResource("LauncherBackground") as Brush ?? Brushes.White; - var fgBrush = TryFindResource("PrimaryText") as Brush ?? Brushes.White; - var subBrush = TryFindResource("SecondaryText") as Brush ?? Brushes.Gray; - var itemBg = TryFindResource("ItemBackground") as Brush ?? Brushes.Transparent; - var borderBrush = TryFindResource("BorderColor") as Brush ?? Brushes.Gray; - - var outerBorder = new Border - { - Background = bgBrush, - CornerRadius = new CornerRadius(12), - BorderBrush = borderBrush, - BorderThickness = new Thickness(1), - Effect = new System.Windows.Media.Effects.DropShadowEffect - { - BlurRadius = 20, ShadowDepth = 4, Opacity = 0.3, Color = Colors.Black, - }, - }; - - var grid = new Grid(); - grid.RowDefinitions.Add(new RowDefinition { Height = new GridLength(44) }); - grid.RowDefinitions.Add(new RowDefinition { Height = new GridLength(1, GridUnitType.Star) }); - grid.RowDefinitions.Add(new RowDefinition { Height = GridLength.Auto }); - - // 타이틀바 - var titleBar = new Border - { - Background = itemBg, - CornerRadius = new CornerRadius(12, 12, 0, 0), - }; - titleBar.MouseLeftButtonDown += (_, _) => previewWin.DragMove(); - var titleText = new TextBlock - { - Text = "미리보기 — .skill.md", - FontSize = 14, - FontWeight = FontWeights.SemiBold, - Foreground = fgBrush, - VerticalAlignment = VerticalAlignment.Center, - Margin = new Thickness(16, 0, 0, 0), - }; - titleBar.Child = titleText; - Grid.SetRow(titleBar, 0); - - // 콘텐츠 - var textBox = new TextBox - { - Text = content, - FontFamily = ThemeResourceHelper.ConsolasCode, - FontSize = 12.5, - IsReadOnly = true, - AcceptsReturn = true, - TextWrapping = TextWrapping.Wrap, - VerticalScrollBarVisibility = ScrollBarVisibility.Auto, - Background = itemBg, - Foreground = fgBrush, - BorderThickness = new Thickness(0), - Padding = new Thickness(16, 12, 16, 12), - Margin = new Thickness(8, 8, 8, 0), - }; - Grid.SetRow(textBox, 1); - - // 하단 - var bottomBar = new Border - { - Padding = new Thickness(16, 10, 16, 10), - CornerRadius = new CornerRadius(0, 0, 12, 12), - }; - var closeBtn = new Border - { - CornerRadius = new CornerRadius(8), - Padding = new Thickness(18, 8, 18, 8), - Background = itemBg, - Cursor = Cursors.Hand, - HorizontalAlignment = HorizontalAlignment.Right, - }; - closeBtn.Child = new TextBlock - { - Text = "닫기", - FontSize = 12.5, - Foreground = subBrush, - }; - closeBtn.MouseLeftButtonUp += (_, _) => previewWin.Close(); - closeBtn.MouseEnter += (s, _) => - { - if (s is Border b) b.Background = new SolidColorBrush(Color.FromArgb(0x18, 0xFF, 0xFF, 0xFF)); - }; - closeBtn.MouseLeave += (s, _) => - { - if (s is Border b) b.Background = itemBg; - }; - bottomBar.Child = closeBtn; - Grid.SetRow(bottomBar, 2); - - grid.Children.Add(titleBar); - grid.Children.Add(textBox); - grid.Children.Add(bottomBar); - outerBorder.Child = grid; - previewWin.Content = outerBorder; - previewWin.ShowDialog(); - } - - // ─── 저장 ───────────────────────────────────────────────────────────── - - private void BtnSave_Click(object sender, MouseButtonEventArgs e) - { - // 유효성 검사 - var name = TxtName.Text.Trim(); - if (string.IsNullOrWhiteSpace(name)) - { - StatusText.Text = "⚠ 이름을 입력하세요."; - StatusText.Foreground = new SolidColorBrush(Color.FromRgb(0xF8, 0x71, 0x71)); - TxtName.Focus(); - return; - } - - // 영문 + 하이픈 + 숫자만 허용 - if (!System.Text.RegularExpressions.Regex.IsMatch(name, @"^[a-zA-Z][a-zA-Z0-9\-]*$")) - { - StatusText.Text = "⚠ 이름은 영문으로 시작하며 영문, 숫자, 하이픈만 사용 가능합니다."; - StatusText.Foreground = new SolidColorBrush(Color.FromRgb(0xF8, 0x71, 0x71)); - TxtName.Focus(); - return; - } - - if (string.IsNullOrWhiteSpace(TxtInstructions.Text)) - { - StatusText.Text = "⚠ 지시사항을 입력하세요."; - StatusText.Foreground = new SolidColorBrush(Color.FromRgb(0xF8, 0x71, 0x71)); - TxtInstructions.Focus(); - return; - } - - var content = GenerateSkillContent(); - - // 저장 경로 결정 - string savePath; - if (_editingSkill != null) - { - // 편집 모드: 기존 파일 덮어쓰기 - savePath = _editingSkill.FilePath; - } - else - { - // 새 스킬: 사용자 폴더에 저장 - var userFolder = Path.Combine( - Environment.GetFolderPath(Environment.SpecialFolder.ApplicationData), - "AxCopilot", "skills"); - if (!Directory.Exists(userFolder)) - Directory.CreateDirectory(userFolder); - - savePath = Path.Combine(userFolder, $"{name}.skill.md"); - - // 파일 이름 충돌 시 숫자 추가 - if (File.Exists(savePath)) - { - var counter = 2; - while (File.Exists(Path.Combine(userFolder, $"{name}_{counter}.skill.md"))) - counter++; - savePath = Path.Combine(userFolder, $"{name}_{counter}.skill.md"); - } - } - - try - { - File.WriteAllText(savePath, content, System.Text.Encoding.UTF8); - SkillService.LoadSkills(); - - StatusText.Text = $"✓ 저장 완료: {Path.GetFileName(savePath)}"; - StatusText.Foreground = new SolidColorBrush(Color.FromRgb(0x34, 0xD3, 0x99)); - - // 편집 결과 반환 - DialogResult = true; - } - catch (Exception ex) - { - CustomMessageBox.Show($"저장 실패: {ex.Message}", "스킬 저장"); - } - } - - // ─── 편집 모드 로드 ─────────────────────────────────────────────────── - - private void LoadSkill(SkillDefinition skill) - { - TitleText.Text = "스킬 편집"; - TxtName.Text = skill.Name; - TxtLabel.Text = skill.Label; - TxtDescription.Text = skill.Description; - TxtInstructions.Text = skill.SystemPrompt; - - // 아이콘 선택 - _selectedIcon = IconCandidates.Contains(skill.Icon) ? skill.Icon : IconCandidates[0]; - BuildIconSelector(); - - // 런타임 요구사항 - for (int i = 0; i < CmbRequires.Items.Count; i++) - { - if (CmbRequires.Items[i] is ComboBoxItem item - && item.Tag is string tag - && string.Equals(tag, skill.Requires, StringComparison.OrdinalIgnoreCase)) - { - CmbRequires.SelectedIndex = i; - break; - } - } - - // 도구 체크리스트 (BuildToolChecklist에서 이미 _editingSkill 기반으로 설정됨) - UpdatePreview(); - } }