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

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

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

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

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

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

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

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

빌드: 경고 0, 오류 0

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -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<WindowSnapshot> 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; } = ""; // 확장될 전체 텍스트
}
// ─── 퀵링크 ──────────────────────────────────────────────────────────────────
/// <summary>
/// 파라미터 퀵링크 항목.
/// 예: keyword="maps", urlTemplate="https://map.naver.com/p/search/{0}"
/// 사용: ql maps 강남역 → URL에 "강남역" 치환 후 브라우저 열기
/// </summary>
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 스니펫 템플릿 ─────────────────────────────────────────────────────────
/// <summary>
/// AI 스니펫 템플릿 항목.
/// 예: keyword="email", prompt="다음 상황에 맞는 업무 이메일 작성: {0}"
/// 사용: ai email 프로젝트 일정 변경 안내 → AI가 이메일 초안 생성
/// </summary>
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<string> 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;
/// <summary>
/// 시스템 명령 별칭. key = 기본 명령어(lock/sleep 등), value = 사용자 정의 별칭 목록.
/// 예: { "lock": ["잠금", "l"] } → /잠금, /l 로도 실행 가능
/// </summary>
[JsonPropertyName("commandAliases")]
public Dictionary<string, List<string>> CommandAliases { get; set; } = new();
}
// ─── 스크린 캡처 설정 ──────────────────────────────────────────────────────────
public class ScreenCaptureSettings
{
/// <summary>캡처 명령어 프리픽스. 기본값 "cap".</summary>
[JsonPropertyName("prefix")]
public string Prefix { get; set; } = "cap";
/// <summary>런처를 열지 않고 글로벌 단축키로 캡처하는 기능 활성화 여부.</summary>
[JsonPropertyName("globalHotkeyEnabled")]
public bool GlobalHotkeyEnabled { get; set; } = false;
/// <summary>글로벌 캡처 단축키 문자열. 기본값 "PrintScreen".</summary>
[JsonPropertyName("globalHotkey")]
public string GlobalHotkey { get; set; } = "PrintScreen";
/// <summary>글로벌 캡처 단축키 실행 모드. screen|window|region.</summary>
[JsonPropertyName("globalHotkeyMode")]
public string GlobalHotkeyMode { get; set; } = "screen";
/// <summary>스크롤 캡처 프레임 간 대기 시간(ms). 기본값 120.</summary>
[JsonPropertyName("scrollDelayMs")]
public int ScrollDelayMs { get; set; } = 120;
}
// ─── 잠금 해제 알림 설정 ───────────────────────────────────────────────────────
public class ReminderSettings
{
/// <summary>기능 활성화 여부. 기본값 false.</summary>
[JsonPropertyName("enabled")]
public bool Enabled { get; set; } = false;
/// <summary>팝업 표시 위치. top-left | top-right | bottom-left | bottom-right</summary>
[JsonPropertyName("corner")]
public string Corner { get; set; } = "bottom-right";
/// <summary>알림 간격(분). 30 | 60 | 120 | 180 | 240</summary>
[JsonPropertyName("intervalMinutes")]
public int IntervalMinutes { get; set; } = 60;
/// <summary>팝업 자동 닫힘 시간(초). 기본값 15. (5/10/15/20/30/60/120/180)</summary>
[JsonPropertyName("displaySeconds")]
public int DisplaySeconds { get; set; } = 15;
/// <summary>알림 콘텐츠 카테고리 활성화 목록.</summary>
[JsonPropertyName("enabledCategories")]
public List<string> EnabledCategories { get; set; } = new() { "motivational" };
}

View File

