[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>
|
/// </summary>
|
||||||
public class ScreenCaptureHandler : IActionHandler
|
public partial class ScreenCaptureHandler : IActionHandler
|
||||||
{
|
{
|
||||||
private readonly AxCopilot.Services.SettingsService _settings;
|
private readonly AxCopilot.Services.SettingsService _settings;
|
||||||
|
|
||||||
@@ -237,401 +237,4 @@ public class ScreenCaptureHandler : IActionHandler
|
|||||||
NotificationService.Notify("창 캡처 완료", "클립보드에 복사되었습니다");
|
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 uptime → 시스템 가동 시간
|
||||||
/// info volume → 볼륨 수준
|
/// info volume → 볼륨 수준
|
||||||
/// </summary>
|
/// </summary>
|
||||||
public class SystemInfoHandler : IActionHandler
|
public partial class SystemInfoHandler : IActionHandler
|
||||||
{
|
{
|
||||||
public string? Prefix => "info";
|
public string? Prefix => "info";
|
||||||
|
|
||||||
@@ -348,162 +348,4 @@ public class SystemInfoHandler : IActionHandler
|
|||||||
// "shell" → 셸 명령 실행 (Payload = 명령)
|
// "shell" → 셸 명령 실행 (Payload = 명령)
|
||||||
// "ms_settings" → ms-settings: URI 열기 (Payload = URI)
|
// "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 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 파일로 생성하는 스킬.
|
/// CSS/SVG 기반 차트를 HTML 파일로 생성하는 스킬.
|
||||||
/// bar, line, pie(donut), radar, area 차트를 지원하며 TemplateService 무드 스타일을 적용합니다.
|
/// bar, line, pie(donut), radar, area 차트를 지원하며 TemplateService 무드 스타일을 적용합니다.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
public class ChartSkill : IAgentTool
|
public partial class ChartSkill : IAgentTool
|
||||||
{
|
{
|
||||||
public string Name => "chart_create";
|
public string Name => "chart_create";
|
||||||
public string Description =>
|
public string Description =>
|
||||||
@@ -171,367 +171,4 @@ public class ChartSkill : IAgentTool
|
|||||||
return sb.ToString();
|
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) 문서를 생성하는 내장 스킬.
|
/// Word (.docx) 문서를 생성하는 내장 스킬.
|
||||||
/// 테이블, 텍스트 스타일링, 머리글/바닥글, 페이지 나누기 등 고급 기능을 지원합니다.
|
/// 테이블, 텍스트 스타일링, 머리글/바닥글, 페이지 나누기 등 고급 기능을 지원합니다.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
public class DocxSkill : IAgentTool
|
public partial class DocxSkill : IAgentTool
|
||||||
{
|
{
|
||||||
public string Name => "docx_create";
|
public string Name => "docx_create";
|
||||||
public string Description => "Create a rich Word (.docx) document. " +
|
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;
|
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