[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:
411
src/AxCopilot/Handlers/ScreenCaptureHandler.Helpers.cs
Normal file
411
src/AxCopilot/Handlers/ScreenCaptureHandler.Helpers.cs
Normal 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; }
|
||||
}
|
||||
}
|
||||
@@ -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; }
|
||||
}
|
||||
}
|
||||
|
||||
168
src/AxCopilot/Handlers/SystemInfoHandler.Helpers.cs
Normal file
168
src/AxCopilot/Handlers/SystemInfoHandler.Helpers.cs
Normal 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);
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
262
src/AxCopilot/Models/AppSettings.Models.cs
Normal file
262
src/AxCopilot/Models/AppSettings.Models.cs
Normal 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" };
|
||||
}
|
||||
@@ -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" };
|
||||
}
|
||||
|
||||
373
src/AxCopilot/Services/Agent/ChartSkill.Renderers.cs
Normal file
373
src/AxCopilot/Services/Agent/ChartSkill.Renderers.cs
Normal 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("&", "&").Replace("<", "<").Replace(">", ">").Replace("\"", """);
|
||||
|
||||
private static string FormatSize(long bytes) =>
|
||||
bytes switch { < 1024 => $"{bytes}B", < 1048576 => $"{bytes / 1024.0:F1}KB", _ => $"{bytes / 1048576.0:F1}MB" };
|
||||
|
||||
private sealed class Dataset
|
||||
{
|
||||
public string Name { get; init; } = "";
|
||||
public List<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; }
|
||||
";
|
||||
}
|
||||
@@ -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("&", "&").Replace("<", "<").Replace(">", ">").Replace("\"", """);
|
||||
|
||||
private static string FormatSize(long bytes) =>
|
||||
bytes switch { < 1024 => $"{bytes}B", < 1048576 => $"{bytes / 1024.0:F1}KB", _ => $"{bytes / 1048576.0:F1}MB" };
|
||||
|
||||
private sealed class Dataset
|
||||
{
|
||||
public string Name { get; init; } = "";
|
||||
public List<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; }
|
||||
";
|
||||
}
|
||||
|
||||
397
src/AxCopilot/Services/Agent/DocxSkill.Builders.cs
Normal file
397
src/AxCopilot/Services/Agent/DocxSkill.Builders.cs
Normal 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;
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
277
src/AxCopilot/Services/LlmService.GeminiClaude.cs
Normal file
277
src/AxCopilot/Services/LlmService.GeminiClaude.cs
Normal 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
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
238
src/AxCopilot/Views/SkillEditorWindow.PreviewSave.cs
Normal file
238
src/AxCopilot/Views/SkillEditorWindow.PreviewSave.cs
Normal 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();
|
||||
}
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user