@@ -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<WindowSnapshot> 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; } = ""; // 확장될 전체 텍스트
}
// ─── 퀵링크 ──────────────────────────────────────────────────────────────────
/// <summary>
/// 파라미터 퀵링크 항목.
/// 예: keyword="maps", urlTemplate="https://map.naver.com/p/search/{0}"
/// 사용: ql maps 강남역 → URL에 "강남역" 치환 후 브라우저 열기
/// </summary>
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 스니펫 템플릿 ─────────────────────────────────────────────────────────
/// <summary>
/// AI 스니펫 템플릿 항목.
/// 예: keyword="email", prompt="다음 상황에 맞는 업무 이메일 작성: {0}"
/// 사용: ai email 프로젝트 일정 변경 안내 → AI가 이메일 초안 생성
/// </summary>
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<string> 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;
/// <summary>
/// 시스템 명령 별칭. key = 기본 명령어(lock/sleep 등), value = 사용자 정의 별칭 목록.
/// 예: { "lock": ["잠금", "l"] } → /잠금, /l 로도 실행 가능
/// </summary>
[JsonPropertyName("commandAliases")]
public Dictionary<string, List<string>> CommandAliases { get; set; } = new();
}
// ─── 스크린 캡처 설정 ──────────────────────────────────────────────────────────
public class ScreenCaptureSettings
{
/// <summary>캡처 명령어 프리픽스. 기본값 "cap".</summary>
[JsonPropertyName("prefix")]
public string Prefix { get; set; } = "cap";
/// <summary>런처를 열지 않고 글로벌 단축키로 캡처하는 기능 활성화 여부.</summary>
[JsonPropertyName("globalHotkeyEnabled")]
public bool GlobalHotkeyEnabled { get; set; } = false;
/// <summary>글로벌 캡처 단축키 문자열. 기본값 "PrintScreen".</summary>
[JsonPropertyName("globalHotkey")]
public string GlobalHotkey { get; set; } = "PrintScreen";
/// <summary>글로벌 캡처 단축키 실행 모드. screen|window|region.</summary>
[JsonPropertyName("globalHotkeyMode")]
public string GlobalHotkeyMode { get; set; } = "screen";
/// <summary>스크롤 캡처 프레임 간 대기 시간(ms). 기본값 120.</summary>
[JsonPropertyName("scrollDelayMs")]
public int ScrollDelayMs { get; set; } = 120;
}
// ─── 잠금 해제 알림 설정 ───────────────────────────────────────────────────────
public class ReminderSettings
{
/// <summary>기능 활성화 여부. 기본값 false.</summary>
[JsonPropertyName("enabled")]
public bool Enabled { get; set; } = false;
/// <summary>팝업 표시 위치. top-left | top-right | bottom-left | bottom-right</summary>
[JsonPropertyName("corner")]
public string Corner { get; set; } = "bottom-right";
/// <summary>알림 간격(분). 30 | 60 | 120 | 180 | 240</summary>
[JsonPropertyName("intervalMinutes")]
public int IntervalMinutes { get; set; } = 60;
/// <summary>팝업 자동 닫힘 시간(초). 기본값 15. (5/10/15/20/30/60/120/180)</summary>
[JsonPropertyName("displaySeconds")]
public int DisplaySeconds { get; set; } = 15;
/// <summary>알림 콘텐츠 카테고리 활성화 목록.</summary>
[JsonPropertyName("enabledCategories")]
public List<string> EnabledCategories { get; set; } = new() { "motivational" };
}

View File

@@ -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<string> labels, List<Dataset> 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("<div class=\"hbar-chart\">");
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($"<div class=\"hbar-row\"><span class=\"hbar-label\">{Escape(labels[i])}</span>");
sb.AppendLine($"<div class=\"hbar-track\"><div class=\"hbar-fill\" style=\"width:{pct}%;background:{color};\"></div></div>");
sb.AppendLine($"<span class=\"hbar-value\">{val:G}{unit}</span></div>");
}
sb.AppendLine("</div>");
}
else
{
sb.AppendLine("<div class=\"vbar-chart\">");
sb.AppendLine("<div class=\"vbar-bars\">");
for (int i = 0; i < labels.Count; i++)
{
sb.AppendLine("<div class=\"vbar-group\">");
foreach (var ds in datasets)
{
var val = i < ds.Values.Count ? ds.Values[i] : 0;
var pct = (int)(val / maxVal * 100);
sb.AppendLine($"<div class=\"vbar-bar\" style=\"height:{pct}%;background:{ds.Color};\" title=\"{val:G}{unit}\"></div>");
}
sb.AppendLine($"<div class=\"vbar-label\">{Escape(labels[i])}</div>");
sb.AppendLine("</div>");
}
sb.AppendLine("</div></div>");
}
return sb.ToString();
}
// ─── Stacked Bar ─────────────────────────────────────────────────────
private static string RenderStackedBar(List<string> labels, List<Dataset> datasets, string unit)
{
var sb = new StringBuilder();
sb.AppendLine("<div class=\"hbar-chart\">");
for (int i = 0; i < labels.Count; i++)
{
var total = datasets.Sum(ds => i < ds.Values.Count ? ds.Values[i] : 0);
sb.AppendLine($"<div class=\"hbar-row\"><span class=\"hbar-label\">{Escape(labels[i])}</span>");
sb.AppendLine("<div class=\"hbar-track\" style=\"display:flex;\">");
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($"<div style=\"width:{pct}%;background:{ds.Color};height:100%;\" title=\"{ds.Name}: {val:G}{unit}\"></div>");
}
sb.AppendLine($"</div><span class=\"hbar-value\">{total:G}{unit}</span></div>");
}
sb.AppendLine("</div>");
return sb.ToString();
}
// ─── Line / Area Chart (SVG) ─────────────────────────────────────────
private static string RenderLineChart(List<string> labels, List<Dataset> 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($"<svg viewBox=\"0 0 {w} {h}\" class=\"line-chart-svg\" preserveAspectRatio=\"xMidYMid meet\">");
// 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($"<line x1=\"{padL}\" y1=\"{y:F0}\" x2=\"{w - padR}\" y2=\"{y:F0}\" stroke=\"#E5E7EB\" stroke-width=\"1\"/>");
sb.AppendLine($"<text x=\"{padL - 8}\" y=\"{y + 4:F0}\" text-anchor=\"end\" fill=\"#6B7280\" font-size=\"11\">{val:G3}{unit}</text>");
}
// X축 라벨
for (int i = 0; i < n; i++)
{
var x = padL + (n > 1 ? chartW * i / (double)(n - 1) : chartW / 2.0);
sb.AppendLine($"<text x=\"{x:F0}\" y=\"{h - 8}\" text-anchor=\"middle\" fill=\"#6B7280\" font-size=\"11\">{Escape(labels[i])}</text>");
}
// 데이터셋
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($"<path d=\"{areaPath}\" fill=\"{ds.Color}\" opacity=\"0.15\"/>");
}
sb.AppendLine($"<path d=\"{pathData}\" fill=\"none\" stroke=\"{ds.Color}\" stroke-width=\"2.5\" stroke-linecap=\"round\" stroke-linejoin=\"round\"/>");
// 데이터 포인트
foreach (var (px, py) in points)
sb.AppendLine($"<circle cx=\"{px:F1}\" cy=\"{py:F1}\" r=\"4\" fill=\"{ds.Color}\" stroke=\"white\" stroke-width=\"2\"/>");
}
sb.AppendLine("</svg>");
return sb.ToString();
}
// ─── Pie / Donut Chart (SVG) ─────────────────────────────────────────
private static string RenderPieChart(List<string> labels, List<Dataset> datasets, bool isDonut)
{
var values = datasets.Count > 0 ? datasets[0].Values : new List<double>();
var total = values.Sum();
if (total <= 0) total = 1;
int cx = 150, cy = 150, r = 120;
var sb = new StringBuilder();
sb.AppendLine($"<div style=\"display:flex;align-items:center;gap:24px;flex-wrap:wrap;\">");
sb.AppendLine($"<svg viewBox=\"0 0 300 300\" width=\"260\" height=\"260\">");
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($"<path d=\"M{cx},{cy} L{x1:F1},{y1:F1} A{r},{r} 0 {largeArc},1 {x2:F1},{y2:F1} Z\" fill=\"{color}\"/>");
startAngle = endAngle;
}
if (isDonut)
sb.AppendLine($"<circle cx=\"{cx}\" cy=\"{cy}\" r=\"{r * 0.55}\" fill=\"white\"/>");
sb.AppendLine("</svg>");
// 범례
sb.AppendLine("<div class=\"pie-legend\">");
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($"<div class=\"pie-legend-item\"><span class=\"legend-dot\" style=\"background:{color}\"></span>{Escape(labels[i])} <span style=\"color:#6B7280;font-size:12px;\">({pct:F1}%)</span></div>");
}
sb.AppendLine("</div></div>");
return sb.ToString();
}
// ─── Progress Chart ──────────────────────────────────────────────────
private static string RenderProgressChart(List<string> labels, List<Dataset> datasets, string unit)
{
var values = datasets.Count > 0 ? datasets[0].Values : new List<double>();
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($"<div style=\"margin-bottom:12px;\"><div style=\"display:flex;justify-content:space-between;margin-bottom:4px;\"><span style=\"font-size:13px;font-weight:600;\">{Escape(labels[i])}</span><span style=\"font-size:13px;color:#6B7280;\">{values[i]:G}{unit}</span></div>");
sb.AppendLine($"<div class=\"progress\"><div class=\"progress-fill\" style=\"width:{pct}%;background:{color};\"></div></div></div>");
}
return sb.ToString();
}
// ─── Comparison Chart ────────────────────────────────────────────────
private static string RenderComparisonChart(List<string> labels, List<Dataset> datasets, string unit)
{
var sb = new StringBuilder();
sb.AppendLine("<table style=\"width:100%;border-collapse:collapse;\">");
sb.AppendLine("<tr><th style=\"text-align:left;padding:8px 12px;\">항목</th>");
foreach (var ds in datasets)
sb.AppendLine($"<th style=\"text-align:center;padding:8px 12px;color:{ds.Color};\">{Escape(ds.Name)}</th>");
sb.AppendLine("</tr>");
for (int i = 0; i < labels.Count; i++)
{
sb.Append($"<tr style=\"border-top:1px solid #E5E7EB;\"><td style=\"padding:8px 12px;font-weight:500;\">{Escape(labels[i])}</td>");
foreach (var ds in datasets)
{
var val = i < ds.Values.Count ? ds.Values[i] : 0;
sb.Append($"<td style=\"text-align:center;padding:8px 12px;\">{val:G}{unit}</td>");
}
sb.AppendLine("</tr>");
}
sb.AppendLine("</table>");
return sb.ToString();
}
// ─── Radar Chart (SVG) ───────────────────────────────────────────────
private static string RenderRadarChart(List<string> labels, List<Dataset> datasets)
{
int cx = 150, cy = 150, r = 110;
var n = labels.Count;
if (n < 3) return "<p>레이더 차트는 최소 3개 항목이 필요합니다.</p>";
var sb = new StringBuilder();
sb.AppendLine($"<svg viewBox=\"0 0 300 300\" width=\"300\" height=\"300\">");
// 그리드
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($"<polygon points=\"{points}\" fill=\"none\" stroke=\"#E5E7EB\" stroke-width=\"1\"/>");
}
// 축선 + 라벨
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($"<line x1=\"{cx}\" y1=\"{cy}\" x2=\"{x:F1}\" y2=\"{y:F1}\" stroke=\"#D1D5DB\" stroke-width=\"1\"/>");
var lx = cx + (r + 16) * Math.Cos(angle);
var ly = cy + (r + 16) * Math.Sin(angle);
sb.AppendLine($"<text x=\"{lx:F0}\" y=\"{ly + 4:F0}\" text-anchor=\"middle\" fill=\"#374151\" font-size=\"11\">{Escape(labels[i])}</text>");
}
// 데이터
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($"<polygon points=\"{points}\" fill=\"{ds.Color}\" fill-opacity=\"0.2\" stroke=\"{ds.Color}\" stroke-width=\"2\"/>");
}
sb.AppendLine("</svg>");
return sb.ToString();
}
// ─── Helpers ─────────────────────────────────────────────────────────
private static List<string> 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<Dataset> 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<Dataset>();
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<double>();
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("&", "&amp;").Replace("<", "&lt;").Replace(">", "&gt;").Replace("\"", "&quot;");
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<double> 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; }
";
}

View File

@@ -8,7 +8,7 @@ namespace AxCopilot.Services.Agent;
/// CSS/SVG 기반 차트를 HTML 파일로 생성하는 스킬.
/// bar, line, pie(donut), radar, area 차트를 지원하며 TemplateService 무드 스타일을 적용합니다.
/// </summary>
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<string> labels, List<Dataset> 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("<div class=\"hbar-chart\">");
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($"<div class=\"hbar-row\"><span class=\"hbar-label\">{Escape(labels[i])}</span>");
sb.AppendLine($"<div class=\"hbar-track\"><div class=\"hbar-fill\" style=\"width:{pct}%;background:{color};\"></div></div>");
sb.AppendLine($"<span class=\"hbar-value\">{val:G}{unit}</span></div>");
}
sb.AppendLine("</div>");
}
else
{
sb.AppendLine("<div class=\"vbar-chart\">");
sb.AppendLine("<div class=\"vbar-bars\">");
for (int i = 0; i < labels.Count; i++)
{
sb.AppendLine("<div class=\"vbar-group\">");
foreach (var ds in datasets)
{
var val = i < ds.Values.Count ? ds.Values[i] : 0;
var pct = (int)(val / maxVal * 100);
sb.AppendLine($"<div class=\"vbar-bar\" style=\"height:{pct}%;background:{ds.Color};\" title=\"{val:G}{unit}\"></div>");
}
sb.AppendLine($"<div class=\"vbar-label\">{Escape(labels[i])}</div>");
sb.AppendLine("</div>");
}
sb.AppendLine("</div></div>");
}
return sb.ToString();
}
// ─── Stacked Bar ─────────────────────────────────────────────────────
private static string RenderStackedBar(List<string> labels, List<Dataset> datasets, string unit)
{
var sb = new StringBuilder();
sb.AppendLine("<div class=\"hbar-chart\">");
for (int i = 0; i < labels.Count; i++)
{
var total = datasets.Sum(ds => i < ds.Values.Count ? ds.Values[i] : 0);
sb.AppendLine($"<div class=\"hbar-row\"><span class=\"hbar-label\">{Escape(labels[i])}</span>");
sb.AppendLine("<div class=\"hbar-track\" style=\"display:flex;\">");
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($"<div style=\"width:{pct}%;background:{ds.Color};height:100%;\" title=\"{ds.Name}: {val:G}{unit}\"></div>");
}
sb.AppendLine($"</div><span class=\"hbar-value\">{total:G}{unit}</span></div>");
}
sb.AppendLine("</div>");
return sb.ToString();
}
// ─── Line / Area Chart (SVG) ─────────────────────────────────────────
private static string RenderLineChart(List<string> labels, List<Dataset> 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($"<svg viewBox=\"0 0 {w} {h}\" class=\"line-chart-svg\" preserveAspectRatio=\"xMidYMid meet\">");
// 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($"<line x1=\"{padL}\" y1=\"{y:F0}\" x2=\"{w - padR}\" y2=\"{y:F0}\" stroke=\"#E5E7EB\" stroke-width=\"1\"/>");
sb.AppendLine($"<text x=\"{padL - 8}\" y=\"{y + 4:F0}\" text-anchor=\"end\" fill=\"#6B7280\" font-size=\"11\">{val:G3}{unit}</text>");
}
// X축 라벨
for (int i = 0; i < n; i++)
{
var x = padL + (n > 1 ? chartW * i / (double)(n - 1) : chartW / 2.0);
sb.AppendLine($"<text x=\"{x:F0}\" y=\"{h - 8}\" text-anchor=\"middle\" fill=\"#6B7280\" font-size=\"11\">{Escape(labels[i])}</text>");
}
// 데이터셋
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($"<path d=\"{areaPath}\" fill=\"{ds.Color}\" opacity=\"0.15\"/>");
}
sb.AppendLine($"<path d=\"{pathData}\" fill=\"none\" stroke=\"{ds.Color}\" stroke-width=\"2.5\" stroke-linecap=\"round\" stroke-linejoin=\"round\"/>");
// 데이터 포인트
foreach (var (px, py) in points)
sb.AppendLine($"<circle cx=\"{px:F1}\" cy=\"{py:F1}\" r=\"4\" fill=\"{ds.Color}\" stroke=\"white\" stroke-width=\"2\"/>");
}
sb.AppendLine("</svg>");
return sb.ToString();
}
// ─── Pie / Donut Chart (SVG) ─────────────────────────────────────────
private static string RenderPieChart(List<string> labels, List<Dataset> datasets, bool isDonut)
{
var values = datasets.Count > 0 ? datasets[0].Values : new List<double>();
var total = values.Sum();
if (total <= 0) total = 1;
int cx = 150, cy = 150, r = 120;
var sb = new StringBuilder();
sb.AppendLine($"<div style=\"display:flex;align-items:center;gap:24px;flex-wrap:wrap;\">");
sb.AppendLine($"<svg viewBox=\"0 0 300 300\" width=\"260\" height=\"260\">");
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($"<path d=\"M{cx},{cy} L{x1:F1},{y1:F1} A{r},{r} 0 {largeArc},1 {x2:F1},{y2:F1} Z\" fill=\"{color}\"/>");
startAngle = endAngle;
}
if (isDonut)
sb.AppendLine($"<circle cx=\"{cx}\" cy=\"{cy}\" r=\"{r * 0.55}\" fill=\"white\"/>");
sb.AppendLine("</svg>");
// 범례
sb.AppendLine("<div class=\"pie-legend\">");
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($"<div class=\"pie-legend-item\"><span class=\"legend-dot\" style=\"background:{color}\"></span>{Escape(labels[i])} <span style=\"color:#6B7280;font-size:12px;\">({pct:F1}%)</span></div>");
}
sb.AppendLine("</div></div>");
return sb.ToString();
}
// ─── Progress Chart ──────────────────────────────────────────────────
private static string RenderProgressChart(List<string> labels, List<Dataset> datasets, string unit)
{
var values = datasets.Count > 0 ? datasets[0].Values : new List<double>();
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($"<div style=\"margin-bottom:12px;\"><div style=\"display:flex;justify-content:space-between;margin-bottom:4px;\"><span style=\"font-size:13px;font-weight:600;\">{Escape(labels[i])}</span><span style=\"font-size:13px;color:#6B7280;\">{values[i]:G}{unit}</span></div>");
sb.AppendLine($"<div class=\"progress\"><div class=\"progress-fill\" style=\"width:{pct}%;background:{color};\"></div></div></div>");
}
return sb.ToString();
}
// ─── Comparison Chart ────────────────────────────────────────────────
private static string RenderComparisonChart(List<string> labels, List<Dataset> datasets, string unit)
{
var sb = new StringBuilder();
sb.AppendLine("<table style=\"width:100%;border-collapse:collapse;\">");
sb.AppendLine("<tr><th style=\"text-align:left;padding:8px 12px;\">항목</th>");
foreach (var ds in datasets)
sb.AppendLine($"<th style=\"text-align:center;padding:8px 12px;color:{ds.Color};\">{Escape(ds.Name)}</th>");
sb.AppendLine("</tr>");
for (int i = 0; i < labels.Count; i++)
{
sb.Append($"<tr style=\"border-top:1px solid #E5E7EB;\"><td style=\"padding:8px 12px;font-weight:500;\">{Escape(labels[i])}</td>");
foreach (var ds in datasets)
{
var val = i < ds.Values.Count ? ds.Values[i] : 0;
sb.Append($"<td style=\"text-align:center;padding:8px 12px;\">{val:G}{unit}</td>");
}
sb.AppendLine("</tr>");
}
sb.AppendLine("</table>");
return sb.ToString();
}
// ─── Radar Chart (SVG) ───────────────────────────────────────────────
private static string RenderRadarChart(List<string> labels, List<Dataset> datasets)
{
int cx = 150, cy = 150, r = 110;
var n = labels.Count;
if (n < 3) return "<p>레이더 차트는 최소 3개 항목이 필요합니다.</p>";
var sb = new StringBuilder();
sb.AppendLine($"<svg viewBox=\"0 0 300 300\" width=\"300\" height=\"300\">");
// 그리드
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($"<polygon points=\"{points}\" fill=\"none\" stroke=\"#E5E7EB\" stroke-width=\"1\"/>");
}
// 축선 + 라벨
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($"<line x1=\"{cx}\" y1=\"{cy}\" x2=\"{x:F1}\" y2=\"{y:F1}\" stroke=\"#D1D5DB\" stroke-width=\"1\"/>");
var lx = cx + (r + 16) * Math.Cos(angle);
var ly = cy + (r + 16) * Math.Sin(angle);
sb.AppendLine($"<text x=\"{lx:F0}\" y=\"{ly + 4:F0}\" text-anchor=\"middle\" fill=\"#374151\" font-size=\"11\">{Escape(labels[i])}</text>");
}
// 데이터
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($"<polygon points=\"{points}\" fill=\"{ds.Color}\" fill-opacity=\"0.2\" stroke=\"{ds.Color}\" stroke-width=\"2\"/>");
}
sb.AppendLine("</svg>");
return sb.ToString();
}
// ─── Helpers ─────────────────────────────────────────────────────────
private static List<string> 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<Dataset> 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<Dataset>();
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<double>();
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("&", "&amp;").Replace("<", "&lt;").Replace(">", "&gt;").Replace("\"", "&quot;");
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<double> 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; }
";
}

View File

@@ -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;
}
/// <summary>**bold**, *italic*, `code` 인라인 서식을 Run으로 변환</summary>
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<HeaderPart>();
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<SectionProperties>() ?? body.AppendChild(new SectionProperties());
secProps.Append(new HeaderReference
{
Type = HeaderFooterValues.Default,
Id = mainPart.GetIdOfPart(headerPart)
});
}
// 바닥글
if (!string.IsNullOrEmpty(footerText) || showPageNumbers)
{
var footerPart = mainPart.AddNewPart<FooterPart>();
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<SectionProperties>() ?? 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;
}
}

View File

@@ -10,7 +10,7 @@ namespace AxCopilot.Services.Agent;
/// Word (.docx) 문서를 생성하는 내장 스킬.
/// 테이블, 텍스트 스타일링, 머리글/바닥글, 페이지 나누기 등 고급 기능을 지원합니다.
/// </summary>
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;
}
/// <summary>**bold**, *italic*, `code` 인라인 서식을 Run으로 변환</summary>
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<HeaderPart>();
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<SectionProperties>() ?? body.AppendChild(new SectionProperties());
secProps.Append(new HeaderReference
{
Type = HeaderFooterValues.Default,
Id = mainPart.GetIdOfPart(headerPart)
});
}
// 바닥글
if (!string.IsNullOrEmpty(footerText) || showPageNumbers)
{
var footerPart = mainPart.AddNewPart<FooterPart>();
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<SectionProperties>() ?? 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;
}
}

View File

@@ -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<string> SendGeminiAsync(List<ChatMessage> 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<string> StreamGeminiAsync(
List<ChatMessage> 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<ChatMessage> messages)
{
var llm = _settings.Settings.Llm;
var contents = new List<object>();
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<object> { 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<string> SendClaudeAsync(List<ChatMessage> 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<string> StreamClaudeAsync(
List<ChatMessage> 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<ChatMessage> messages, bool stream)
{
var llm = _settings.Settings.Llm;
var msgs = new List<object>();
foreach (var m in messages)
{
if (m.Role == "system") continue;
if (m.Images?.Count > 0)
{
// Claude Vision: content를 배열로 변환 (이미지 + 텍스트)
var contentParts = new List<object>();
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
};
}
}

View File

@@ -250,267 +250,4 @@ public partial class LlmService
};
}
// ═══════════════════════════════════════════════════════════════════════
// Gemini
// ═══════════════════════════════════════════════════════════════════════
private async Task<string> SendGeminiAsync(List<ChatMessage> 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<string> StreamGeminiAsync(
List<ChatMessage> 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<ChatMessage> messages)
{
var llm = _settings.Settings.Llm;
var contents = new List<object>();
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<object> { 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<string> SendClaudeAsync(List<ChatMessage> 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<string> StreamClaudeAsync(
List<ChatMessage> 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<ChatMessage> messages, bool stream)
{
var llm = _settings.Settings.Llm;
var msgs = new List<object>();
foreach (var m in messages)
{
if (m.Role == "system") continue;
if (m.Images?.Count > 0)
{
// Claude Vision: content를 배열로 변환 (이미지 + 텍스트)
var contentParts = new List<object>();
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
};
}
}

View File

@@ -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();
}
}

View File

@@ -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();
}
}