[Phase51] 대규모 파일 분리 — 9개 파일 → 19개 파일

SettingsWindow.Tools:
- SettingsWindow.Tools.cs: 605 → 238줄 (BuildToolRegistryPanel 유지)
- SettingsWindow.SkillListPanel.cs (신규): BuildSkillListSection, CreateSkillGroupCard (295줄)

LauncherWindow.Keyboard:
- LauncherWindow.Keyboard.cs: 593 → 454줄
- LauncherWindow.ShortcutHelp.cs (신규): ShowShortcutHelp, ShowToast, TryHandleSpecialAction (139줄)

CalculatorHandler:
- CalculatorHandler.cs: 566 → ~240줄 (CalculatorHandler 클래스만 유지)
- UnitConverter.cs (신규): 단위변환 클래스 독립 파일 (152줄)
- MathEvaluator.cs (신규): 수식파서 클래스 독립 파일 (183줄)

EmojiHandler:
- EmojiHandler.cs: 553 → 70줄 (핸들러 메서드만 유지)
- EmojiHandler.Data.cs (신규): 이모지 데이터베이스 배열 (~490줄)

DocumentPlannerTool:
- DocumentPlannerTool.cs: 598 → 324줄 (Execute 메서드 유지)
- DocumentPlannerTool.Generators.cs (신규): GenerateHtml/Docx/Markdown, BuildSections 등 (274줄)

DocumentReaderTool:
- DocumentReaderTool.cs: 571 → 338줄 (ExecuteAsync + PDF 메서드 유지)
- DocumentReaderTool.Formats.cs (신규): BibTeX/RIS/DOCX/XLSX/Text/Helpers (233줄)

CodeIndexService:
- CodeIndexService.cs: 588 → 285줄 (DB/인덱싱 유지)
- CodeIndexService.Search.cs (신규): Search, TF-IDF, Tokenize, Dispose (199줄)

ClipboardHistoryService:
- ClipboardHistoryService.cs: 575 → 458줄
- ClipboardHistoryService.ImageCache.cs (신규): 이미지 캐시 유틸리티 (120줄)

IndexService:
- IndexService.cs: 568 → 412줄
- IndexService.Helpers.cs (신규): 경로 탐색 헬퍼 + 검색 캐시 (163줄)

빌드: 경고 0, 오류 0

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-04-03 21:34:51 +09:00
parent 5bed67f64e
commit 55befebf34
19 changed files with 2396 additions and 2288 deletions

View File

@@ -111,335 +111,6 @@ public class CalculatorHandler : IActionHandler
}
}
// ─── 단위 변환 ─────────────────────────────────────────────────────────────────
/// <summary>
/// "100km in miles", "32f in c", "5lb to kg" 형식의 단위 변환.
/// </summary>
internal static class UnitConverter
{
// 패턴: <숫자> <단위> in|to <단위>
private static readonly Regex Pattern = new(
@"^(-?\d+(?:\.\d+)?)\s*([a-z°/²³µ]+)\s+(?:in|to)\s+([a-z°/²³µ]+)$",
RegexOptions.IgnoreCase | RegexOptions.Compiled);
public static bool TryConvert(string input, out string? result)
{
result = null;
var m = Pattern.Match(input.Trim());
if (!m.Success) return false;
if (!double.TryParse(m.Groups[1].Value,
System.Globalization.NumberStyles.Float,
System.Globalization.CultureInfo.InvariantCulture,
out var value))
return false;
var from = m.Groups[2].Value.ToLowerInvariant();
var to = m.Groups[3].Value.ToLowerInvariant();
// 온도는 비선형 → 별도 처리
if (TryConvertTemperature(value, from, to, out var tResult))
{
result = $"{FormatNum(tResult)} {TemperatureLabel(to)}";
return true;
}
// 나머지 범주(선형 변환)
foreach (var table in _tables)
{
if (table.TryGetValue(from, out var fromFactor) &&
table.TryGetValue(to, out var toFactor))
{
var converted = value * fromFactor / toFactor;
result = $"{FormatNum(converted)} {to}";
return true;
}
}
return false;
}
// ─── 온도 ────────────────────────────────────────────────────────────────
private static bool TryConvertTemperature(double v, string from, string to, out double r)
{
r = 0;
// 섭씨 표준화
double celsius;
switch (from)
{
case "c": case "°c": case "celsius": celsius = v; break;
case "f": case "°f": case "fahrenheit": celsius = (v - 32) * 5 / 9; break;
case "k": case "kelvin": celsius = v - 273.15; break;
default: return false;
}
switch (to)
{
case "c": case "°c": case "celsius": r = celsius; break;
case "f": case "°f": case "fahrenheit": r = celsius * 9 / 5 + 32; break;
case "k": case "kelvin": r = celsius + 273.15; break;
default: return false;
}
return true;
}
private static string TemperatureLabel(string unit) => unit switch
{
"c" or "°c" or "celsius" => "°C",
"f" or "°f" or "fahrenheit" => "°F",
"k" or "kelvin" => "K",
_ => unit
};
// ─── 선형 변환 테이블 (기준 단위 = 1) ────────────────────────────────────
// 길이 (기준: m)
private static readonly Dictionary<string, double> _length = new()
{
["km"] = 1000, ["m"] = 1, ["cm"] = 0.01, ["mm"] = 0.001,
["mi"] = 1609.344, ["mile"] = 1609.344, ["miles"] = 1609.344,
["ft"] = 0.3048, ["feet"] = 0.3048, ["foot"] = 0.3048,
["in"] = 0.0254, ["inch"] = 0.0254, ["inches"] = 0.0254,
["yd"] = 0.9144, ["yard"] = 0.9144, ["yards"] = 0.9144,
["nm"] = 1e-9,
};
// 무게 (기준: kg)
private static readonly Dictionary<string, double> _weight = new()
{
["t"] = 1000, ["ton"] = 1000, ["tonnes"] = 1000,
["kg"] = 1, ["g"] = 0.001, ["mg"] = 1e-6,
["lb"] = 0.453592, ["lbs"] = 0.453592, ["pound"] = 0.453592, ["pounds"] = 0.453592,
["oz"] = 0.0283495, ["ounce"] = 0.0283495, ["ounces"] = 0.0283495,
};
// 속도 (기준: m/s)
private static readonly Dictionary<string, double> _speed = new()
{
["m/s"] = 1, ["mps"] = 1,
["km/h"] = 1.0 / 3.6, ["kmh"] = 1.0 / 3.6, ["kph"] = 1.0 / 3.6,
["mph"] = 0.44704,
["kn"] = 0.514444, ["knot"] = 0.514444, ["knots"] = 0.514444,
};
// 데이터 (기준: byte)
private static readonly Dictionary<string, double> _data = new()
{
["b"] = 1, ["byte"] = 1, ["bytes"] = 1,
["kb"] = 1024, ["kib"] = 1024,
["mb"] = 1024 * 1024, ["mib"] = 1024 * 1024,
["gb"] = 1024.0 * 1024 * 1024, ["gib"] = 1024.0 * 1024 * 1024,
["tb"] = 1024.0 * 1024 * 1024 * 1024, ["tib"] = 1024.0 * 1024 * 1024 * 1024,
["pb"] = 1024.0 * 1024 * 1024 * 1024 * 1024,
};
// 넓이 (기준: m²)
private static readonly Dictionary<string, double> _area = new()
{
["m²"] = 1, ["m2"] = 1,
["km²"] = 1e6, ["km2"] = 1e6,
["cm²"] = 1e-4, ["cm2"] = 1e-4,
["ha"] = 10000,
["acre"] = 4046.86, ["acres"] = 4046.86,
["ft²"] = 0.092903, ["ft2"] = 0.092903,
};
private static readonly List<Dictionary<string, double>> _tables = new()
{ _length, _weight, _speed, _data, _area };
private static string FormatNum(double v)
{
if (v == Math.Floor(v) && Math.Abs(v) < 1e12)
return ((long)v).ToString("N0", System.Globalization.CultureInfo.CurrentCulture);
return v.ToString("G6", System.Globalization.CultureInfo.InvariantCulture);
}
}
// ─── 수식 파서 ─────────────────────────────────────────────────────────────────
/// <summary>
/// 재귀 하강 파서 기반 수학 수식 평가기.
/// 지원: +, -, *, /, %, ^ (거듭제곱), 괄호, 단항 음수,
/// sqrt, abs, ceil, floor, round, sin, cos, tan (도 단위),
/// log (밑 10), ln (자연로그), pi, e
/// </summary>
internal static class MathEvaluator
{
public static double Evaluate(string expr)
{
var evaluator = new Evaluator(
expr.Replace(" ", "")
.Replace("×", "*")
.Replace("÷", "/")
.Replace("", ",")
.ToLowerInvariant());
return evaluator.Parse();
}
private class Evaluator
{
private readonly string _s;
private int _i;
public Evaluator(string s) { _s = s; _i = 0; }
public double Parse()
{
var result = ParseExpr();
if (_i < _s.Length)
throw new InvalidOperationException($"예기치 않은 문자: '{_s[_i]}'");
return result;
}
// 덧셈 / 뺄셈
private double ParseExpr()
{
var left = ParseTerm();
while (_i < _s.Length && (_s[_i] == '+' || _s[_i] == '-'))
{
var op = _s[_i++];
var right = ParseTerm();
left = op == '+' ? left + right : left - right;
}
return left;
}
// 곱셈 / 나눗셈 / 나머지
private double ParseTerm()
{
var left = ParsePower();
while (_i < _s.Length && (_s[_i] == '*' || _s[_i] == '/' || _s[_i] == '%'))
{
var op = _s[_i++];
var right = ParsePower();
left = op == '*' ? left * right
: op == '/' ? left / right
: left % right;
}
return left;
}
// 거듭제곱 (오른쪽 결합)
private double ParsePower()
{
var b = ParseUnary();
if (_i < _s.Length && _s[_i] == '^')
{
_i++;
var exp = ParseUnary();
return Math.Pow(b, exp);
}
return b;
}
// 단항 부호
private double ParseUnary()
{
if (_i < _s.Length && _s[_i] == '-') { _i++; return -ParsePrimary(); }
if (_i < _s.Length && _s[_i] == '+') { _i++; return ParsePrimary(); }
return ParsePrimary();
}
// 리터럴 / 괄호 / 함수 호출
private double ParsePrimary()
{
if (_i >= _s.Length)
throw new InvalidOperationException("수식이 불완전합니다.");
// 16진수 리터럴 0x...
if (_i + 1 < _s.Length && _s[_i] == '0' && _s[_i + 1] == 'x')
{
_i += 2;
var hexStart = _i;
while (_i < _s.Length && "0123456789abcdef".Contains(_s[_i])) _i++;
return Convert.ToInt64(_s[hexStart.._i], 16);
}
// 숫자
if (char.IsDigit(_s[_i]) || _s[_i] == '.')
{
var start = _i;
while (_i < _s.Length && (char.IsDigit(_s[_i]) || _s[_i] == '.')) _i++;
// 과학적 표기: 1.5e3
if (_i < _s.Length && _s[_i] == 'e')
{
_i++;
if (_i < _s.Length && (_s[_i] == '+' || _s[_i] == '-')) _i++;
while (_i < _s.Length && char.IsDigit(_s[_i])) _i++;
}
return double.Parse(_s[start.._i],
System.Globalization.NumberStyles.Float,
System.Globalization.CultureInfo.InvariantCulture);
}
// 괄호
if (_s[_i] == '(')
{
_i++;
var val = ParseExpr();
if (_i < _s.Length && _s[_i] == ')') _i++;
return val;
}
// 식별자 (상수 또는 함수)
if (char.IsLetter(_s[_i]))
{
var start = _i;
while (_i < _s.Length && (char.IsLetterOrDigit(_s[_i]) || _s[_i] == '_')) _i++;
var name = _s[start.._i];
// 상수
if (name == "pi") return Math.PI;
if (name == "e") return Math.E;
if (name == "inf") return double.PositiveInfinity;
// 함수 호출
if (_i < _s.Length && _s[_i] == '(')
{
_i++; // (
var arg = ParseExpr();
// 두 번째 인자 (pow, log2 등)
double? arg2 = null;
if (_i < _s.Length && _s[_i] == ',')
{
_i++;
arg2 = ParseExpr();
}
if (_i < _s.Length && _s[_i] == ')') _i++;
return name switch
{
"sqrt" => Math.Sqrt(arg),
"abs" => Math.Abs(arg),
"ceil" => Math.Ceiling(arg),
"floor" => Math.Floor(arg),
"round" => arg2.HasValue ? Math.Round(arg, (int)arg2.Value) : Math.Round(arg),
"sin" => Math.Sin(arg * Math.PI / 180), // 도 단위
"cos" => Math.Cos(arg * Math.PI / 180),
"tan" => Math.Tan(arg * Math.PI / 180),
"asin" => Math.Asin(arg) * 180 / Math.PI,
"acos" => Math.Acos(arg) * 180 / Math.PI,
"atan" => Math.Atan(arg) * 180 / Math.PI,
"log" => arg2.HasValue ? Math.Log(arg, arg2.Value) : Math.Log10(arg),
"log2" => Math.Log2(arg),
"ln" => Math.Log(arg),
"exp" => Math.Exp(arg),
"pow" => arg2.HasValue ? Math.Pow(arg, arg2.Value) : throw new InvalidOperationException("pow(x,y) 형식으로 사용하세요."),
"min" => arg2.HasValue ? Math.Min(arg, arg2.Value) : arg,
"max" => arg2.HasValue ? Math.Max(arg, arg2.Value) : arg,
_ => throw new InvalidOperationException($"알 수 없는 함수: {name}()")
};
}
throw new InvalidOperationException($"알 수 없는 식별자: {name}");
}
throw new InvalidOperationException($"예기치 않은 문자: '{_s[_i]}'");
}
}
}
// ─── 통화 변환 ──────────────────────────────────────────────────────────────────
/// <summary>

View File

@@ -0,0 +1,488 @@
namespace AxCopilot.Handlers;
public partial class EmojiHandler
{
// ─── 이모지 데이터베이스 (이모지, 이름(한/영), 태그) ──────────────────────
private static readonly (string Emoji, string Name, string Tags)[] _emojis =
{
// 표정 / 감정
("😀", "크게 웃는 얼굴", "smile happy grin 웃음 행복"),
("😃", "웃는 얼굴", "smile happy joy 웃음"),
("😄", "눈 웃음", "smile laugh 웃음 기쁨"),
("😁", "히죽 웃음", "grin beam 씩 웃다"),
("😆", "크게 웃음", "laughing 폭소"),
("😅", "식은땀 웃음", "sweat smile 안도"),
("🤣", "바닥 구르며 웃음", "rofl lol 빵 웃음"),
("😂", "눈물 나게 웃음", "joy tears laugh 폭소"),
("🙂", "살짝 웃음", "slightly smiling 미소"),
("🙃", "거꾸로 웃음", "upside down 뒤집힌"),
("😉", "윙크", "wink 윙크"),
("😊", "볼 빨개진 웃음", "blush 부끄러움 미소"),
("😇", "천사", "angel halo 천사 선량"),
("🥰", "사랑스러운 얼굴", "love hearts 사랑 하트"),
("😍", "하트 눈", "heart eyes 사랑 반함"),
("🤩", "별 눈", "star struck 감동 황홀"),
("😘", "뽀뽀", "kiss blow 키스 뽀뽀"),
("😗", "오므린 입", "kiss whistle 키스"),
("😚", "눈 감고 뽀뽀", "kiss 키스"),
("😙", "볼 뽀뽀", "kiss 키스"),
("😋", "맛있다", "yum delicious 맛 음식"),
("😛", "혀 내밀기", "tongue out 혀 놀림"),
("😜", "윙크하며 혀", "wink tongue 장난"),
("🤪", "미친 표정", "zany crazy 정신없음"),
("😝", "눈 감고 혀", "tongue 혀"),
("🤑", "돈 눈", "money face 돈 부자"),
("🤗", "포옹", "hugging hug 안아줘 포옹"),
("🤭", "입 가리고", "hand over mouth 헉 깜짝"),
("🤫", "쉿", "shushing quiet 조용 쉿"),
("🤔", "생각 중", "thinking 고민 생각"),
("🤐", "입 막음", "zipper mouth 비밀"),
("🤨", "의심", "raised eyebrow 의심 의아"),
("😐", "무표정", "neutral 무감각 무표정"),
("😑", "표정 없음", "expressionless 냉담"),
("😶", "입 없는 얼굴", "no mouth 침묵"),
("😏", "비웃음", "smirk 비웃 냉소"),
("😒", "불만", "unamused 불만 짜증"),
("🙄", "눈 굴리기", "eye roll 어이없음"),
("😬", "이 드러냄", "grimace 으 민망"),
("🤥", "거짓말", "lying pinocchio 거짓말"),
("😌", "안도/평온", "relieved 안도 평온"),
("😔", "슬픔", "pensive sad 슬픔 우울"),
("😪", "졸림", "sleepy 졸음"),
("🤤", "침 흘림", "drooling 군침 식욕"),
("😴", "잠", "sleeping sleep 수면 잠"),
("😷", "마스크", "mask sick 마스크 아픔"),
("🤒", "열 나는", "sick fever 열 아픔"),
("🤕", "머리 붕대", "injured hurt 부상"),
("🤢", "구역질", "nauseated sick 구역 메스꺼움"),
("🤮", "토하는", "vomit 구토"),
("🤧", "재채기", "sneezing sick 재채기 감기"),
("🥵", "더운", "hot overheated 더움 열"),
("🥶", "추운", "cold freezing 추움 냉기"),
("🥴", "어지러운", "woozy 어지럼 취함"),
("😵", "어질어질", "dizzy 어지럼 충격"),
("🤯", "머리 폭발", "exploding head 충격 대박"),
("🤠", "카우보이", "cowboy hat 카우보이"),
("🥸", "변장", "disguise 변장 선글라스"),
("😎", "쿨한", "cool sunglasses 선글라스 쿨"),
("🤓", "공부벌레", "nerd glasses 공부 안경"),
("🧐", "모노클", "monocle curious 고상 탐정"),
("😕", "당황", "confused 당황 모호"),
("😟", "걱정", "worried concern 걱정"),
("🙁", "살짝 찡그림", "frown 슬픔"),
("☹️", "찡그린 얼굴", "frown sad 슬픔"),
("😮", "입 벌림", "open mouth surprised 놀람"),
("😯", "놀람", "hushed surprised 깜짝"),
("😲", "충격", "astonished 충격 놀람"),
("😳", "얼굴 빨개짐", "flushed embarrassed 부끄럼 당황"),
("🥺", "애원", "pleading eyes 부탁 눈빛"),
("😦", "찡그리며 벌린 입", "frowning 불안"),
("😧", "고통", "anguished 고통"),
("😨", "무서움", "fearful scared 무서움 공포"),
("😰", "식은땀", "anxious sweat 불안 걱정"),
("😥", "눈물 조금", "sad disappointed 실망 눈물"),
("😢", "울음", "cry sad 슬픔 눈물"),
("😭", "엉엉 울음", "loudly crying sob 통곡"),
("😱", "공포에 질림", "screaming fear 비명 공포"),
("😖", "혼란", "confounded 혼란"),
("😣", "힘듦", "persevering 고생"),
("😞", "실망", "disappointed 실망"),
("😓", "땀", "downcast sweat 땀 힘듦"),
("😩", "피곤", "weary tired 지침 피곤"),
("😫", "극도로 지침", "tired exhausted 탈진"),
("🥱", "하품", "yawning bored 하품 지루함"),
("😤", "콧김", "triumph snort 분노 콧김"),
("😡", "화남", "angry mad 화남 분노"),
("😠", "성남", "angry 화 성남"),
("🤬", "욕", "cursing swearing 욕 분노"),
("😈", "나쁜 미소", "smiling devil 악마 장난"),
("👿", "화난 악마", "angry devil 악마"),
("💀", "해골", "skull death 해골 죽음"),
("☠️", "해골 십자", "skull crossbones 독"),
("💩", "응가", "poop 똥 응가"),
("🤡", "피에로", "clown 광대"),
("👹", "도깨비", "ogre 도깨비 귀신"),
("👺", "텐구", "goblin 텐구"),
("👻", "유령", "ghost 유령 귀신"),
("👾", "우주인", "alien monster 외계인 게임"),
("🤖", "로봇", "robot 로봇"),
// 손 / 몸
("👋", "손 흔들기", "wave waving hi bye 안녕"),
("🤚", "손 뒤", "raised back hand 손"),
("🖐️", "손바닥", "hand palm 다섯 손가락"),
("✋", "손 들기", "raised hand 손 들기 멈춤"),
("🖖", "스팍 손인사", "vulcan salute 스타트렉"),
("👌", "오케이", "ok perfect 오케이 좋아"),
("🤌", "손가락 모아", "pinched fingers 이탈리아"),
("✌️", "브이", "victory peace v 브이 평화"),
("🤞", "행운 손가락", "crossed fingers lucky 행운 기도"),
("🤟", "아이 러브 유", "love you 사랑해"),
("🤘", "록 손", "rock on metal 록"),
("🤙", "전화해", "call me shaka 전화 샤카"),
("👈", "왼쪽 가리킴", "backhand left 왼쪽"),
("👉", "오른쪽 가리킴", "backhand right 오른쪽"),
("👆", "위 가리킴", "backhand up 위"),
("🖕", "욕", "middle finger 욕"),
("👇", "아래 가리킴", "backhand down 아래"),
("☝️", "검지 들기", "index pointing up 하나 포인트"),
("👍", "좋아요", "thumbs up like good 좋아 최고"),
("👎", "싫어요", "thumbs down dislike 싫어 별로"),
("✊", "주먹", "fist punch 주먹"),
("👊", "주먹 치기", "punch fist 주먹"),
("🤛", "왼 주먹", "left fist 주먹"),
("🤜", "오른 주먹", "right fist 주먹"),
("👏", "박수", "clapping applause 박수 응원"),
("🙌", "만세", "raising hands celebrate 만세"),
("👐", "양손 펼침", "open hands 환영"),
("🤲", "두 손 모음", "palms up together 기도 바람"),
("🙏", "두 손 합장", "pray please thanks 감사 부탁 기도"),
("✍️", "글쓰기", "writing pen 글쓰기"),
("💅", "네일", "nail polish manicure 네일 손톱"),
("🤳", "셀카", "selfie 셀카"),
("💪", "근육", "muscle strong 근육 힘"),
("🦾", "기계 팔", "mechanical arm 로봇 팔"),
("🦿", "기계 다리", "mechanical leg 로봇 다리"),
("🦵", "다리", "leg kick 다리"),
("🦶", "발", "foot kick 발"),
("👂", "귀", "ear hear 귀"),
("🦻", "보청기 귀", "ear hearing aid 보청기"),
("👃", "코", "nose smell 코"),
("🫀", "심장", "heart anatomical 심장"),
("🫁", "폐", "lungs 폐"),
("🧠", "뇌", "brain mind 뇌 지능"),
("🦷", "치아", "tooth dental 치아"),
("🦴", "뼈", "bone 뼈"),
("👀", "눈", "eyes look see 눈 보기"),
("👁️", "한쪽 눈", "eye 눈"),
("👅", "혀", "tongue 혀"),
("👄", "입술", "lips mouth 입술"),
("💋", "입맞춤", "kiss lips 키스 입술"),
("🩸", "피", "blood drop 피 혈액"),
// 하트 / 감정 기호
("❤️", "빨간 하트", "red heart love 사랑 빨강"),
("🧡", "주황 하트", "orange heart 사랑"),
("💛", "노란 하트", "yellow heart 사랑"),
("💚", "초록 하트", "green heart 사랑"),
("💙", "파란 하트", "blue heart 사랑"),
("💜", "보라 하트", "purple heart 사랑"),
("🖤", "검은 하트", "black heart 사랑 다크"),
("🤍", "흰 하트", "white heart 사랑"),
("🤎", "갈색 하트", "brown heart 사랑"),
("💔", "깨진 하트", "broken heart 이별 상처"),
("❣️", "느낌표 하트", "heart exclamation 사랑"),
("💕", "두 하트", "two hearts 사랑"),
("💞", "회전 하트", "revolving hearts 사랑"),
("💓", "뛰는 하트", "beating heart 설렘"),
("💗", "성장 하트", "growing heart 사랑"),
("💖", "반짝 하트", "sparkling heart 사랑"),
("💘", "화살 하트", "heart arrow 큐피드"),
("💝", "리본 하트", "heart ribbon 선물 사랑"),
("💟", "하트 장식", "heart decoration 사랑"),
("☮️", "평화", "peace 평화"),
("✝️", "십자가", "cross 기독교"),
("☯️", "음양", "yin yang 음양 균형"),
("🔮", "수정구", "crystal ball magic 마법 점"),
("✨", "반짝임", "sparkles glitter 빛 반짝"),
("⭐", "별", "star 별"),
("🌟", "빛나는 별", "glowing star 별빛"),
("💫", "현기증", "dizzy star 빙글"),
("⚡", "번개", "lightning bolt 번개 전기"),
("🔥", "불", "fire hot 불 열정"),
("💥", "폭발", "explosion boom 폭발"),
("❄️", "눈송이", "snowflake cold 눈 추위"),
("🌈", "무지개", "rainbow 무지개"),
("☀️", "태양", "sun sunny 태양 맑음"),
("🌙", "달", "moon crescent 달"),
("🌊", "파도", "wave ocean 파도 바다"),
("💨", "바람", "wind dash 바람"),
("💦", "물방울", "sweat droplets water 물"),
("🌸", "벚꽃", "cherry blossom 벚꽃 봄"),
("🌹", "장미", "rose 장미 꽃"),
("🌺", "히비스커스", "hibiscus 꽃"),
("🌻", "해바라기", "sunflower 해바라기"),
("🌼", "꽃", "blossom flower 꽃"),
("🌷", "튤립", "tulip 튤립"),
("💐", "꽃다발", "bouquet flowers 꽃다발"),
("🍀", "네잎클로버", "four leaf clover lucky 행운"),
("🌿", "허브", "herb green 풀 허브"),
("🍃", "잎사귀", "leaf 잎"),
// 음식
("🍕", "피자", "pizza 피자"),
("🍔", "햄버거", "hamburger burger 버거"),
("🌮", "타코", "taco 타코"),
("🍜", "라면", "ramen noodles 라면 국수"),
("🍱", "도시락", "bento box 도시락"),
("🍣", "초밥", "sushi 초밥"),
("🍚", "밥", "rice 밥"),
("🍛", "카레", "curry rice 카레"),
("🍝", "파스타", "pasta spaghetti 파스타"),
("🍦", "소프트 아이스크림", "ice cream soft serve 아이스크림"),
("🎂", "생일 케이크", "cake birthday 생일 케이크"),
("🍰", "케이크 조각", "cake slice 케이크"),
("🧁", "컵케이크", "cupcake 컵케이크"),
("🍩", "도넛", "donut 도넛"),
("🍪", "쿠키", "cookie 쿠키"),
("🍫", "초콜릿", "chocolate bar 초콜릿"),
("🍬", "사탕", "candy 사탕"),
("🍭", "막대 사탕", "lollipop 막대사탕"),
("🍺", "맥주", "beer mug 맥주"),
("🍻", "건배", "clinking beer 건배"),
("🥂", "샴페인 건배", "champagne 샴페인 건배"),
("🍷", "와인", "wine 와인"),
("☕", "커피", "coffee hot 커피"),
("🧃", "주스", "juice 주스"),
("🥤", "음료", "drink cup 음료 컵"),
("🧋", "버블티", "bubble tea boba 버블티"),
("🍵", "녹차", "tea matcha 차 녹차"),
// 동물
("🐶", "강아지", "dog puppy 강아지 개"),
("🐱", "고양이", "cat kitten 고양이"),
("🐭", "쥐", "mouse 쥐"),
("🐹", "햄스터", "hamster 햄스터"),
("🐰", "토끼", "rabbit bunny 토끼"),
("🦊", "여우", "fox 여우"),
("🐻", "곰", "bear 곰"),
("🐼", "판다", "panda 판다"),
("🐨", "코알라", "koala 코알라"),
("🐯", "호랑이", "tiger 호랑이"),
("🦁", "사자", "lion 사자"),
("🐮", "소", "cow 소"),
("🐷", "돼지", "pig 돼지"),
("🐸", "개구리", "frog 개구리"),
("🐵", "원숭이", "monkey 원숭이"),
("🙈", "눈 가린 원숭이", "see no evil monkey 안 봐"),
("🙉", "귀 가린 원숭이", "hear no evil monkey 안 들어"),
("🙊", "입 가린 원숭이", "speak no evil monkey 안 말해"),
("🐔", "닭", "chicken 닭"),
("🐧", "펭귄", "penguin 펭귄"),
("🐦", "새", "bird 새"),
("🦆", "오리", "duck 오리"),
("🦅", "독수리", "eagle 독수리"),
("🦉", "부엉이", "owl 부엉이"),
("🐍", "뱀", "snake 뱀"),
("🐢", "거북이", "turtle 거북이"),
("🦋", "나비", "butterfly 나비"),
("🐌", "달팽이", "snail 달팽이"),
("🐛", "애벌레", "bug caterpillar 애벌레"),
("🐝", "꿀벌", "bee honeybee 벌"),
("🦑", "오징어", "squid 오징어"),
("🐙", "문어", "octopus 문어"),
("🐠", "열대어", "tropical fish 열대어"),
("🐡", "복어", "blowfish puffer 복어"),
("🦈", "상어", "shark 상어"),
("🐬", "돌고래", "dolphin 돌고래"),
("🐳", "고래", "whale 고래"),
("🐲", "용", "dragon 용"),
("🦄", "유니콘", "unicorn 유니콘"),
// 물건 / 도구
("📱", "스마트폰", "phone mobile smartphone 폰"),
("💻", "노트북", "laptop computer 노트북"),
("🖥️", "데스크톱", "desktop computer 컴퓨터"),
("⌨️", "키보드", "keyboard 키보드"),
("🖱️", "마우스", "mouse 마우스"),
("🖨️", "프린터", "printer 프린터"),
("📷", "카메라", "camera 카메라"),
("📸", "플래시 카메라", "camera flash 사진"),
("📹", "비디오 카메라", "video camera 동영상"),
("🎥", "영화 카메라", "movie camera film 영화"),
("📺", "TV", "television tv 텔레비전"),
("📻", "라디오", "radio 라디오"),
("🎙️", "마이크", "microphone studio 마이크"),
("🎤", "마이크 핸드헬드", "microphone karaoke 마이크"),
("🎧", "헤드폰", "headphones 헤드폰"),
("📡", "안테나", "satellite antenna 안테나"),
("🔋", "배터리", "battery 배터리"),
("🔌", "전원 플러그", "plug electric 플러그"),
("💡", "전구", "bulb idea light 전구 아이디어"),
("🔦", "손전등", "flashlight torch 손전등"),
("🕯️", "양초", "candle 양초"),
("📚", "책", "books stack 책"),
("📖", "열린 책", "open book read 독서"),
("📝", "메모", "memo note pencil 메모 노트"),
("✏️", "연필", "pencil 연필"),
("🖊️", "펜", "pen 펜"),
("📌", "압정", "pushpin pin 압정"),
("📎", "클립", "paperclip 클립"),
("✂️", "가위", "scissors cut 가위"),
("🗂️", "파일 폴더", "card index dividers folder 파일"),
("📁", "폴더", "folder 폴더"),
("📂", "열린 폴더", "open folder 폴더"),
("🗃️", "파일 박스", "card file box 서류함"),
("🗑️", "휴지통", "wastebasket trash 휴지통"),
("🔒", "잠금", "locked lock 잠금"),
("🔓", "열림", "unlocked 열림"),
("🔑", "열쇠", "key 열쇠"),
("🗝️", "구식 열쇠", "old key 열쇠"),
("🔨", "망치", "hammer 망치"),
("🔧", "렌치", "wrench tool 렌치"),
("🔩", "나사", "nut bolt 나사"),
("⚙️", "톱니바퀴", "gear settings 설정 톱니"),
("🛠️", "도구", "tools hammer wrench 도구"),
("💊", "알약", "pill medicine 약 알약"),
("💉", "주사기", "syringe injection 주사"),
("🩺", "청진기", "stethoscope doctor 청진기"),
("🏆", "트로피", "trophy award 트로피 우승"),
("🥇", "금메달", "first gold medal 금메달"),
("🥈", "은메달", "second silver 은메달"),
("🥉", "동메달", "third bronze 동메달"),
("🎖️", "훈장", "medal military 훈장"),
("🎗️", "리본", "ribbon awareness 리본"),
("🎫", "티켓", "ticket admission 티켓"),
("🎟️", "입장권", "admission tickets 티켓"),
("🎪", "서커스", "circus tent 서커스"),
("🎨", "팔레트", "art palette paint 그림 예술"),
("🎭", "연극", "performing arts theater 연극"),
("🎬", "클래퍼보드", "clapper film 영화 촬영"),
("🎮", "게임 컨트롤러", "video game controller 게임"),
("🎲", "주사위", "dice game 주사위"),
("🎯", "다트", "bullseye target dart 다트 목표"),
("🎳", "볼링", "bowling 볼링"),
("⚽", "축구", "soccer football 축구"),
("🏀", "농구", "basketball 농구"),
("🏈", "미식축구", "american football 미식축구"),
("⚾", "야구", "baseball 야구"),
("🎾", "테니스", "tennis 테니스"),
("🏐", "배구", "volleyball 배구"),
("🏉", "럭비", "rugby 럭비"),
("🎱", "당구", "billiards pool 당구"),
("🏓", "탁구", "ping pong table tennis 탁구"),
("🏸", "배드민턴", "badminton 배드민턴"),
("🥊", "권투 장갑", "boxing glove 권투"),
("🎣", "낚시", "fishing 낚시"),
("🏋️", "역도", "weightlifting gym 헬스 역도"),
("🧘", "명상", "yoga meditation 명상 요가"),
// 이동수단
("🚗", "자동차", "car automobile 자동차"),
("🚕", "택시", "taxi cab 택시"),
("🚙", "SUV", "suv car 차"),
("🚌", "버스", "bus 버스"),
("🚎", "무궤도 전차", "trolleybus 버스"),
("🏎️", "레이싱카", "racing car 레이싱"),
("🚓", "경찰차", "police car 경찰"),
("🚑", "구급차", "ambulance 구급차"),
("🚒", "소방차", "fire truck 소방차"),
("🚐", "미니밴", "minibus van 밴"),
("🚚", "트럭", "truck delivery 트럭"),
("✈️", "비행기", "airplane flight plane 비행기"),
("🚀", "로켓", "rocket space launch 로켓"),
("🛸", "UFO", "flying saucer ufo 유에프오"),
("🚁", "헬리콥터", "helicopter 헬리콥터"),
("🚂", "기차", "train locomotive 기차"),
("🚆", "고속열차", "train 기차"),
("🚇", "지하철", "metro subway 지하철"),
("⛵", "돛단배", "sailboat 요트"),
("🚢", "배", "ship cruise 배"),
("🚲", "자전거", "bicycle bike 자전거"),
("🛵", "스쿠터", "scooter moped 스쿠터"),
("🏍️", "오토바이", "motorcycle 오토바이"),
// 장소
("🏠", "집", "house home 집"),
("🏡", "마당 있는 집", "house garden 집"),
("🏢", "빌딩", "office building 빌딩"),
("🏣", "우체국", "post office 우체국"),
("🏥", "병원", "hospital 병원"),
("🏦", "은행", "bank 은행"),
("🏨", "호텔", "hotel 호텔"),
("🏫", "학교", "school 학교"),
("🏪", "편의점", "convenience store shop 편의점"),
("🏬", "백화점", "department store 백화점"),
("🏰", "성", "castle 성"),
("⛪", "교회", "church 교회"),
("🕌", "모스크", "mosque 모스크"),
("🗼", "에펠탑", "eiffel tower paris 파리"),
("🗽", "자유의 여신상", "statue of liberty new york 뉴욕"),
("🏔️", "산", "mountain snow 산"),
("🌋", "화산", "volcano 화산"),
("🗻", "후지산", "mount fuji japan 후지산"),
("🏕️", "캠핑", "camping tent 캠핑"),
("🏖️", "해변", "beach summer 해변 해수욕"),
("🌏", "지구", "earth globe asia 지구"),
// 기호 / 숫자
("💯", "100점", "hundred percent perfect 완벽 100"),
("🔢", "숫자", "numbers 숫자"),
("🆗", "OK", "ok button 오케이"),
("🆙", "업", "up button 업"),
("🆒", "쿨", "cool button 쿨"),
("🆕", "새것", "new button 새"),
("🆓", "무료", "free button 무료"),
("🆘", "SOS", "sos emergency 긴급 구조"),
("⚠️", "경고", "warning caution 경고 주의"),
("🚫", "금지", "prohibited no 금지"),
("✅", "체크", "check mark done 완료 확인"),
("❌", "엑스", "x cross error 실패 오류"),
("❓", "물음표", "question mark 물음표"),
("❗", "느낌표", "exclamation mark 느낌표"),
("", "더하기", "plus add 더하기"),
("", "빼기", "minus subtract 빼기"),
("➗", "나누기", "divide 나누기"),
("✖️", "곱하기", "multiply times 곱하기"),
("♾️", "무한대", "infinity 무한"),
("🔁", "반복", "repeat loop 반복"),
("🔀", "셔플", "shuffle random 랜덤"),
("▶️", "재생", "play 재생"),
("⏸️", "일시정지", "pause 일시정지"),
("⏹️", "정지", "stop 정지"),
("⏩", "빨리 감기", "fast forward 빨리감기"),
("⏪", "되감기", "rewind 되감기"),
("🔔", "알림", "bell notification 알림 벨"),
("🔕", "알림 끔", "bell off 알림끔"),
("🔊", "볼륨 크게", "loud speaker volume up 볼륨"),
("🔇", "음소거", "muted speaker 음소거"),
("📣", "메가폰", "megaphone loud 확성기"),
("📢", "스피커", "loudspeaker 스피커"),
("💬", "말풍선", "speech bubble chat 대화"),
("💭", "생각 말풍선", "thought bubble thinking 생각"),
("📧", "이메일", "email mail 이메일 메일"),
("📨", "수신 봉투", "incoming envelope 수신"),
("📩", "발신 봉투", "envelope outbox 발신"),
("📬", "우편함", "mailbox 우편함"),
("📦", "택배 박스", "package box parcel 택배 상자"),
("🎁", "선물", "gift present 선물"),
("🎀", "리본 묶음", "ribbon bow 리본"),
("🎊", "색종이", "confetti 파티 축하"),
("🎉", "파티 폭죽", "party popper celebrate 파티 축하"),
("🎈", "풍선", "balloon party 풍선"),
("🕐", "1시", "one o'clock 1시 시간"),
("🕒", "3시", "three o'clock 3시 시간"),
("🕔", "4시", "four o'clock 4시 시간"),
("⏰", "알람 시계", "alarm clock 알람 시계"),
("⏱️", "스톱워치", "stopwatch timer 스톱워치 타이머"),
("📅", "달력", "calendar date 달력 날짜"),
("📆", "찢는 달력", "tear-off calendar 달력"),
("💰", "돈 가방", "money bag 돈 부자"),
("💳", "신용카드", "credit card payment 카드 결제"),
("💵", "달러", "dollar banknote 달러"),
("💴", "엔화", "yen banknote 엔"),
("💶", "유로", "euro banknote 유로"),
("💷", "파운드", "pound banknote 파운드"),
("📊", "막대 그래프", "bar chart graph 그래프"),
("📈", "상승 그래프", "chart increasing trend 상승 트렌드"),
("📉", "하락 그래프", "chart decreasing trend 하락"),
("🔍", "돋보기", "magnifying glass search 검색 돋보기"),
("🔎", "오른쪽 돋보기", "magnifying glass right search 검색"),
("🏳️", "흰 깃발", "white flag 항복"),
("🏴", "검은 깃발", "black flag 해적"),
("🚩", "빨간 삼각기", "triangular flag 경고 깃발"),
("🏁", "체크무늬 깃발", "chequered flag finish race 결승"),
("🌐", "지구본", "globe internet web 인터넷 웹"),
("⚓", "닻", "anchor 닻"),
("🎵", "음표", "music note 음악 음표"),
("🎶", "음표들", "musical notes 음악"),
("🎼", "악보", "musical score 악보"),
("🎹", "피아노", "piano keyboard 피아노"),
("🎸", "기타", "guitar 기타"),
("🥁", "드럼", "drum 드럼"),
("🪗", "아코디언", "accordion 아코디언"),
("🎷", "색소폰", "saxophone 색소폰"),
("🎺", "트럼펫", "trumpet 트럼펫"),
("🎻", "바이올린", "violin 바이올린"),
};
}

View File

@@ -10,7 +10,7 @@ namespace AxCopilot.Handlers;
/// emoji wave → 👋 검색
/// emoji → 자주 쓰는 이모지 목록
/// </summary>
public class EmojiHandler : IActionHandler
public partial class EmojiHandler : IActionHandler
{
public string? Prefix => "emoji";
@@ -20,490 +20,6 @@ public class EmojiHandler : IActionHandler
"1.0",
"AX");
// ─── 이모지 데이터베이스 (이모지, 이름(한/영), 태그) ──────────────────────
private static readonly (string Emoji, string Name, string Tags)[] _emojis =
{
// 표정 / 감정
("😀", "크게 웃는 얼굴", "smile happy grin 웃음 행복"),
("😃", "웃는 얼굴", "smile happy joy 웃음"),
("😄", "눈 웃음", "smile laugh 웃음 기쁨"),
("😁", "히죽 웃음", "grin beam 씩 웃다"),
("😆", "크게 웃음", "laughing 폭소"),
("😅", "식은땀 웃음", "sweat smile 안도"),
("🤣", "바닥 구르며 웃음", "rofl lol 빵 웃음"),
("😂", "눈물 나게 웃음", "joy tears laugh 폭소"),
("🙂", "살짝 웃음", "slightly smiling 미소"),
("🙃", "거꾸로 웃음", "upside down 뒤집힌"),
("😉", "윙크", "wink 윙크"),
("😊", "볼 빨개진 웃음", "blush 부끄러움 미소"),
("😇", "천사", "angel halo 천사 선량"),
("🥰", "사랑스러운 얼굴", "love hearts 사랑 하트"),
("😍", "하트 눈", "heart eyes 사랑 반함"),
("🤩", "별 눈", "star struck 감동 황홀"),
("😘", "뽀뽀", "kiss blow 키스 뽀뽀"),
("😗", "오므린 입", "kiss whistle 키스"),
("😚", "눈 감고 뽀뽀", "kiss 키스"),
("😙", "볼 뽀뽀", "kiss 키스"),
("😋", "맛있다", "yum delicious 맛 음식"),
("😛", "혀 내밀기", "tongue out 혀 놀림"),
("😜", "윙크하며 혀", "wink tongue 장난"),
("🤪", "미친 표정", "zany crazy 정신없음"),
("😝", "눈 감고 혀", "tongue 혀"),
("🤑", "돈 눈", "money face 돈 부자"),
("🤗", "포옹", "hugging hug 안아줘 포옹"),
("🤭", "입 가리고", "hand over mouth 헉 깜짝"),
("🤫", "쉿", "shushing quiet 조용 쉿"),
("🤔", "생각 중", "thinking 고민 생각"),
("🤐", "입 막음", "zipper mouth 비밀"),
("🤨", "의심", "raised eyebrow 의심 의아"),
("😐", "무표정", "neutral 무감각 무표정"),
("😑", "표정 없음", "expressionless 냉담"),
("😶", "입 없는 얼굴", "no mouth 침묵"),
("😏", "비웃음", "smirk 비웃 냉소"),
("😒", "불만", "unamused 불만 짜증"),
("🙄", "눈 굴리기", "eye roll 어이없음"),
("😬", "이 드러냄", "grimace 으 민망"),
("🤥", "거짓말", "lying pinocchio 거짓말"),
("😌", "안도/평온", "relieved 안도 평온"),
("😔", "슬픔", "pensive sad 슬픔 우울"),
("😪", "졸림", "sleepy 졸음"),
("🤤", "침 흘림", "drooling 군침 식욕"),
("😴", "잠", "sleeping sleep 수면 잠"),
("😷", "마스크", "mask sick 마스크 아픔"),
("🤒", "열 나는", "sick fever 열 아픔"),
("🤕", "머리 붕대", "injured hurt 부상"),
("🤢", "구역질", "nauseated sick 구역 메스꺼움"),
("🤮", "토하는", "vomit 구토"),
("🤧", "재채기", "sneezing sick 재채기 감기"),
("🥵", "더운", "hot overheated 더움 열"),
("🥶", "추운", "cold freezing 추움 냉기"),
("🥴", "어지러운", "woozy 어지럼 취함"),
("😵", "어질어질", "dizzy 어지럼 충격"),
("🤯", "머리 폭발", "exploding head 충격 대박"),
("🤠", "카우보이", "cowboy hat 카우보이"),
("🥸", "변장", "disguise 변장 선글라스"),
("😎", "쿨한", "cool sunglasses 선글라스 쿨"),
("🤓", "공부벌레", "nerd glasses 공부 안경"),
("🧐", "모노클", "monocle curious 고상 탐정"),
("😕", "당황", "confused 당황 모호"),
("😟", "걱정", "worried concern 걱정"),
("🙁", "살짝 찡그림", "frown 슬픔"),
("☹️", "찡그린 얼굴", "frown sad 슬픔"),
("😮", "입 벌림", "open mouth surprised 놀람"),
("😯", "놀람", "hushed surprised 깜짝"),
("😲", "충격", "astonished 충격 놀람"),
("😳", "얼굴 빨개짐", "flushed embarrassed 부끄럼 당황"),
("🥺", "애원", "pleading eyes 부탁 눈빛"),
("😦", "찡그리며 벌린 입", "frowning 불안"),
("😧", "고통", "anguished 고통"),
("😨", "무서움", "fearful scared 무서움 공포"),
("😰", "식은땀", "anxious sweat 불안 걱정"),
("😥", "눈물 조금", "sad disappointed 실망 눈물"),
("😢", "울음", "cry sad 슬픔 눈물"),
("😭", "엉엉 울음", "loudly crying sob 통곡"),
("😱", "공포에 질림", "screaming fear 비명 공포"),
("😖", "혼란", "confounded 혼란"),
("😣", "힘듦", "persevering 고생"),
("😞", "실망", "disappointed 실망"),
("😓", "땀", "downcast sweat 땀 힘듦"),
("😩", "피곤", "weary tired 지침 피곤"),
("😫", "극도로 지침", "tired exhausted 탈진"),
("🥱", "하품", "yawning bored 하품 지루함"),
("😤", "콧김", "triumph snort 분노 콧김"),
("😡", "화남", "angry mad 화남 분노"),
("😠", "성남", "angry 화 성남"),
("🤬", "욕", "cursing swearing 욕 분노"),
("😈", "나쁜 미소", "smiling devil 악마 장난"),
("👿", "화난 악마", "angry devil 악마"),
("💀", "해골", "skull death 해골 죽음"),
("☠️", "해골 십자", "skull crossbones 독"),
("💩", "응가", "poop 똥 응가"),
("🤡", "피에로", "clown 광대"),
("👹", "도깨비", "ogre 도깨비 귀신"),
("👺", "텐구", "goblin 텐구"),
("👻", "유령", "ghost 유령 귀신"),
("👾", "우주인", "alien monster 외계인 게임"),
("🤖", "로봇", "robot 로봇"),
// 손 / 몸
("👋", "손 흔들기", "wave waving hi bye 안녕"),
("🤚", "손 뒤", "raised back hand 손"),
("🖐️", "손바닥", "hand palm 다섯 손가락"),
("✋", "손 들기", "raised hand 손 들기 멈춤"),
("🖖", "스팍 손인사", "vulcan salute 스타트렉"),
("👌", "오케이", "ok perfect 오케이 좋아"),
("🤌", "손가락 모아", "pinched fingers 이탈리아"),
("✌️", "브이", "victory peace v 브이 평화"),
("🤞", "행운 손가락", "crossed fingers lucky 행운 기도"),
("🤟", "아이 러브 유", "love you 사랑해"),
("🤘", "록 손", "rock on metal 록"),
("🤙", "전화해", "call me shaka 전화 샤카"),
("👈", "왼쪽 가리킴", "backhand left 왼쪽"),
("👉", "오른쪽 가리킴", "backhand right 오른쪽"),
("👆", "위 가리킴", "backhand up 위"),
("🖕", "욕", "middle finger 욕"),
("👇", "아래 가리킴", "backhand down 아래"),
("☝️", "검지 들기", "index pointing up 하나 포인트"),
("👍", "좋아요", "thumbs up like good 좋아 최고"),
("👎", "싫어요", "thumbs down dislike 싫어 별로"),
("✊", "주먹", "fist punch 주먹"),
("👊", "주먹 치기", "punch fist 주먹"),
("🤛", "왼 주먹", "left fist 주먹"),
("🤜", "오른 주먹", "right fist 주먹"),
("👏", "박수", "clapping applause 박수 응원"),
("🙌", "만세", "raising hands celebrate 만세"),
("👐", "양손 펼침", "open hands 환영"),
("🤲", "두 손 모음", "palms up together 기도 바람"),
("🙏", "두 손 합장", "pray please thanks 감사 부탁 기도"),
("✍️", "글쓰기", "writing pen 글쓰기"),
("💅", "네일", "nail polish manicure 네일 손톱"),
("🤳", "셀카", "selfie 셀카"),
("💪", "근육", "muscle strong 근육 힘"),
("🦾", "기계 팔", "mechanical arm 로봇 팔"),
("🦿", "기계 다리", "mechanical leg 로봇 다리"),
("🦵", "다리", "leg kick 다리"),
("🦶", "발", "foot kick 발"),
("👂", "귀", "ear hear 귀"),
("🦻", "보청기 귀", "ear hearing aid 보청기"),
("👃", "코", "nose smell 코"),
("🫀", "심장", "heart anatomical 심장"),
("🫁", "폐", "lungs 폐"),
("🧠", "뇌", "brain mind 뇌 지능"),
("🦷", "치아", "tooth dental 치아"),
("🦴", "뼈", "bone 뼈"),
("👀", "눈", "eyes look see 눈 보기"),
("👁️", "한쪽 눈", "eye 눈"),
("👅", "혀", "tongue 혀"),
("👄", "입술", "lips mouth 입술"),
("💋", "입맞춤", "kiss lips 키스 입술"),
("🩸", "피", "blood drop 피 혈액"),
// 하트 / 감정 기호
("❤️", "빨간 하트", "red heart love 사랑 빨강"),
("🧡", "주황 하트", "orange heart 사랑"),
("💛", "노란 하트", "yellow heart 사랑"),
("💚", "초록 하트", "green heart 사랑"),
("💙", "파란 하트", "blue heart 사랑"),
("💜", "보라 하트", "purple heart 사랑"),
("🖤", "검은 하트", "black heart 사랑 다크"),
("🤍", "흰 하트", "white heart 사랑"),
("🤎", "갈색 하트", "brown heart 사랑"),
("💔", "깨진 하트", "broken heart 이별 상처"),
("❣️", "느낌표 하트", "heart exclamation 사랑"),
("💕", "두 하트", "two hearts 사랑"),
("💞", "회전 하트", "revolving hearts 사랑"),
("💓", "뛰는 하트", "beating heart 설렘"),
("💗", "성장 하트", "growing heart 사랑"),
("💖", "반짝 하트", "sparkling heart 사랑"),
("💘", "화살 하트", "heart arrow 큐피드"),
("💝", "리본 하트", "heart ribbon 선물 사랑"),
("💟", "하트 장식", "heart decoration 사랑"),
("☮️", "평화", "peace 평화"),
("✝️", "십자가", "cross 기독교"),
("☯️", "음양", "yin yang 음양 균형"),
("🔮", "수정구", "crystal ball magic 마법 점"),
("✨", "반짝임", "sparkles glitter 빛 반짝"),
("⭐", "별", "star 별"),
("🌟", "빛나는 별", "glowing star 별빛"),
("💫", "현기증", "dizzy star 빙글"),
("⚡", "번개", "lightning bolt 번개 전기"),
("🔥", "불", "fire hot 불 열정"),
("💥", "폭발", "explosion boom 폭발"),
("❄️", "눈송이", "snowflake cold 눈 추위"),
("🌈", "무지개", "rainbow 무지개"),
("☀️", "태양", "sun sunny 태양 맑음"),
("🌙", "달", "moon crescent 달"),
("🌊", "파도", "wave ocean 파도 바다"),
("💨", "바람", "wind dash 바람"),
("💦", "물방울", "sweat droplets water 물"),
("🌸", "벚꽃", "cherry blossom 벚꽃 봄"),
("🌹", "장미", "rose 장미 꽃"),
("🌺", "히비스커스", "hibiscus 꽃"),
("🌻", "해바라기", "sunflower 해바라기"),
("🌼", "꽃", "blossom flower 꽃"),
("🌷", "튤립", "tulip 튤립"),
("💐", "꽃다발", "bouquet flowers 꽃다발"),
("🍀", "네잎클로버", "four leaf clover lucky 행운"),
("🌿", "허브", "herb green 풀 허브"),
("🍃", "잎사귀", "leaf 잎"),
// 음식
("🍕", "피자", "pizza 피자"),
("🍔", "햄버거", "hamburger burger 버거"),
("🌮", "타코", "taco 타코"),
("🍜", "라면", "ramen noodles 라면 국수"),
("🍱", "도시락", "bento box 도시락"),
("🍣", "초밥", "sushi 초밥"),
("🍚", "밥", "rice 밥"),
("🍛", "카레", "curry rice 카레"),
("🍝", "파스타", "pasta spaghetti 파스타"),
("🍦", "소프트 아이스크림", "ice cream soft serve 아이스크림"),
("🎂", "생일 케이크", "cake birthday 생일 케이크"),
("🍰", "케이크 조각", "cake slice 케이크"),
("🧁", "컵케이크", "cupcake 컵케이크"),
("🍩", "도넛", "donut 도넛"),
("🍪", "쿠키", "cookie 쿠키"),
("🍫", "초콜릿", "chocolate bar 초콜릿"),
("🍬", "사탕", "candy 사탕"),
("🍭", "막대 사탕", "lollipop 막대사탕"),
("🍺", "맥주", "beer mug 맥주"),
("🍻", "건배", "clinking beer 건배"),
("🥂", "샴페인 건배", "champagne 샴페인 건배"),
("🍷", "와인", "wine 와인"),
("☕", "커피", "coffee hot 커피"),
("🧃", "주스", "juice 주스"),
("🥤", "음료", "drink cup 음료 컵"),
("🧋", "버블티", "bubble tea boba 버블티"),
("🍵", "녹차", "tea matcha 차 녹차"),
// 동물
("🐶", "강아지", "dog puppy 강아지 개"),
("🐱", "고양이", "cat kitten 고양이"),
("🐭", "쥐", "mouse 쥐"),
("🐹", "햄스터", "hamster 햄스터"),
("🐰", "토끼", "rabbit bunny 토끼"),
("🦊", "여우", "fox 여우"),
("🐻", "곰", "bear 곰"),
("🐼", "판다", "panda 판다"),
("🐨", "코알라", "koala 코알라"),
("🐯", "호랑이", "tiger 호랑이"),
("🦁", "사자", "lion 사자"),
("🐮", "소", "cow 소"),
("🐷", "돼지", "pig 돼지"),
("🐸", "개구리", "frog 개구리"),
("🐵", "원숭이", "monkey 원숭이"),
("🙈", "눈 가린 원숭이", "see no evil monkey 안 봐"),
("🙉", "귀 가린 원숭이", "hear no evil monkey 안 들어"),
("🙊", "입 가린 원숭이", "speak no evil monkey 안 말해"),
("🐔", "닭", "chicken 닭"),
("🐧", "펭귄", "penguin 펭귄"),
("🐦", "새", "bird 새"),
("🦆", "오리", "duck 오리"),
("🦅", "독수리", "eagle 독수리"),
("🦉", "부엉이", "owl 부엉이"),
("🐍", "뱀", "snake 뱀"),
("🐢", "거북이", "turtle 거북이"),
("🦋", "나비", "butterfly 나비"),
("🐌", "달팽이", "snail 달팽이"),
("🐛", "애벌레", "bug caterpillar 애벌레"),
("🐝", "꿀벌", "bee honeybee 벌"),
("🦑", "오징어", "squid 오징어"),
("🐙", "문어", "octopus 문어"),
("🐠", "열대어", "tropical fish 열대어"),
("🐡", "복어", "blowfish puffer 복어"),
("🦈", "상어", "shark 상어"),
("🐬", "돌고래", "dolphin 돌고래"),
("🐳", "고래", "whale 고래"),
("🐲", "용", "dragon 용"),
("🦄", "유니콘", "unicorn 유니콘"),
// 물건 / 도구
("📱", "스마트폰", "phone mobile smartphone 폰"),
("💻", "노트북", "laptop computer 노트북"),
("🖥️", "데스크톱", "desktop computer 컴퓨터"),
("⌨️", "키보드", "keyboard 키보드"),
("🖱️", "마우스", "mouse 마우스"),
("🖨️", "프린터", "printer 프린터"),
("📷", "카메라", "camera 카메라"),
("📸", "플래시 카메라", "camera flash 사진"),
("📹", "비디오 카메라", "video camera 동영상"),
("🎥", "영화 카메라", "movie camera film 영화"),
("📺", "TV", "television tv 텔레비전"),
("📻", "라디오", "radio 라디오"),
("🎙️", "마이크", "microphone studio 마이크"),
("🎤", "마이크 핸드헬드", "microphone karaoke 마이크"),
("🎧", "헤드폰", "headphones 헤드폰"),
("📡", "안테나", "satellite antenna 안테나"),
("🔋", "배터리", "battery 배터리"),
("🔌", "전원 플러그", "plug electric 플러그"),
("💡", "전구", "bulb idea light 전구 아이디어"),
("🔦", "손전등", "flashlight torch 손전등"),
("🕯️", "양초", "candle 양초"),
("📚", "책", "books stack 책"),
("📖", "열린 책", "open book read 독서"),
("📝", "메모", "memo note pencil 메모 노트"),
("✏️", "연필", "pencil 연필"),
("🖊️", "펜", "pen 펜"),
("📌", "압정", "pushpin pin 압정"),
("📎", "클립", "paperclip 클립"),
("✂️", "가위", "scissors cut 가위"),
("🗂️", "파일 폴더", "card index dividers folder 파일"),
("📁", "폴더", "folder 폴더"),
("📂", "열린 폴더", "open folder 폴더"),
("🗃️", "파일 박스", "card file box 서류함"),
("🗑️", "휴지통", "wastebasket trash 휴지통"),
("🔒", "잠금", "locked lock 잠금"),
("🔓", "열림", "unlocked 열림"),
("🔑", "열쇠", "key 열쇠"),
("🗝️", "구식 열쇠", "old key 열쇠"),
("🔨", "망치", "hammer 망치"),
("🔧", "렌치", "wrench tool 렌치"),
("🔩", "나사", "nut bolt 나사"),
("⚙️", "톱니바퀴", "gear settings 설정 톱니"),
("🛠️", "도구", "tools hammer wrench 도구"),
("💊", "알약", "pill medicine 약 알약"),
("💉", "주사기", "syringe injection 주사"),
("🩺", "청진기", "stethoscope doctor 청진기"),
("🏆", "트로피", "trophy award 트로피 우승"),
("🥇", "금메달", "first gold medal 금메달"),
("🥈", "은메달", "second silver 은메달"),
("🥉", "동메달", "third bronze 동메달"),
("🎖️", "훈장", "medal military 훈장"),
("🎗️", "리본", "ribbon awareness 리본"),
("🎫", "티켓", "ticket admission 티켓"),
("🎟️", "입장권", "admission tickets 티켓"),
("🎪", "서커스", "circus tent 서커스"),
("🎨", "팔레트", "art palette paint 그림 예술"),
("🎭", "연극", "performing arts theater 연극"),
("🎬", "클래퍼보드", "clapper film 영화 촬영"),
("🎮", "게임 컨트롤러", "video game controller 게임"),
("🎲", "주사위", "dice game 주사위"),
("🎯", "다트", "bullseye target dart 다트 목표"),
("🎳", "볼링", "bowling 볼링"),
("⚽", "축구", "soccer football 축구"),
("🏀", "농구", "basketball 농구"),
("🏈", "미식축구", "american football 미식축구"),
("⚾", "야구", "baseball 야구"),
("🎾", "테니스", "tennis 테니스"),
("🏐", "배구", "volleyball 배구"),
("🏉", "럭비", "rugby 럭비"),
("🎱", "당구", "billiards pool 당구"),
("🏓", "탁구", "ping pong table tennis 탁구"),
("🏸", "배드민턴", "badminton 배드민턴"),
("🥊", "권투 장갑", "boxing glove 권투"),
("🎣", "낚시", "fishing 낚시"),
("🏋️", "역도", "weightlifting gym 헬스 역도"),
("🧘", "명상", "yoga meditation 명상 요가"),
// 이동수단
("🚗", "자동차", "car automobile 자동차"),
("🚕", "택시", "taxi cab 택시"),
("🚙", "SUV", "suv car 차"),
("🚌", "버스", "bus 버스"),
("🚎", "무궤도 전차", "trolleybus 버스"),
("🏎️", "레이싱카", "racing car 레이싱"),
("🚓", "경찰차", "police car 경찰"),
("🚑", "구급차", "ambulance 구급차"),
("🚒", "소방차", "fire truck 소방차"),
("🚐", "미니밴", "minibus van 밴"),
("🚚", "트럭", "truck delivery 트럭"),
("✈️", "비행기", "airplane flight plane 비행기"),
("🚀", "로켓", "rocket space launch 로켓"),
("🛸", "UFO", "flying saucer ufo 유에프오"),
("🚁", "헬리콥터", "helicopter 헬리콥터"),
("🚂", "기차", "train locomotive 기차"),
("🚆", "고속열차", "train 기차"),
("🚇", "지하철", "metro subway 지하철"),
("⛵", "돛단배", "sailboat 요트"),
("🚢", "배", "ship cruise 배"),
("🚲", "자전거", "bicycle bike 자전거"),
("🛵", "스쿠터", "scooter moped 스쿠터"),
("🏍️", "오토바이", "motorcycle 오토바이"),
// 장소
("🏠", "집", "house home 집"),
("🏡", "마당 있는 집", "house garden 집"),
("🏢", "빌딩", "office building 빌딩"),
("🏣", "우체국", "post office 우체국"),
("🏥", "병원", "hospital 병원"),
("🏦", "은행", "bank 은행"),
("🏨", "호텔", "hotel 호텔"),
("🏫", "학교", "school 학교"),
("🏪", "편의점", "convenience store shop 편의점"),
("🏬", "백화점", "department store 백화점"),
("🏰", "성", "castle 성"),
("⛪", "교회", "church 교회"),
("🕌", "모스크", "mosque 모스크"),
("🗼", "에펠탑", "eiffel tower paris 파리"),
("🗽", "자유의 여신상", "statue of liberty new york 뉴욕"),
("🏔️", "산", "mountain snow 산"),
("🌋", "화산", "volcano 화산"),
("🗻", "후지산", "mount fuji japan 후지산"),
("🏕️", "캠핑", "camping tent 캠핑"),
("🏖️", "해변", "beach summer 해변 해수욕"),
("🌏", "지구", "earth globe asia 지구"),
// 기호 / 숫자
("💯", "100점", "hundred percent perfect 완벽 100"),
("🔢", "숫자", "numbers 숫자"),
("🆗", "OK", "ok button 오케이"),
("🆙", "업", "up button 업"),
("🆒", "쿨", "cool button 쿨"),
("🆕", "새것", "new button 새"),
("🆓", "무료", "free button 무료"),
("🆘", "SOS", "sos emergency 긴급 구조"),
("⚠️", "경고", "warning caution 경고 주의"),
("🚫", "금지", "prohibited no 금지"),
("✅", "체크", "check mark done 완료 확인"),
("❌", "엑스", "x cross error 실패 오류"),
("❓", "물음표", "question mark 물음표"),
("❗", "느낌표", "exclamation mark 느낌표"),
("", "더하기", "plus add 더하기"),
("", "빼기", "minus subtract 빼기"),
("➗", "나누기", "divide 나누기"),
("✖️", "곱하기", "multiply times 곱하기"),
("♾️", "무한대", "infinity 무한"),
("🔁", "반복", "repeat loop 반복"),
("🔀", "셔플", "shuffle random 랜덤"),
("▶️", "재생", "play 재생"),
("⏸️", "일시정지", "pause 일시정지"),
("⏹️", "정지", "stop 정지"),
("⏩", "빨리 감기", "fast forward 빨리감기"),
("⏪", "되감기", "rewind 되감기"),
("🔔", "알림", "bell notification 알림 벨"),
("🔕", "알림 끔", "bell off 알림끔"),
("🔊", "볼륨 크게", "loud speaker volume up 볼륨"),
("🔇", "음소거", "muted speaker 음소거"),
("📣", "메가폰", "megaphone loud 확성기"),
("📢", "스피커", "loudspeaker 스피커"),
("💬", "말풍선", "speech bubble chat 대화"),
("💭", "생각 말풍선", "thought bubble thinking 생각"),
("📧", "이메일", "email mail 이메일 메일"),
("📨", "수신 봉투", "incoming envelope 수신"),
("📩", "발신 봉투", "envelope outbox 발신"),
("📬", "우편함", "mailbox 우편함"),
("📦", "택배 박스", "package box parcel 택배 상자"),
("🎁", "선물", "gift present 선물"),
("🎀", "리본 묶음", "ribbon bow 리본"),
("🎊", "색종이", "confetti 파티 축하"),
("🎉", "파티 폭죽", "party popper celebrate 파티 축하"),
("🎈", "풍선", "balloon party 풍선"),
("🕐", "1시", "one o'clock 1시 시간"),
("🕒", "3시", "three o'clock 3시 시간"),
("🕔", "4시", "four o'clock 4시 시간"),
("⏰", "알람 시계", "alarm clock 알람 시계"),
("⏱️", "스톱워치", "stopwatch timer 스톱워치 타이머"),
("📅", "달력", "calendar date 달력 날짜"),
("📆", "찢는 달력", "tear-off calendar 달력"),
("💰", "돈 가방", "money bag 돈 부자"),
("💳", "신용카드", "credit card payment 카드 결제"),
("💵", "달러", "dollar banknote 달러"),
("💴", "엔화", "yen banknote 엔"),
("💶", "유로", "euro banknote 유로"),
("💷", "파운드", "pound banknote 파운드"),
("📊", "막대 그래프", "bar chart graph 그래프"),
("📈", "상승 그래프", "chart increasing trend 상승 트렌드"),
("📉", "하락 그래프", "chart decreasing trend 하락"),
("🔍", "돋보기", "magnifying glass search 검색 돋보기"),
("🔎", "오른쪽 돋보기", "magnifying glass right search 검색"),
("🏳️", "흰 깃발", "white flag 항복"),
("🏴", "검은 깃발", "black flag 해적"),
("🚩", "빨간 삼각기", "triangular flag 경고 깃발"),
("🏁", "체크무늬 깃발", "chequered flag finish race 결승"),
("🌐", "지구본", "globe internet web 인터넷 웹"),
("⚓", "닻", "anchor 닻"),
("🎵", "음표", "music note 음악 음표"),
("🎶", "음표들", "musical notes 음악"),
("🎼", "악보", "musical score 악보"),
("🎹", "피아노", "piano keyboard 피아노"),
("🎸", "기타", "guitar 기타"),
("🥁", "드럼", "drum 드럼"),
("🪗", "아코디언", "accordion 아코디언"),
("🎷", "색소폰", "saxophone 색소폰"),
("🎺", "트럼펫", "trumpet 트럼펫"),
("🎻", "바이올린", "violin 바이올린"),
};
public Task<IEnumerable<LauncherItem>> GetItemsAsync(string query, CancellationToken ct)
{
IEnumerable<(string Emoji, string Name, string Tags)> matches;

View File

@@ -0,0 +1,193 @@
using AxCopilot.SDK;
using AxCopilot.Services;
using AxCopilot.Themes;
using System.Net.Http;
using System.Text.Json;
using System.Text.RegularExpressions;
using System.Windows;
namespace AxCopilot.Handlers;
// ─── 수식 파서 ─────────────────────────────────────────────────────────────────
/// <summary>
/// 재귀 하강 파서 기반 수학 수식 평가기.
/// 지원: +, -, *, /, %, ^ (거듭제곱), 괄호, 단항 음수,
/// sqrt, abs, ceil, floor, round, sin, cos, tan (도 단위),
/// log (밑 10), ln (자연로그), pi, e
/// </summary>
internal static class MathEvaluator
{
public static double Evaluate(string expr)
{
var evaluator = new Evaluator(
expr.Replace(" ", "")
.Replace("×", "*")
.Replace("÷", "/")
.Replace("", ",")
.ToLowerInvariant());
return evaluator.Parse();
}
private class Evaluator
{
private readonly string _s;
private int _i;
public Evaluator(string s) { _s = s; _i = 0; }
public double Parse()
{
var result = ParseExpr();
if (_i < _s.Length)
throw new InvalidOperationException($"예기치 않은 문자: '{_s[_i]}'");
return result;
}
// 덧셈 / 뺄셈
private double ParseExpr()
{
var left = ParseTerm();
while (_i < _s.Length && (_s[_i] == '+' || _s[_i] == '-'))
{
var op = _s[_i++];
var right = ParseTerm();
left = op == '+' ? left + right : left - right;
}
return left;
}
// 곱셈 / 나눗셈 / 나머지
private double ParseTerm()
{
var left = ParsePower();
while (_i < _s.Length && (_s[_i] == '*' || _s[_i] == '/' || _s[_i] == '%'))
{
var op = _s[_i++];
var right = ParsePower();
left = op == '*' ? left * right
: op == '/' ? left / right
: left % right;
}
return left;
}
// 거듭제곱 (오른쪽 결합)
private double ParsePower()
{
var b = ParseUnary();
if (_i < _s.Length && _s[_i] == '^')
{
_i++;
var exp = ParseUnary();
return Math.Pow(b, exp);
}
return b;
}
// 단항 부호
private double ParseUnary()
{
if (_i < _s.Length && _s[_i] == '-') { _i++; return -ParsePrimary(); }
if (_i < _s.Length && _s[_i] == '+') { _i++; return ParsePrimary(); }
return ParsePrimary();
}
// 리터럴 / 괄호 / 함수 호출
private double ParsePrimary()
{
if (_i >= _s.Length)
throw new InvalidOperationException("수식이 불완전합니다.");
// 16진수 리터럴 0x...
if (_i + 1 < _s.Length && _s[_i] == '0' && _s[_i + 1] == 'x')
{
_i += 2;
var hexStart = _i;
while (_i < _s.Length && "0123456789abcdef".Contains(_s[_i])) _i++;
return Convert.ToInt64(_s[hexStart.._i], 16);
}
// 숫자
if (char.IsDigit(_s[_i]) || _s[_i] == '.')
{
var start = _i;
while (_i < _s.Length && (char.IsDigit(_s[_i]) || _s[_i] == '.')) _i++;
// 과학적 표기: 1.5e3
if (_i < _s.Length && _s[_i] == 'e')
{
_i++;
if (_i < _s.Length && (_s[_i] == '+' || _s[_i] == '-')) _i++;
while (_i < _s.Length && char.IsDigit(_s[_i])) _i++;
}
return double.Parse(_s[start.._i],
System.Globalization.NumberStyles.Float,
System.Globalization.CultureInfo.InvariantCulture);
}
// 괄호
if (_s[_i] == '(')
{
_i++;
var val = ParseExpr();
if (_i < _s.Length && _s[_i] == ')') _i++;
return val;
}
// 식별자 (상수 또는 함수)
if (char.IsLetter(_s[_i]))
{
var start = _i;
while (_i < _s.Length && (char.IsLetterOrDigit(_s[_i]) || _s[_i] == '_')) _i++;
var name = _s[start.._i];
// 상수
if (name == "pi") return Math.PI;
if (name == "e") return Math.E;
if (name == "inf") return double.PositiveInfinity;
// 함수 호출
if (_i < _s.Length && _s[_i] == '(')
{
_i++; // (
var arg = ParseExpr();
// 두 번째 인자 (pow, log2 등)
double? arg2 = null;
if (_i < _s.Length && _s[_i] == ',')
{
_i++;
arg2 = ParseExpr();
}
if (_i < _s.Length && _s[_i] == ')') _i++;
return name switch
{
"sqrt" => Math.Sqrt(arg),
"abs" => Math.Abs(arg),
"ceil" => Math.Ceiling(arg),
"floor" => Math.Floor(arg),
"round" => arg2.HasValue ? Math.Round(arg, (int)arg2.Value) : Math.Round(arg),
"sin" => Math.Sin(arg * Math.PI / 180), // 도 단위
"cos" => Math.Cos(arg * Math.PI / 180),
"tan" => Math.Tan(arg * Math.PI / 180),
"asin" => Math.Asin(arg) * 180 / Math.PI,
"acos" => Math.Acos(arg) * 180 / Math.PI,
"atan" => Math.Atan(arg) * 180 / Math.PI,
"log" => arg2.HasValue ? Math.Log(arg, arg2.Value) : Math.Log10(arg),
"log2" => Math.Log2(arg),
"ln" => Math.Log(arg),
"exp" => Math.Exp(arg),
"pow" => arg2.HasValue ? Math.Pow(arg, arg2.Value) : throw new InvalidOperationException("pow(x,y) 형식으로 사용하세요."),
"min" => arg2.HasValue ? Math.Min(arg, arg2.Value) : arg,
"max" => arg2.HasValue ? Math.Max(arg, arg2.Value) : arg,
_ => throw new InvalidOperationException($"알 수 없는 함수: {name}()")
};
}
throw new InvalidOperationException($"알 수 없는 식별자: {name}");
}
throw new InvalidOperationException($"예기치 않은 문자: '{_s[_i]}'");
}
}
}

View File

@@ -0,0 +1,154 @@
using AxCopilot.SDK;
using AxCopilot.Services;
using AxCopilot.Themes;
using System.Net.Http;
using System.Text.Json;
using System.Text.RegularExpressions;
using System.Windows;
namespace AxCopilot.Handlers;
// ─── 단위 변환 ─────────────────────────────────────────────────────────────────
/// <summary>
/// "100km in miles", "32f in c", "5lb to kg" 형식의 단위 변환.
/// </summary>
internal static class UnitConverter
{
// 패턴: <숫자> <단위> in|to <단위>
private static readonly Regex Pattern = new(
@"^(-?\d+(?:\.\d+)?)\s*([a-z°/²³µ]+)\s+(?:in|to)\s+([a-z°/²³µ]+)$",
RegexOptions.IgnoreCase | RegexOptions.Compiled);
public static bool TryConvert(string input, out string? result)
{
result = null;
var m = Pattern.Match(input.Trim());
if (!m.Success) return false;
if (!double.TryParse(m.Groups[1].Value,
System.Globalization.NumberStyles.Float,
System.Globalization.CultureInfo.InvariantCulture,
out var value))
return false;
var from = m.Groups[2].Value.ToLowerInvariant();
var to = m.Groups[3].Value.ToLowerInvariant();
// 온도는 비선형 → 별도 처리
if (TryConvertTemperature(value, from, to, out var tResult))
{
result = $"{FormatNum(tResult)} {TemperatureLabel(to)}";
return true;
}
// 나머지 범주(선형 변환)
foreach (var table in _tables)
{
if (table.TryGetValue(from, out var fromFactor) &&
table.TryGetValue(to, out var toFactor))
{
var converted = value * fromFactor / toFactor;
result = $"{FormatNum(converted)} {to}";
return true;
}
}
return false;
}
// ─── 온도 ────────────────────────────────────────────────────────────────
private static bool TryConvertTemperature(double v, string from, string to, out double r)
{
r = 0;
// 섭씨 표준화
double celsius;
switch (from)
{
case "c": case "°c": case "celsius": celsius = v; break;
case "f": case "°f": case "fahrenheit": celsius = (v - 32) * 5 / 9; break;
case "k": case "kelvin": celsius = v - 273.15; break;
default: return false;
}
switch (to)
{
case "c": case "°c": case "celsius": r = celsius; break;
case "f": case "°f": case "fahrenheit": r = celsius * 9 / 5 + 32; break;
case "k": case "kelvin": r = celsius + 273.15; break;
default: return false;
}
return true;
}
private static string TemperatureLabel(string unit) => unit switch
{
"c" or "°c" or "celsius" => "°C",
"f" or "°f" or "fahrenheit" => "°F",
"k" or "kelvin" => "K",
_ => unit
};
// ─── 선형 변환 테이블 (기준 단위 = 1) ────────────────────────────────────
// 길이 (기준: m)
private static readonly Dictionary<string, double> _length = new()
{
["km"] = 1000, ["m"] = 1, ["cm"] = 0.01, ["mm"] = 0.001,
["mi"] = 1609.344, ["mile"] = 1609.344, ["miles"] = 1609.344,
["ft"] = 0.3048, ["feet"] = 0.3048, ["foot"] = 0.3048,
["in"] = 0.0254, ["inch"] = 0.0254, ["inches"] = 0.0254,
["yd"] = 0.9144, ["yard"] = 0.9144, ["yards"] = 0.9144,
["nm"] = 1e-9,
};
// 무게 (기준: kg)
private static readonly Dictionary<string, double> _weight = new()
{
["t"] = 1000, ["ton"] = 1000, ["tonnes"] = 1000,
["kg"] = 1, ["g"] = 0.001, ["mg"] = 1e-6,
["lb"] = 0.453592, ["lbs"] = 0.453592, ["pound"] = 0.453592, ["pounds"] = 0.453592,
["oz"] = 0.0283495, ["ounce"] = 0.0283495, ["ounces"] = 0.0283495,
};
// 속도 (기준: m/s)
private static readonly Dictionary<string, double> _speed = new()
{
["m/s"] = 1, ["mps"] = 1,
["km/h"] = 1.0 / 3.6, ["kmh"] = 1.0 / 3.6, ["kph"] = 1.0 / 3.6,
["mph"] = 0.44704,
["kn"] = 0.514444, ["knot"] = 0.514444, ["knots"] = 0.514444,
};
// 데이터 (기준: byte)
private static readonly Dictionary<string, double> _data = new()
{
["b"] = 1, ["byte"] = 1, ["bytes"] = 1,
["kb"] = 1024, ["kib"] = 1024,
["mb"] = 1024 * 1024, ["mib"] = 1024 * 1024,
["gb"] = 1024.0 * 1024 * 1024, ["gib"] = 1024.0 * 1024 * 1024,
["tb"] = 1024.0 * 1024 * 1024 * 1024, ["tib"] = 1024.0 * 1024 * 1024 * 1024,
["pb"] = 1024.0 * 1024 * 1024 * 1024 * 1024,
};
// 넓이 (기준: m²)
private static readonly Dictionary<string, double> _area = new()
{
["m²"] = 1, ["m2"] = 1,
["km²"] = 1e6, ["km2"] = 1e6,
["cm²"] = 1e-4, ["cm2"] = 1e-4,
["ha"] = 10000,
["acre"] = 4046.86, ["acres"] = 4046.86,
["ft²"] = 0.092903, ["ft2"] = 0.092903,
};
private static readonly List<Dictionary<string, double>> _tables = new()
{ _length, _weight, _speed, _data, _area };
private static string FormatNum(double v)
{
if (v == Math.Floor(v) && Math.Abs(v) < 1e12)
return ((long)v).ToString("N0", System.Globalization.CultureInfo.CurrentCulture);
return v.ToString("G6", System.Globalization.CultureInfo.InvariantCulture);
}
}

View File

@@ -0,0 +1,283 @@
using System.IO;
using System.Text;
using System.Text.Json;
namespace AxCopilot.Services.Agent;
public partial class DocumentPlannerTool
{
// ─── 파일 생성: HTML / DOCX / Markdown ──────────────────────────────────
private static void GenerateHtml(string path, string title, string docType, List<SectionPlan> sections)
{
var css = TemplateService.GetCss("professional");
var sb = new StringBuilder();
sb.AppendLine("<!DOCTYPE html>");
sb.AppendLine("<html lang=\"ko\">");
sb.AppendLine("<head>");
sb.AppendLine("<meta charset=\"utf-8\">");
sb.AppendLine($"<title>{Escape(title)}</title>");
sb.AppendLine("<style>");
sb.AppendLine(css);
sb.AppendLine(@"
.doc { max-width: 900px; margin: 0 auto; padding: 40px 30px 60px; }
.doc h1 { font-size: 28px; margin-bottom: 8px; border-bottom: 3px solid var(--accent, #4B5EFC); padding-bottom: 10px; }
.doc h2 { font-size: 22px; margin-top: 36px; margin-bottom: 12px; border-bottom: 2px solid var(--accent, #4B5EFC); padding-bottom: 6px; }
.doc .meta { color: #888; font-size: 13px; margin-bottom: 24px; }
.doc .section { line-height: 1.8; margin-bottom: 20px; }
.doc .toc { background: #f8f9fa; border-radius: 12px; padding: 20px 28px; margin: 24px 0 32px; }
.doc .toc h3 { margin-top: 0; }
.doc .toc a { color: inherit; text-decoration: none; }
.doc .toc a:hover { text-decoration: underline; }
.doc .toc ul { list-style: none; padding-left: 0; }
.doc .toc li { padding: 4px 0; }
.doc .key-point { background: #f0f4ff; border-left: 4px solid var(--accent, #4B5EFC); padding: 12px 16px; margin: 12px 0; border-radius: 0 8px 8px 0; }
@media print { .doc h2 { page-break-before: auto; } }
");
sb.AppendLine("</style>");
sb.AppendLine("</head>");
sb.AppendLine("<body>");
sb.AppendLine("<div class=\"doc\">");
sb.AppendLine($"<h1>{Escape(title)}</h1>");
sb.AppendLine($"<div class=\"meta\">문서 유형: {Escape(GetDocTypeLabel(docType))} | 작성일: {DateTime.Now:yyyy-MM-dd} | 섹션: {sections.Count}개</div>");
// 목차
if (sections.Count > 1)
{
sb.AppendLine("<div class=\"toc\">");
sb.AppendLine("<h3>📋 목차</h3>");
sb.AppendLine("<ul>");
for (int i = 0; i < sections.Count; i++)
sb.AppendLine($"<li><a href=\"#sec-{i + 1}\">{Escape(sections[i].Heading)}</a></li>");
sb.AppendLine("</ul>");
sb.AppendLine("</div>");
}
// 섹션 본문
for (int i = 0; i < sections.Count; i++)
{
var sec = sections[i];
sb.AppendLine($"<h2 id=\"sec-{i + 1}\">{Escape(sec.Heading)}</h2>");
sb.AppendLine("<div class=\"section\">");
foreach (var kp in sec.KeyPoints)
{
sb.AppendLine($"<div class=\"key-point\">");
sb.AppendLine($"<strong>▸ {Escape(kp)}</strong>");
sb.AppendLine($"<p>{Escape(kp)}에 대한 상세 내용을 여기에 작성합니다. (목표: 약 {sec.TargetWords / Math.Max(1, sec.KeyPoints.Count)}단어)</p>");
sb.AppendLine("</div>");
}
sb.AppendLine("</div>");
}
sb.AppendLine("</div>");
sb.AppendLine("</body>");
sb.AppendLine("</html>");
File.WriteAllText(path, sb.ToString(), Encoding.UTF8);
}
// ─── DOCX 생성 ──────────────────────────────────────────────────────────
private static void GenerateDocx(string path, string title, List<SectionPlan> sections)
{
using var doc = DocumentFormat.OpenXml.Packaging.WordprocessingDocument.Create(
path, DocumentFormat.OpenXml.WordprocessingDocumentType.Document);
var mainPart = doc.AddMainDocumentPart();
mainPart.Document = new DocumentFormat.OpenXml.Wordprocessing.Document();
var body = mainPart.Document.AppendChild(new DocumentFormat.OpenXml.Wordprocessing.Body());
// 제목
AddDocxParagraph(body, title, bold: true, fontSize: "48");
AddDocxParagraph(body, $"작성일: {DateTime.Now:yyyy-MM-dd}", fontSize: "20", color: "888888");
AddDocxParagraph(body, ""); // 빈 줄
foreach (var sec in sections)
{
AddDocxParagraph(body, sec.Heading, bold: true, fontSize: "32", color: "2B579A");
foreach (var kp in sec.KeyPoints)
{
AddDocxParagraph(body, $"▸ {kp}", bold: true, fontSize: "22");
AddDocxParagraph(body, $"{kp}에 대한 상세 내용을 여기에 작성합니다.");
}
AddDocxParagraph(body, ""); // 섹션 간 빈 줄
}
}
private static void AddDocxParagraph(DocumentFormat.OpenXml.Wordprocessing.Body body,
string text, bool bold = false, string fontSize = "22", string? color = null)
{
var para = new DocumentFormat.OpenXml.Wordprocessing.Paragraph();
var run = new DocumentFormat.OpenXml.Wordprocessing.Run();
var props = new DocumentFormat.OpenXml.Wordprocessing.RunProperties
{
FontSize = new DocumentFormat.OpenXml.Wordprocessing.FontSize { Val = fontSize }
};
if (bold) props.Bold = new DocumentFormat.OpenXml.Wordprocessing.Bold();
if (color != null) props.Color = new DocumentFormat.OpenXml.Wordprocessing.Color { Val = color };
run.AppendChild(props);
run.AppendChild(new DocumentFormat.OpenXml.Wordprocessing.Text(text)
{
Space = DocumentFormat.OpenXml.SpaceProcessingModeValues.Preserve
});
para.AppendChild(run);
body.AppendChild(para);
}
// ─── Markdown 생성 ──────────────────────────────────────────────────────
private static void GenerateMarkdown(string path, string title, List<SectionPlan> sections)
{
var sb = new StringBuilder();
sb.AppendLine($"# {title}");
sb.AppendLine();
sb.AppendLine($"*작성일: {DateTime.Now:yyyy-MM-dd}*");
sb.AppendLine();
// 목차
if (sections.Count > 1)
{
sb.AppendLine("## 목차");
sb.AppendLine();
foreach (var sec in sections)
sb.AppendLine($"- [{sec.Heading}](#{sec.Heading.Replace(" ", "-").ToLowerInvariant()})");
sb.AppendLine();
sb.AppendLine("---");
sb.AppendLine();
}
foreach (var sec in sections)
{
sb.AppendLine($"## {sec.Heading}");
sb.AppendLine();
foreach (var kp in sec.KeyPoints)
{
sb.AppendLine($"### ▸ {kp}");
sb.AppendLine();
sb.AppendLine($"{kp}에 대한 상세 내용을 여기에 작성합니다.");
sb.AppendLine();
}
}
File.WriteAllText(path, sb.ToString(), Encoding.UTF8);
}
// ─── 헬퍼 ────────────────────────────────────────────────────────────────
private static List<SectionPlan> BuildSections(string docType, int pages, string hint, string refSummary)
{
if (!string.IsNullOrWhiteSpace(hint))
{
var hintSections = hint.Split(new[] { ',', '/', '→', '\n' }, StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries);
var result = new List<SectionPlan>();
for (int i = 0; i < hintSections.Length; i++)
{
result.Add(new SectionPlan
{
Id = $"sec-{i + 1}",
Heading = $"{i + 1}. {hintSections[i].TrimStart("0123456789. ".ToCharArray())}",
Level = 1,
KeyPoints = [$"{hintSections[i]} 관련 핵심 내용을 상세히 작성"],
});
}
return result;
}
return docType switch
{
"proposal" => new List<SectionPlan>
{
new() { Id = "sec-1", Heading = "1. 개요", Level = 1, KeyPoints = ["배경", "목적", "범위"] },
new() { Id = "sec-2", Heading = "2. 현황 분석", Level = 1, KeyPoints = ["현재 상황", "문제점 식별"] },
new() { Id = "sec-3", Heading = "3. 제안 내용", Level = 1, KeyPoints = ["핵심 제안", "기대 효과", "실행 방안"] },
new() { Id = "sec-4", Heading = "4. 추진 일정", Level = 1, KeyPoints = ["단계별 일정", "마일스톤"] },
new() { Id = "sec-5", Heading = "5. 소요 자원", Level = 1, KeyPoints = ["인력", "예산", "장비"] },
new() { Id = "sec-6", Heading = "6. 기대 효과 및 결론", Level = 1, KeyPoints = ["정량적 효과", "정성적 효과", "결론"] },
},
"analysis" => new List<SectionPlan>
{
new() { Id = "sec-1", Heading = "1. 분석 개요", Level = 1, KeyPoints = ["분석 목적", "분석 범위", "방법론"] },
new() { Id = "sec-2", Heading = "2. 데이터 현황", Level = 1, KeyPoints = ["데이터 출처", "기본 통계", "데이터 품질"] },
new() { Id = "sec-3", Heading = "3. 정량 분석", Level = 1, KeyPoints = ["수치 분석", "추세", "비교"] },
new() { Id = "sec-4", Heading = "4. 정성 분석", Level = 1, KeyPoints = ["패턴", "인사이트", "이상치"] },
new() { Id = "sec-5", Heading = "5. 종합 해석", Level = 1, KeyPoints = ["핵심 발견", "시사점"] },
new() { Id = "sec-6", Heading = "6. 결론 및 권장사항", Level = 1, KeyPoints = ["결론", "조치 방안", "추가 분석 필요 항목"] },
},
"manual" or "guide" => new List<SectionPlan>
{
new() { Id = "sec-1", Heading = "1. 소개", Level = 1, KeyPoints = ["목적", "대상 독자", "사전 요구사항"] },
new() { Id = "sec-2", Heading = "2. 시작하기", Level = 1, KeyPoints = ["설치", "초기 설정", "기본 사용법"] },
new() { Id = "sec-3", Heading = "3. 주요 기능", Level = 1, KeyPoints = ["기능별 상세 설명", "사용 예시"] },
new() { Id = "sec-4", Heading = "4. 고급 기능", Level = 1, KeyPoints = ["고급 설정", "커스터마이징", "자동화"] },
new() { Id = "sec-5", Heading = "5. 문제 해결", Level = 1, KeyPoints = ["자주 묻는 질문", "에러 대응", "지원 정보"] },
},
"minutes" => new List<SectionPlan>
{
new() { Id = "sec-1", Heading = "1. 회의 정보", Level = 1, KeyPoints = ["일시", "참석자", "장소"] },
new() { Id = "sec-2", Heading = "2. 안건 및 논의", Level = 1, KeyPoints = ["안건별 논의 내용", "주요 의견"] },
new() { Id = "sec-3", Heading = "3. 결정 사항", Level = 1, KeyPoints = ["합의 내용", "변경 사항"] },
new() { Id = "sec-4", Heading = "4. 액션 아이템", Level = 1, KeyPoints = ["담당자", "기한", "세부 내용"] },
new() { Id = "sec-5", Heading = "5. 다음 회의", Level = 1, KeyPoints = ["예정일", "주요 안건"] },
},
_ => new List<SectionPlan>
{
new() { Id = "sec-1", Heading = "1. 개요", Level = 1, KeyPoints = ["배경", "목적", "범위"] },
new() { Id = "sec-2", Heading = "2. 현황", Level = 1, KeyPoints = ["현재 상태", "주요 지표"] },
new() { Id = "sec-3", Heading = "3. 분석", Level = 1, KeyPoints = ["데이터 분석", "비교", "추세"] },
new() { Id = "sec-4", Heading = "4. 주요 발견", Level = 1, KeyPoints = ["핵심 인사이트", "문제점", "기회"] },
new() { Id = "sec-5", Heading = "5. 제안", Level = 1, KeyPoints = ["개선 방안", "실행 계획"] },
new() { Id = "sec-6", Heading = "6. 결론", Level = 1, KeyPoints = ["요약", "기대 효과", "향후 과제"] },
},
};
}
private static void DistributeWordCount(List<SectionPlan> sections, int totalWords)
{
if (sections.Count == 0) return;
var weights = new double[sections.Count];
for (int i = 0; i < sections.Count; i++)
weights[i] = (i == 0 || i == sections.Count - 1) ? 0.7 : 1.2;
var totalWeight = weights.Sum();
for (int i = 0; i < sections.Count; i++)
sections[i].TargetWords = Math.Max(100, (int)(totalWords * weights[i] / totalWeight));
}
private static string SanitizeFileName(string name)
{
var safe = name.Length > 60 ? name[..60] : name;
foreach (var c in Path.GetInvalidFileNameChars())
safe = safe.Replace(c, '_');
return safe.Trim().TrimEnd('.');
}
private static string GetDocTypeLabel(string docType) => docType switch
{
"proposal" => "제안서",
"analysis" => "분석 보고서",
"manual" or "guide" => "매뉴얼/가이드",
"minutes" => "회의록",
"presentation" => "프레젠테이션",
_ => "보고서",
};
private static string Escape(string text)
=> text.Replace("&", "&amp;").Replace("<", "&lt;").Replace(">", "&gt;");
private static readonly JsonSerializerOptions _jsonOptions = new()
{
WriteIndented = true,
Encoder = System.Text.Encodings.Web.JavaScriptEncoder.UnsafeRelaxedJsonEscaping,
PropertyNamingPolicy = JsonNamingPolicy.CamelCase,
};
private class SectionPlan
{
public string Id { get; set; } = "";
public string Heading { get; set; } = "";
public int Level { get; set; } = 1;
public int TargetWords { get; set; } = 300;
public List<string> KeyPoints { get; set; } = new();
}
}

View File

@@ -9,7 +9,7 @@ namespace AxCopilot.Services.Agent;
/// - 멀티패스(고품질) ON : 개요만 반환 → LLM이 섹션별로 상세 작성 → document_assemble로 조립
/// - 멀티패스(고품질) OFF: 개요 + 기본 문서를 즉시 로컬 파일로 저장 (LLM 호출 최소)
/// </summary>
public class DocumentPlannerTool : IAgentTool
public partial class DocumentPlannerTool : IAgentTool
{
private static bool IsMultiPassEnabled(AgentContext context)
{
@@ -321,278 +321,4 @@ public class DocumentPlannerTool : IAgentTool
$"작성 완료 후 {createTool}로 '{suggestedPath}' 파일에 저장하세요.\n\n{json}"));
}
// ─── HTML 생성 ───────────────────────────────────────────────────────────
private static void GenerateHtml(string path, string title, string docType, List<SectionPlan> sections)
{
var css = TemplateService.GetCss("professional");
var sb = new StringBuilder();
sb.AppendLine("<!DOCTYPE html>");
sb.AppendLine("<html lang=\"ko\">");
sb.AppendLine("<head>");
sb.AppendLine("<meta charset=\"utf-8\">");
sb.AppendLine($"<title>{Escape(title)}</title>");
sb.AppendLine("<style>");
sb.AppendLine(css);
sb.AppendLine(@"
.doc { max-width: 900px; margin: 0 auto; padding: 40px 30px 60px; }
.doc h1 { font-size: 28px; margin-bottom: 8px; border-bottom: 3px solid var(--accent, #4B5EFC); padding-bottom: 10px; }
.doc h2 { font-size: 22px; margin-top: 36px; margin-bottom: 12px; border-bottom: 2px solid var(--accent, #4B5EFC); padding-bottom: 6px; }
.doc .meta { color: #888; font-size: 13px; margin-bottom: 24px; }
.doc .section { line-height: 1.8; margin-bottom: 20px; }
.doc .toc { background: #f8f9fa; border-radius: 12px; padding: 20px 28px; margin: 24px 0 32px; }
.doc .toc h3 { margin-top: 0; }
.doc .toc a { color: inherit; text-decoration: none; }
.doc .toc a:hover { text-decoration: underline; }
.doc .toc ul { list-style: none; padding-left: 0; }
.doc .toc li { padding: 4px 0; }
.doc .key-point { background: #f0f4ff; border-left: 4px solid var(--accent, #4B5EFC); padding: 12px 16px; margin: 12px 0; border-radius: 0 8px 8px 0; }
@media print { .doc h2 { page-break-before: auto; } }
");
sb.AppendLine("</style>");
sb.AppendLine("</head>");
sb.AppendLine("<body>");
sb.AppendLine("<div class=\"doc\">");
sb.AppendLine($"<h1>{Escape(title)}</h1>");
sb.AppendLine($"<div class=\"meta\">문서 유형: {Escape(GetDocTypeLabel(docType))} | 작성일: {DateTime.Now:yyyy-MM-dd} | 섹션: {sections.Count}개</div>");
// 목차
if (sections.Count > 1)
{
sb.AppendLine("<div class=\"toc\">");
sb.AppendLine("<h3>📋 목차</h3>");
sb.AppendLine("<ul>");
for (int i = 0; i < sections.Count; i++)
sb.AppendLine($"<li><a href=\"#sec-{i + 1}\">{Escape(sections[i].Heading)}</a></li>");
sb.AppendLine("</ul>");
sb.AppendLine("</div>");
}
// 섹션 본문
for (int i = 0; i < sections.Count; i++)
{
var sec = sections[i];
sb.AppendLine($"<h2 id=\"sec-{i + 1}\">{Escape(sec.Heading)}</h2>");
sb.AppendLine("<div class=\"section\">");
foreach (var kp in sec.KeyPoints)
{
sb.AppendLine($"<div class=\"key-point\">");
sb.AppendLine($"<strong>▸ {Escape(kp)}</strong>");
sb.AppendLine($"<p>{Escape(kp)}에 대한 상세 내용을 여기에 작성합니다. (목표: 약 {sec.TargetWords / Math.Max(1, sec.KeyPoints.Count)}단어)</p>");
sb.AppendLine("</div>");
}
sb.AppendLine("</div>");
}
sb.AppendLine("</div>");
sb.AppendLine("</body>");
sb.AppendLine("</html>");
File.WriteAllText(path, sb.ToString(), Encoding.UTF8);
}
// ─── DOCX 생성 ──────────────────────────────────────────────────────────
private static void GenerateDocx(string path, string title, List<SectionPlan> sections)
{
using var doc = DocumentFormat.OpenXml.Packaging.WordprocessingDocument.Create(
path, DocumentFormat.OpenXml.WordprocessingDocumentType.Document);
var mainPart = doc.AddMainDocumentPart();
mainPart.Document = new DocumentFormat.OpenXml.Wordprocessing.Document();
var body = mainPart.Document.AppendChild(new DocumentFormat.OpenXml.Wordprocessing.Body());
// 제목
AddDocxParagraph(body, title, bold: true, fontSize: "48");
AddDocxParagraph(body, $"작성일: {DateTime.Now:yyyy-MM-dd}", fontSize: "20", color: "888888");
AddDocxParagraph(body, ""); // 빈 줄
foreach (var sec in sections)
{
AddDocxParagraph(body, sec.Heading, bold: true, fontSize: "32", color: "2B579A");
foreach (var kp in sec.KeyPoints)
{
AddDocxParagraph(body, $"▸ {kp}", bold: true, fontSize: "22");
AddDocxParagraph(body, $"{kp}에 대한 상세 내용을 여기에 작성합니다.");
}
AddDocxParagraph(body, ""); // 섹션 간 빈 줄
}
}
private static void AddDocxParagraph(DocumentFormat.OpenXml.Wordprocessing.Body body,
string text, bool bold = false, string fontSize = "22", string? color = null)
{
var para = new DocumentFormat.OpenXml.Wordprocessing.Paragraph();
var run = new DocumentFormat.OpenXml.Wordprocessing.Run();
var props = new DocumentFormat.OpenXml.Wordprocessing.RunProperties
{
FontSize = new DocumentFormat.OpenXml.Wordprocessing.FontSize { Val = fontSize }
};
if (bold) props.Bold = new DocumentFormat.OpenXml.Wordprocessing.Bold();
if (color != null) props.Color = new DocumentFormat.OpenXml.Wordprocessing.Color { Val = color };
run.AppendChild(props);
run.AppendChild(new DocumentFormat.OpenXml.Wordprocessing.Text(text)
{
Space = DocumentFormat.OpenXml.SpaceProcessingModeValues.Preserve
});
para.AppendChild(run);
body.AppendChild(para);
}
// ─── Markdown 생성 ──────────────────────────────────────────────────────
private static void GenerateMarkdown(string path, string title, List<SectionPlan> sections)
{
var sb = new StringBuilder();
sb.AppendLine($"# {title}");
sb.AppendLine();
sb.AppendLine($"*작성일: {DateTime.Now:yyyy-MM-dd}*");
sb.AppendLine();
// 목차
if (sections.Count > 1)
{
sb.AppendLine("## 목차");
sb.AppendLine();
foreach (var sec in sections)
sb.AppendLine($"- [{sec.Heading}](#{sec.Heading.Replace(" ", "-").ToLowerInvariant()})");
sb.AppendLine();
sb.AppendLine("---");
sb.AppendLine();
}
foreach (var sec in sections)
{
sb.AppendLine($"## {sec.Heading}");
sb.AppendLine();
foreach (var kp in sec.KeyPoints)
{
sb.AppendLine($"### ▸ {kp}");
sb.AppendLine();
sb.AppendLine($"{kp}에 대한 상세 내용을 여기에 작성합니다.");
sb.AppendLine();
}
}
File.WriteAllText(path, sb.ToString(), Encoding.UTF8);
}
// ─── 헬퍼 ────────────────────────────────────────────────────────────────
private static List<SectionPlan> BuildSections(string docType, int pages, string hint, string refSummary)
{
if (!string.IsNullOrWhiteSpace(hint))
{
var hintSections = hint.Split(new[] { ',', '/', '→', '\n' }, StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries);
var result = new List<SectionPlan>();
for (int i = 0; i < hintSections.Length; i++)
{
result.Add(new SectionPlan
{
Id = $"sec-{i + 1}",
Heading = $"{i + 1}. {hintSections[i].TrimStart("0123456789. ".ToCharArray())}",
Level = 1,
KeyPoints = [$"{hintSections[i]} 관련 핵심 내용을 상세히 작성"],
});
}
return result;
}
return docType switch
{
"proposal" => new List<SectionPlan>
{
new() { Id = "sec-1", Heading = "1. 개요", Level = 1, KeyPoints = ["배경", "목적", "범위"] },
new() { Id = "sec-2", Heading = "2. 현황 분석", Level = 1, KeyPoints = ["현재 상황", "문제점 식별"] },
new() { Id = "sec-3", Heading = "3. 제안 내용", Level = 1, KeyPoints = ["핵심 제안", "기대 효과", "실행 방안"] },
new() { Id = "sec-4", Heading = "4. 추진 일정", Level = 1, KeyPoints = ["단계별 일정", "마일스톤"] },
new() { Id = "sec-5", Heading = "5. 소요 자원", Level = 1, KeyPoints = ["인력", "예산", "장비"] },
new() { Id = "sec-6", Heading = "6. 기대 효과 및 결론", Level = 1, KeyPoints = ["정량적 효과", "정성적 효과", "결론"] },
},
"analysis" => new List<SectionPlan>
{
new() { Id = "sec-1", Heading = "1. 분석 개요", Level = 1, KeyPoints = ["분석 목적", "분석 범위", "방법론"] },
new() { Id = "sec-2", Heading = "2. 데이터 현황", Level = 1, KeyPoints = ["데이터 출처", "기본 통계", "데이터 품질"] },
new() { Id = "sec-3", Heading = "3. 정량 분석", Level = 1, KeyPoints = ["수치 분석", "추세", "비교"] },
new() { Id = "sec-4", Heading = "4. 정성 분석", Level = 1, KeyPoints = ["패턴", "인사이트", "이상치"] },
new() { Id = "sec-5", Heading = "5. 종합 해석", Level = 1, KeyPoints = ["핵심 발견", "시사점"] },
new() { Id = "sec-6", Heading = "6. 결론 및 권장사항", Level = 1, KeyPoints = ["결론", "조치 방안", "추가 분석 필요 항목"] },
},
"manual" or "guide" => new List<SectionPlan>
{
new() { Id = "sec-1", Heading = "1. 소개", Level = 1, KeyPoints = ["목적", "대상 독자", "사전 요구사항"] },
new() { Id = "sec-2", Heading = "2. 시작하기", Level = 1, KeyPoints = ["설치", "초기 설정", "기본 사용법"] },
new() { Id = "sec-3", Heading = "3. 주요 기능", Level = 1, KeyPoints = ["기능별 상세 설명", "사용 예시"] },
new() { Id = "sec-4", Heading = "4. 고급 기능", Level = 1, KeyPoints = ["고급 설정", "커스터마이징", "자동화"] },
new() { Id = "sec-5", Heading = "5. 문제 해결", Level = 1, KeyPoints = ["자주 묻는 질문", "에러 대응", "지원 정보"] },
},
"minutes" => new List<SectionPlan>
{
new() { Id = "sec-1", Heading = "1. 회의 정보", Level = 1, KeyPoints = ["일시", "참석자", "장소"] },
new() { Id = "sec-2", Heading = "2. 안건 및 논의", Level = 1, KeyPoints = ["안건별 논의 내용", "주요 의견"] },
new() { Id = "sec-3", Heading = "3. 결정 사항", Level = 1, KeyPoints = ["합의 내용", "변경 사항"] },
new() { Id = "sec-4", Heading = "4. 액션 아이템", Level = 1, KeyPoints = ["담당자", "기한", "세부 내용"] },
new() { Id = "sec-5", Heading = "5. 다음 회의", Level = 1, KeyPoints = ["예정일", "주요 안건"] },
},
_ => new List<SectionPlan>
{
new() { Id = "sec-1", Heading = "1. 개요", Level = 1, KeyPoints = ["배경", "목적", "범위"] },
new() { Id = "sec-2", Heading = "2. 현황", Level = 1, KeyPoints = ["현재 상태", "주요 지표"] },
new() { Id = "sec-3", Heading = "3. 분석", Level = 1, KeyPoints = ["데이터 분석", "비교", "추세"] },
new() { Id = "sec-4", Heading = "4. 주요 발견", Level = 1, KeyPoints = ["핵심 인사이트", "문제점", "기회"] },
new() { Id = "sec-5", Heading = "5. 제안", Level = 1, KeyPoints = ["개선 방안", "실행 계획"] },
new() { Id = "sec-6", Heading = "6. 결론", Level = 1, KeyPoints = ["요약", "기대 효과", "향후 과제"] },
},
};
}
private static void DistributeWordCount(List<SectionPlan> sections, int totalWords)
{
if (sections.Count == 0) return;
var weights = new double[sections.Count];
for (int i = 0; i < sections.Count; i++)
weights[i] = (i == 0 || i == sections.Count - 1) ? 0.7 : 1.2;
var totalWeight = weights.Sum();
for (int i = 0; i < sections.Count; i++)
sections[i].TargetWords = Math.Max(100, (int)(totalWords * weights[i] / totalWeight));
}
private static string SanitizeFileName(string name)
{
var safe = name.Length > 60 ? name[..60] : name;
foreach (var c in Path.GetInvalidFileNameChars())
safe = safe.Replace(c, '_');
return safe.Trim().TrimEnd('.');
}
private static string GetDocTypeLabel(string docType) => docType switch
{
"proposal" => "제안서",
"analysis" => "분석 보고서",
"manual" or "guide" => "매뉴얼/가이드",
"minutes" => "회의록",
"presentation" => "프레젠테이션",
_ => "보고서",
};
private static string Escape(string text)
=> text.Replace("&", "&amp;").Replace("<", "&lt;").Replace(">", "&gt;");
private static readonly JsonSerializerOptions _jsonOptions = new()
{
WriteIndented = true,
Encoder = System.Text.Encodings.Web.JavaScriptEncoder.UnsafeRelaxedJsonEscaping,
PropertyNamingPolicy = JsonNamingPolicy.CamelCase,
};
private class SectionPlan
{
public string Id { get; set; } = "";
public string Heading { get; set; } = "";
public int Level { get; set; } = 1;
public int TargetWords { get; set; } = 300;
public List<string> KeyPoints { get; set; } = new();
}
}

View File

@@ -0,0 +1,248 @@
using System.IO;
using System.Text;
using System.Text.Json;
using System.Text.RegularExpressions;
using DocumentFormat.OpenXml.Packaging;
using DocumentFormat.OpenXml.Spreadsheet;
using UglyToad.PdfPig;
namespace AxCopilot.Services.Agent;
public partial class DocumentReaderTool
{
// ─── BibTeX / RIS / DOCX / XLSX / Text / Helpers ─────────────────────
// ─── BibTeX ─────────────────────────────────────────────────────────────
private static string ReadBibTeX(string path, int maxChars)
{
var content = File.ReadAllText(path, Encoding.UTF8);
var sb = new StringBuilder();
var entryPattern = new Regex(
@"@(\w+)\s*\{\s*([^,\s]+)\s*,\s*(.*?)\n\s*\}",
RegexOptions.Singleline);
var fieldPattern = new Regex(
@"(\w+)\s*=\s*[\{""](.*?)[\}""]",
RegexOptions.Singleline);
var matches = entryPattern.Matches(content);
sb.AppendLine($"BibTeX: {matches.Count}개 항목");
sb.AppendLine();
int idx = 0;
foreach (Match m in matches)
{
if (sb.Length >= maxChars) break;
idx++;
var entryType = m.Groups[1].Value;
var citeKey = m.Groups[2].Value;
var body = m.Groups[3].Value;
sb.AppendLine($"[{idx}] @{entryType}{{{citeKey}}}");
var fields = fieldPattern.Matches(body);
foreach (Match f in fields)
{
var fieldName = f.Groups[1].Value.ToLower();
var fieldValue = f.Groups[2].Value.Trim();
// 핵심 필드만 표시
if (fieldName is "author" or "title" or "journal" or "booktitle"
or "year" or "volume" or "number" or "pages" or "doi"
or "publisher" or "url")
{
sb.AppendLine($" {fieldName}: {fieldValue}");
}
}
sb.AppendLine();
}
if (matches.Count == 0)
{
sb.AppendLine("(BibTeX 항목을 파싱하지 못했습니다. 원문을 반환합니다.)");
sb.AppendLine(Truncate(content, maxChars - sb.Length));
}
return Truncate(sb.ToString(), maxChars);
}
// ─── RIS ────────────────────────────────────────────────────────────────
private static string ReadRis(string path, int maxChars)
{
var lines = File.ReadAllLines(path, Encoding.UTF8);
var sb = new StringBuilder();
var entries = new List<Dictionary<string, List<string>>>();
Dictionary<string, List<string>>? current = null;
foreach (var line in lines)
{
if (line.StartsWith("TY -"))
{
current = new Dictionary<string, List<string>>();
entries.Add(current);
}
else if (line.StartsWith("ER -"))
{
current = null;
continue;
}
if (current != null && line.Length >= 6 && line[2] == ' ' && line[3] == ' ' && line[4] == '-' && line[5] == ' ')
{
var tag = line[..2].Trim();
var value = line[6..].Trim();
if (!current.ContainsKey(tag))
current[tag] = new List<string>();
current[tag].Add(value);
}
}
sb.AppendLine($"RIS: {entries.Count}개 항목");
sb.AppendLine();
// RIS 태그 → 사람이 읽을 수 있는 이름
var tagNames = new Dictionary<string, string>
{
["TY"] = "Type", ["AU"] = "Author", ["TI"] = "Title", ["T1"] = "Title",
["JO"] = "Journal", ["JF"] = "Journal", ["PY"] = "Year", ["Y1"] = "Year",
["VL"] = "Volume", ["IS"] = "Issue", ["SP"] = "Start Page", ["EP"] = "End Page",
["DO"] = "DOI", ["UR"] = "URL", ["PB"] = "Publisher", ["AB"] = "Abstract",
["KW"] = "Keyword", ["SN"] = "ISSN/ISBN",
};
for (int i = 0; i < entries.Count && sb.Length < maxChars; i++)
{
sb.AppendLine($"[{i + 1}]");
var entry = entries[i];
foreach (var (tag, values) in entry)
{
var label = tagNames.GetValueOrDefault(tag, tag);
if (tag is "AU" or "KW")
sb.AppendLine($" {label}: {string.Join("; ", values)}");
else
sb.AppendLine($" {label}: {string.Join(" ", values)}");
}
sb.AppendLine();
}
return Truncate(sb.ToString(), maxChars);
}
// ─── DOCX ───────────────────────────────────────────────────────────────
private static string ReadDocx(string path, int maxChars)
{
var sb = new StringBuilder();
using var doc = WordprocessingDocument.Open(path, false);
var body = doc.MainDocumentPart?.Document.Body;
if (body == null) return "(빈 문서)";
foreach (var para in body.Elements<DocumentFormat.OpenXml.Wordprocessing.Paragraph>())
{
var text = para.InnerText;
if (!string.IsNullOrWhiteSpace(text))
{
sb.AppendLine(text);
if (sb.Length >= maxChars) break;
}
}
return Truncate(sb.ToString(), maxChars);
}
// ─── XLSX ───────────────────────────────────────────────────────────────
private static string ReadXlsx(string path, string sheetParam, int maxChars)
{
var sb = new StringBuilder();
using var doc = SpreadsheetDocument.Open(path, false);
var workbook = doc.WorkbookPart;
if (workbook == null) return "(빈 스프레드시트)";
var sheets = workbook.Workbook.Sheets?.Elements<Sheet>().ToList() ?? [];
if (sheets.Count == 0) return "(시트 없음)";
sb.AppendLine($"Excel: {sheets.Count}개 시트 ({string.Join(", ", sheets.Select(s => s.Name?.Value))})");
sb.AppendLine();
Sheet? targetSheet = null;
if (!string.IsNullOrEmpty(sheetParam))
{
if (int.TryParse(sheetParam, out var idx) && idx >= 1 && idx <= sheets.Count)
targetSheet = sheets[idx - 1];
else
targetSheet = sheets.FirstOrDefault(s =>
string.Equals(s.Name?.Value, sheetParam, StringComparison.OrdinalIgnoreCase));
}
targetSheet ??= sheets[0];
var sheetId = targetSheet.Id?.Value;
if (sheetId == null) return "(시트 ID 없음)";
var wsPart = (WorksheetPart)workbook.GetPartById(sheetId);
var sharedStrings = workbook.SharedStringTablePart?.SharedStringTable
.Elements<SharedStringItem>().ToList() ?? [];
var rows = wsPart.Worksheet.Descendants<Row>().ToList();
sb.AppendLine($"[{targetSheet.Name?.Value}] ({rows.Count} rows)");
foreach (var row in rows)
{
var cells = row.Elements<Cell>().ToList();
var values = new List<string>();
foreach (var cell in cells)
values.Add(GetCellValue(cell, sharedStrings));
sb.AppendLine(string.Join("\t", values));
if (sb.Length >= maxChars) break;
}
return Truncate(sb.ToString(), maxChars);
}
private static string GetCellValue(Cell cell, List<SharedStringItem> sharedStrings)
{
var value = cell.CellValue?.Text ?? "";
if (cell.DataType?.Value == CellValues.SharedString)
{
if (int.TryParse(value, out var idx) && idx >= 0 && idx < sharedStrings.Count)
return sharedStrings[idx].InnerText;
}
return value;
}
// ─── Text ───────────────────────────────────────────────────────────────
private static async Task<string> ReadTextFile(string path, int maxChars, CancellationToken ct)
{
var text = await File.ReadAllTextAsync(path, Encoding.UTF8, ct);
return Truncate(text, maxChars);
}
// ─── Helpers ────────────────────────────────────────────────────────────
private static string Truncate(string text, int maxChars)
{
if (text.Length <= maxChars) return text;
return text[..maxChars] + "\n\n... (내용 잘림 — pages 또는 section 파라미터로 특정 부분을 읽을 수 있습니다)";
}
private static string FormatSize(long bytes) => bytes switch
{
< 1024 => $"{bytes} B",
< 1024 * 1024 => $"{bytes / 1024.0:F1} KB",
_ => $"{bytes / (1024.0 * 1024.0):F1} MB",
};
/// <summary>JsonElement에서 int를 안전하게 추출합니다. string/integer 양쪽 호환.</summary>
private static int GetIntValue(JsonElement el, int defaultValue)
{
if (el.ValueKind == JsonValueKind.Number) return el.GetInt32();
if (el.ValueKind == JsonValueKind.String && int.TryParse(el.GetString(), out var v)) return v;
return defaultValue;
}
}

View File

@@ -12,7 +12,7 @@ namespace AxCopilot.Services.Agent;
/// 문서 파일을 읽어 텍스트로 반환하는 도구.
/// PDF, DOCX, XLSX, CSV, TXT, BibTeX, RIS 등 다양한 형식을 지원합니다.
/// </summary>
public class DocumentReaderTool : IAgentTool
public partial class DocumentReaderTool : IAgentTool
{
public string Name => "document_read";
public string Description =>
@@ -335,237 +335,4 @@ public class DocumentReaderTool : IAgentTool
return entries;
}
// ─── BibTeX ─────────────────────────────────────────────────────────────
private static string ReadBibTeX(string path, int maxChars)
{
var content = File.ReadAllText(path, Encoding.UTF8);
var sb = new StringBuilder();
var entryPattern = new Regex(
@"@(\w+)\s*\{\s*([^,\s]+)\s*,\s*(.*?)\n\s*\}",
RegexOptions.Singleline);
var fieldPattern = new Regex(
@"(\w+)\s*=\s*[\{""](.*?)[\}""]",
RegexOptions.Singleline);
var matches = entryPattern.Matches(content);
sb.AppendLine($"BibTeX: {matches.Count}개 항목");
sb.AppendLine();
int idx = 0;
foreach (Match m in matches)
{
if (sb.Length >= maxChars) break;
idx++;
var entryType = m.Groups[1].Value;
var citeKey = m.Groups[2].Value;
var body = m.Groups[3].Value;
sb.AppendLine($"[{idx}] @{entryType}{{{citeKey}}}");
var fields = fieldPattern.Matches(body);
foreach (Match f in fields)
{
var fieldName = f.Groups[1].Value.ToLower();
var fieldValue = f.Groups[2].Value.Trim();
// 핵심 필드만 표시
if (fieldName is "author" or "title" or "journal" or "booktitle"
or "year" or "volume" or "number" or "pages" or "doi"
or "publisher" or "url")
{
sb.AppendLine($" {fieldName}: {fieldValue}");
}
}
sb.AppendLine();
}
if (matches.Count == 0)
{
sb.AppendLine("(BibTeX 항목을 파싱하지 못했습니다. 원문을 반환합니다.)");
sb.AppendLine(Truncate(content, maxChars - sb.Length));
}
return Truncate(sb.ToString(), maxChars);
}
// ─── RIS ────────────────────────────────────────────────────────────────
private static string ReadRis(string path, int maxChars)
{
var lines = File.ReadAllLines(path, Encoding.UTF8);
var sb = new StringBuilder();
var entries = new List<Dictionary<string, List<string>>>();
Dictionary<string, List<string>>? current = null;
foreach (var line in lines)
{
if (line.StartsWith("TY -"))
{
current = new Dictionary<string, List<string>>();
entries.Add(current);
}
else if (line.StartsWith("ER -"))
{
current = null;
continue;
}
if (current != null && line.Length >= 6 && line[2] == ' ' && line[3] == ' ' && line[4] == '-' && line[5] == ' ')
{
var tag = line[..2].Trim();
var value = line[6..].Trim();
if (!current.ContainsKey(tag))
current[tag] = new List<string>();
current[tag].Add(value);
}
}
sb.AppendLine($"RIS: {entries.Count}개 항목");
sb.AppendLine();
// RIS 태그 → 사람이 읽을 수 있는 이름
var tagNames = new Dictionary<string, string>
{
["TY"] = "Type", ["AU"] = "Author", ["TI"] = "Title", ["T1"] = "Title",
["JO"] = "Journal", ["JF"] = "Journal", ["PY"] = "Year", ["Y1"] = "Year",
["VL"] = "Volume", ["IS"] = "Issue", ["SP"] = "Start Page", ["EP"] = "End Page",
["DO"] = "DOI", ["UR"] = "URL", ["PB"] = "Publisher", ["AB"] = "Abstract",
["KW"] = "Keyword", ["SN"] = "ISSN/ISBN",
};
for (int i = 0; i < entries.Count && sb.Length < maxChars; i++)
{
sb.AppendLine($"[{i + 1}]");
var entry = entries[i];
foreach (var (tag, values) in entry)
{
var label = tagNames.GetValueOrDefault(tag, tag);
if (tag is "AU" or "KW")
sb.AppendLine($" {label}: {string.Join("; ", values)}");
else
sb.AppendLine($" {label}: {string.Join(" ", values)}");
}
sb.AppendLine();
}
return Truncate(sb.ToString(), maxChars);
}
// ─── DOCX ───────────────────────────────────────────────────────────────
private static string ReadDocx(string path, int maxChars)
{
var sb = new StringBuilder();
using var doc = WordprocessingDocument.Open(path, false);
var body = doc.MainDocumentPart?.Document.Body;
if (body == null) return "(빈 문서)";
foreach (var para in body.Elements<DocumentFormat.OpenXml.Wordprocessing.Paragraph>())
{
var text = para.InnerText;
if (!string.IsNullOrWhiteSpace(text))
{
sb.AppendLine(text);
if (sb.Length >= maxChars) break;
}
}
return Truncate(sb.ToString(), maxChars);
}
// ─── XLSX ───────────────────────────────────────────────────────────────
private static string ReadXlsx(string path, string sheetParam, int maxChars)
{
var sb = new StringBuilder();
using var doc = SpreadsheetDocument.Open(path, false);
var workbook = doc.WorkbookPart;
if (workbook == null) return "(빈 스프레드시트)";
var sheets = workbook.Workbook.Sheets?.Elements<Sheet>().ToList() ?? [];
if (sheets.Count == 0) return "(시트 없음)";
sb.AppendLine($"Excel: {sheets.Count}개 시트 ({string.Join(", ", sheets.Select(s => s.Name?.Value))})");
sb.AppendLine();
Sheet? targetSheet = null;
if (!string.IsNullOrEmpty(sheetParam))
{
if (int.TryParse(sheetParam, out var idx) && idx >= 1 && idx <= sheets.Count)
targetSheet = sheets[idx - 1];
else
targetSheet = sheets.FirstOrDefault(s =>
string.Equals(s.Name?.Value, sheetParam, StringComparison.OrdinalIgnoreCase));
}
targetSheet ??= sheets[0];
var sheetId = targetSheet.Id?.Value;
if (sheetId == null) return "(시트 ID 없음)";
var wsPart = (WorksheetPart)workbook.GetPartById(sheetId);
var sharedStrings = workbook.SharedStringTablePart?.SharedStringTable
.Elements<SharedStringItem>().ToList() ?? [];
var rows = wsPart.Worksheet.Descendants<Row>().ToList();
sb.AppendLine($"[{targetSheet.Name?.Value}] ({rows.Count} rows)");
foreach (var row in rows)
{
var cells = row.Elements<Cell>().ToList();
var values = new List<string>();
foreach (var cell in cells)
values.Add(GetCellValue(cell, sharedStrings));
sb.AppendLine(string.Join("\t", values));
if (sb.Length >= maxChars) break;
}
return Truncate(sb.ToString(), maxChars);
}
private static string GetCellValue(Cell cell, List<SharedStringItem> sharedStrings)
{
var value = cell.CellValue?.Text ?? "";
if (cell.DataType?.Value == CellValues.SharedString)
{
if (int.TryParse(value, out var idx) && idx >= 0 && idx < sharedStrings.Count)
return sharedStrings[idx].InnerText;
}
return value;
}
// ─── Text ───────────────────────────────────────────────────────────────
private static async Task<string> ReadTextFile(string path, int maxChars, CancellationToken ct)
{
var text = await File.ReadAllTextAsync(path, Encoding.UTF8, ct);
return Truncate(text, maxChars);
}
// ─── Helpers ────────────────────────────────────────────────────────────
private static string Truncate(string text, int maxChars)
{
if (text.Length <= maxChars) return text;
return text[..maxChars] + "\n\n... (내용 잘림 — pages 또는 section 파라미터로 특정 부분을 읽을 수 있습니다)";
}
private static string FormatSize(long bytes) => bytes switch
{
< 1024 => $"{bytes} B",
< 1024 * 1024 => $"{bytes / 1024.0:F1} KB",
_ => $"{bytes / (1024.0 * 1024.0):F1} MB",
};
/// <summary>JsonElement에서 int를 안전하게 추출합니다. string/integer 양쪽 호환.</summary>
private static int GetIntValue(JsonElement el, int defaultValue)
{
if (el.ValueKind == JsonValueKind.Number) return el.GetInt32();
if (el.ValueKind == JsonValueKind.String && int.TryParse(el.GetString(), out var v)) return v;
return defaultValue;
}
}

View File

@@ -0,0 +1,125 @@
using System.IO;
using System.Runtime.InteropServices;
using System.Security.Cryptography;
using System.Text;
using System.Text.Json;
using System.Text.Json.Serialization;
using System.Text.RegularExpressions;
using System.Windows;
using System.Windows.Interop;
using System.Windows.Media.Imaging;
using AxCopilot.Models;
namespace AxCopilot.Services;
public partial class ClipboardHistoryService
{
// ─── 이미지 캐시 + P/Invoke ─────────────────────────────────────────────
/// <summary>원본 이미지를 캐시 폴더에 PNG로 저장합니다.</summary>
private static string? SaveOriginalImage(BitmapSource src)
{
try
{
Directory.CreateDirectory(ImageCachePath);
var fileName = $"clip_{DateTime.Now:yyyyMMdd_HHmmss_fff}.png";
var filePath = Path.Combine(ImageCachePath, fileName);
var encoder = new PngBitmapEncoder();
encoder.Frames.Add(BitmapFrame.Create(src));
using var fs = new FileStream(filePath, FileMode.Create);
encoder.Save(fs);
return filePath;
}
catch (Exception ex)
{
LogService.Warn($"원본 이미지 저장 실패: {ex.Message}");
return null;
}
}
/// <summary>원본 이미지를 파일에서 로드합니다.</summary>
public static BitmapSource? LoadOriginalImage(string? path)
{
if (string.IsNullOrEmpty(path) || !File.Exists(path)) return null;
try
{
var bi = new BitmapImage();
bi.BeginInit();
bi.CacheOption = BitmapCacheOption.OnLoad;
bi.UriSource = new Uri(path, UriKind.Absolute);
bi.EndInit();
bi.Freeze();
return bi;
}
catch (Exception) { return null; }
}
/// <summary>이미지 캐시 정리 (30일 초과 + 500MB 초과 시 오래된 파일부터 삭제).</summary>
public static void CleanupImageCache()
{
try
{
if (!Directory.Exists(ImageCachePath)) return;
var files = new DirectoryInfo(ImageCachePath)
.GetFiles("clip_*.png")
.OrderBy(f => f.LastWriteTime)
.ToList();
// 30일 초과 파일 삭제
var cutoff = DateTime.Now.AddDays(-MaxCacheAgeDays);
foreach (var f in files.Where(f => f.LastWriteTime < cutoff).ToList())
{
try { f.Delete(); files.Remove(f); } catch (Exception) { }
}
// 500MB 초과 시 오래된 파일부터 삭제
var totalSize = files.Sum(f => f.Length);
while (totalSize > MaxCacheSizeBytes && files.Count > 0)
{
var oldest = files[0];
totalSize -= oldest.Length;
try { oldest.Delete(); } catch (Exception) { }
files.RemoveAt(0);
}
}
catch (Exception ex)
{
LogService.Warn($"이미지 캐시 정리 실패: {ex.Message}");
}
}
private static BitmapSource CreateThumbnail(BitmapSource src, int maxWidth)
{
if (src.PixelWidth <= maxWidth) return src;
var scale = (double)maxWidth / src.PixelWidth;
return new TransformedBitmap(src, new System.Windows.Media.ScaleTransform(scale, scale));
}
private static string? ImageToBase64(BitmapSource? img)
{
if (img == null) return null;
try
{
var encoder = new PngBitmapEncoder();
encoder.Frames.Add(BitmapFrame.Create(img));
using var ms = new MemoryStream();
encoder.Save(ms);
return Convert.ToBase64String(ms.ToArray());
}
catch (Exception) { return null; }
}
private static BitmapSource? Base64ToImage(string? base64)
{
if (string.IsNullOrEmpty(base64)) return null;
try
{
var bytes = Convert.FromBase64String(base64);
using var ms = new MemoryStream(bytes);
var decoder = BitmapDecoder.Create(ms, BitmapCreateOptions.None, BitmapCacheOption.OnLoad);
return decoder.Frames[0];
}
catch (Exception) { return null; }
}
}

View File

@@ -16,7 +16,7 @@ namespace AxCopilot.Services;
/// 클립보드 변경을 감지하여 히스토리를 관리합니다.
/// WM_CLIPBOARDUPDATE 메시지를 수신하기 위해 숨겨진 메시지 창을 생성합니다.
/// </summary>
public class ClipboardHistoryService : IDisposable
public partial class ClipboardHistoryService : IDisposable
{
private const int WM_CLIPBOARDUPDATE = 0x031D;
@@ -415,113 +415,6 @@ public class ClipboardHistoryService : IDisposable
});
}
/// <summary>원본 이미지를 캐시 폴더에 PNG로 저장합니다.</summary>
private static string? SaveOriginalImage(BitmapSource src)
{
try
{
Directory.CreateDirectory(ImageCachePath);
var fileName = $"clip_{DateTime.Now:yyyyMMdd_HHmmss_fff}.png";
var filePath = Path.Combine(ImageCachePath, fileName);
var encoder = new PngBitmapEncoder();
encoder.Frames.Add(BitmapFrame.Create(src));
using var fs = new FileStream(filePath, FileMode.Create);
encoder.Save(fs);
return filePath;
}
catch (Exception ex)
{
LogService.Warn($"원본 이미지 저장 실패: {ex.Message}");
return null;
}
}
/// <summary>원본 이미지를 파일에서 로드합니다.</summary>
public static BitmapSource? LoadOriginalImage(string? path)
{
if (string.IsNullOrEmpty(path) || !File.Exists(path)) return null;
try
{
var bi = new BitmapImage();
bi.BeginInit();
bi.CacheOption = BitmapCacheOption.OnLoad;
bi.UriSource = new Uri(path, UriKind.Absolute);
bi.EndInit();
bi.Freeze();
return bi;
}
catch (Exception) { return null; }
}
/// <summary>이미지 캐시 정리 (30일 초과 + 500MB 초과 시 오래된 파일부터 삭제).</summary>
public static void CleanupImageCache()
{
try
{
if (!Directory.Exists(ImageCachePath)) return;
var files = new DirectoryInfo(ImageCachePath)
.GetFiles("clip_*.png")
.OrderBy(f => f.LastWriteTime)
.ToList();
// 30일 초과 파일 삭제
var cutoff = DateTime.Now.AddDays(-MaxCacheAgeDays);
foreach (var f in files.Where(f => f.LastWriteTime < cutoff).ToList())
{
try { f.Delete(); files.Remove(f); } catch (Exception) { }
}
// 500MB 초과 시 오래된 파일부터 삭제
var totalSize = files.Sum(f => f.Length);
while (totalSize > MaxCacheSizeBytes && files.Count > 0)
{
var oldest = files[0];
totalSize -= oldest.Length;
try { oldest.Delete(); } catch (Exception) { }
files.RemoveAt(0);
}
}
catch (Exception ex)
{
LogService.Warn($"이미지 캐시 정리 실패: {ex.Message}");
}
}
private static BitmapSource CreateThumbnail(BitmapSource src, int maxWidth)
{
if (src.PixelWidth <= maxWidth) return src;
var scale = (double)maxWidth / src.PixelWidth;
return new TransformedBitmap(src, new System.Windows.Media.ScaleTransform(scale, scale));
}
private static string? ImageToBase64(BitmapSource? img)
{
if (img == null) return null;
try
{
var encoder = new PngBitmapEncoder();
encoder.Frames.Add(BitmapFrame.Create(img));
using var ms = new MemoryStream();
encoder.Save(ms);
return Convert.ToBase64String(ms.ToArray());
}
catch (Exception) { return null; }
}
private static BitmapSource? Base64ToImage(string? base64)
{
if (string.IsNullOrEmpty(base64)) return null;
try
{
var bytes = Convert.FromBase64String(base64);
using var ms = new MemoryStream(bytes);
var decoder = BitmapDecoder.Create(ms, BitmapCreateOptions.None, BitmapCacheOption.OnLoad);
return decoder.Frames[0];
}
catch (Exception) { return null; }
}
// ─── P/Invoke ──────────────────────────────────────────────────────────
[DllImport("user32.dll", SetLastError = true)]

View File

@@ -0,0 +1,204 @@
using System.IO;
using System.Text;
using System.Text.RegularExpressions;
using Microsoft.Data.Sqlite;
namespace AxCopilot.Services;
public partial class CodeIndexService
{
// ── 검색 + TF-IDF + Dispose ──────────────────────────────────────────
// ── 검색 ────────────────────────────────────────────────────────────
/// <summary>시맨틱 검색: 질문과 가장 관련 있는 코드 청크를 반환합니다.</summary>
public List<SearchResult> Search(string query, int maxResults = 5)
{
if (!_indexed || _db == null || _totalDocs == 0)
return new();
var queryTokens = Tokenize(query);
if (queryTokens.Count == 0) return new();
// 쿼리 토큰의 DF 조회
var dfMap = new Dictionary<string, int>();
foreach (var token in queryTokens.Keys)
{
using var cmd = _db.CreateCommand();
cmd.CommandText = "SELECT df FROM doc_freq WHERE token = @t";
cmd.Parameters.AddWithValue("@t", token);
var result = cmd.ExecuteScalar();
if (result != null) dfMap[token] = Convert.ToInt32(result);
}
// 후보 청크 검색: 쿼리 토큰이 하나라도 포함된 청크만
var candidateChunks = new HashSet<int>();
foreach (var token in queryTokens.Keys)
{
using var cmd = _db.CreateCommand();
cmd.CommandText = "SELECT DISTINCT chunk_id FROM tokens WHERE token = @t";
cmd.Parameters.AddWithValue("@t", token);
using var reader = cmd.ExecuteReader();
while (reader.Read()) candidateChunks.Add(reader.GetInt32(0));
}
if (candidateChunks.Count == 0) return new();
// 각 후보 청크의 TF-IDF 유사도 계산
var scored = new List<(int ChunkId, double Score)>();
foreach (var chunkId in candidateChunks)
{
// 청크의 토큰 TF 로드
var docTf = new Dictionary<string, int>(StringComparer.OrdinalIgnoreCase);
using (var cmd = _db.CreateCommand())
{
cmd.CommandText = "SELECT token, tf FROM tokens WHERE chunk_id = @cid";
cmd.Parameters.AddWithValue("@cid", chunkId);
using var reader = cmd.ExecuteReader();
while (reader.Read())
docTf[reader.GetString(0)] = reader.GetInt32(1);
}
var score = ComputeTfIdfSimilarity(queryTokens, docTf, dfMap);
if (score > 0.01)
scored.Add((chunkId, score));
}
// 상위 결과 추출
var topChunks = scored
.OrderByDescending(s => s.Score)
.Take(maxResults)
.ToList();
var results = new List<SearchResult>();
foreach (var (chunkId, score) in topChunks)
{
using var cmd = _db.CreateCommand();
cmd.CommandText = """
SELECT f.path, c.start_line, c.end_line, c.content
FROM chunks c JOIN files f ON c.file_id = f.id
WHERE c.id = @cid
""";
cmd.Parameters.AddWithValue("@cid", chunkId);
using var reader = cmd.ExecuteReader();
if (reader.Read())
{
results.Add(new SearchResult
{
FilePath = reader.GetString(0),
StartLine = reader.GetInt32(1),
EndLine = reader.GetInt32(2),
Score = score,
Preview = reader.GetString(3) is { Length: > 200 } s ? s[..200] + "..." : reader.GetString(3),
});
}
}
return results;
}
/// <summary>기존 인덱스가 있으면 로드합니다 (앱 재시작 시).</summary>
public void TryLoadExisting(string workFolder)
{
if (string.IsNullOrEmpty(workFolder) || !Directory.Exists(workFolder)) return;
var dbPath = GetDbPath(workFolder);
if (!File.Exists(dbPath)) return;
EnsureDb(workFolder);
_totalDocs = GetTotalChunkCount();
_indexed = _totalDocs > 0;
if (_indexed)
LogService.Info($"기존 코드 인덱스 로드: {_totalDocs}개 청크 [{workFolder}]");
}
// ── TF-IDF 계산 ─────────────────────────────────────────────────────
private double ComputeTfIdfSimilarity(
Dictionary<string, int> queryTf,
Dictionary<string, int> docTf,
Dictionary<string, int> dfMap)
{
double dotProduct = 0, queryNorm = 0, docNorm = 0;
foreach (var (token, qtf) in queryTf)
{
var df = dfMap.GetValueOrDefault(token, 0);
var idf = Math.Log(1.0 + _totalDocs / (1.0 + df));
var qWeight = qtf * idf;
queryNorm += qWeight * qWeight;
if (docTf.TryGetValue(token, out var dtf))
{
var dWeight = dtf * idf;
dotProduct += qWeight * dWeight;
}
}
foreach (var (token, dtf) in docTf)
{
var df = dfMap.GetValueOrDefault(token, 0);
var idf = Math.Log(1.0 + _totalDocs / (1.0 + df));
var dWeight = dtf * idf;
docNorm += dWeight * dWeight;
}
if (queryNorm == 0 || docNorm == 0) return 0;
return dotProduct / (Math.Sqrt(queryNorm) * Math.Sqrt(docNorm));
}
// ── 토큰화 ──────────────────────────────────────────────────────────
/// <summary>텍스트를 토큰으로 분할하고 빈도를 계산합니다. 스톱워드 제거 포함.</summary>
private static Dictionary<string, int> Tokenize(string text)
{
var tf = new Dictionary<string, int>(StringComparer.OrdinalIgnoreCase);
var words = Regex.Split(text, @"[^a-zA-Z0-9가-힣_]+")
.SelectMany(SplitCamelCase)
.Where(w => w.Length >= 2 && !StopWords.Contains(w));
foreach (var word in words)
{
var lower = word.ToLowerInvariant();
tf.TryGetValue(lower, out var count);
tf[lower] = count + 1;
}
// 바이그램 추가 (구문 검색 품질 향상)
var wordList = words.Select(w => w.ToLowerInvariant()).ToList();
for (int i = 0; i < wordList.Count - 1; i++)
{
var bigram = $"{wordList[i]}_{wordList[i + 1]}";
tf.TryGetValue(bigram, out var bc);
tf[bigram] = bc + 1;
}
return tf;
}
private static IEnumerable<string> SplitCamelCase(string word)
{
if (string.IsNullOrEmpty(word)) yield break;
var sb = new StringBuilder();
foreach (var ch in word)
{
if (char.IsUpper(ch) && sb.Length > 0)
{
yield return sb.ToString();
sb.Clear();
}
sb.Append(ch);
}
if (sb.Length > 0) yield return sb.ToString();
}
// ── Dispose ─────────────────────────────────────────────────────────
public void Dispose()
{
_db?.Dispose();
_db = null;
}
}

View File

@@ -11,7 +11,7 @@ namespace AxCopilot.Services;
/// 증분 업데이트와 빠른 재시작을 지원합니다.
/// (로컬 전용, 외부 서버 불필요)
/// </summary>
public class CodeIndexService : IDisposable
public partial class CodeIndexService : IDisposable
{
private static App? CurrentApp => System.Windows.Application.Current as App;
@@ -371,199 +371,6 @@ public class CodeIndexService : IDisposable
cmd.Parameters.AddWithValue("@v", value);
cmd.ExecuteNonQuery();
}
// ── 검색 ────────────────────────────────────────────────────────────
/// <summary>시맨틱 검색: 질문과 가장 관련 있는 코드 청크를 반환합니다.</summary>
public List<SearchResult> Search(string query, int maxResults = 5)
{
if (!_indexed || _db == null || _totalDocs == 0)
return new();
var queryTokens = Tokenize(query);
if (queryTokens.Count == 0) return new();
// 쿼리 토큰의 DF 조회
var dfMap = new Dictionary<string, int>();
foreach (var token in queryTokens.Keys)
{
using var cmd = _db.CreateCommand();
cmd.CommandText = "SELECT df FROM doc_freq WHERE token = @t";
cmd.Parameters.AddWithValue("@t", token);
var result = cmd.ExecuteScalar();
if (result != null) dfMap[token] = Convert.ToInt32(result);
}
// 후보 청크 검색: 쿼리 토큰이 하나라도 포함된 청크만
var candidateChunks = new HashSet<int>();
foreach (var token in queryTokens.Keys)
{
using var cmd = _db.CreateCommand();
cmd.CommandText = "SELECT DISTINCT chunk_id FROM tokens WHERE token = @t";
cmd.Parameters.AddWithValue("@t", token);
using var reader = cmd.ExecuteReader();
while (reader.Read()) candidateChunks.Add(reader.GetInt32(0));
}
if (candidateChunks.Count == 0) return new();
// 각 후보 청크의 TF-IDF 유사도 계산
var scored = new List<(int ChunkId, double Score)>();
foreach (var chunkId in candidateChunks)
{
// 청크의 토큰 TF 로드
var docTf = new Dictionary<string, int>(StringComparer.OrdinalIgnoreCase);
using (var cmd = _db.CreateCommand())
{
cmd.CommandText = "SELECT token, tf FROM tokens WHERE chunk_id = @cid";
cmd.Parameters.AddWithValue("@cid", chunkId);
using var reader = cmd.ExecuteReader();
while (reader.Read())
docTf[reader.GetString(0)] = reader.GetInt32(1);
}
var score = ComputeTfIdfSimilarity(queryTokens, docTf, dfMap);
if (score > 0.01)
scored.Add((chunkId, score));
}
// 상위 결과 추출
var topChunks = scored
.OrderByDescending(s => s.Score)
.Take(maxResults)
.ToList();
var results = new List<SearchResult>();
foreach (var (chunkId, score) in topChunks)
{
using var cmd = _db.CreateCommand();
cmd.CommandText = """
SELECT f.path, c.start_line, c.end_line, c.content
FROM chunks c JOIN files f ON c.file_id = f.id
WHERE c.id = @cid
""";
cmd.Parameters.AddWithValue("@cid", chunkId);
using var reader = cmd.ExecuteReader();
if (reader.Read())
{
results.Add(new SearchResult
{
FilePath = reader.GetString(0),
StartLine = reader.GetInt32(1),
EndLine = reader.GetInt32(2),
Score = score,
Preview = reader.GetString(3) is { Length: > 200 } s ? s[..200] + "..." : reader.GetString(3),
});
}
}
return results;
}
/// <summary>기존 인덱스가 있으면 로드합니다 (앱 재시작 시).</summary>
public void TryLoadExisting(string workFolder)
{
if (string.IsNullOrEmpty(workFolder) || !Directory.Exists(workFolder)) return;
var dbPath = GetDbPath(workFolder);
if (!File.Exists(dbPath)) return;
EnsureDb(workFolder);
_totalDocs = GetTotalChunkCount();
_indexed = _totalDocs > 0;
if (_indexed)
LogService.Info($"기존 코드 인덱스 로드: {_totalDocs}개 청크 [{workFolder}]");
}
// ── TF-IDF 계산 ─────────────────────────────────────────────────────
private double ComputeTfIdfSimilarity(
Dictionary<string, int> queryTf,
Dictionary<string, int> docTf,
Dictionary<string, int> dfMap)
{
double dotProduct = 0, queryNorm = 0, docNorm = 0;
foreach (var (token, qtf) in queryTf)
{
var df = dfMap.GetValueOrDefault(token, 0);
var idf = Math.Log(1.0 + _totalDocs / (1.0 + df));
var qWeight = qtf * idf;
queryNorm += qWeight * qWeight;
if (docTf.TryGetValue(token, out var dtf))
{
var dWeight = dtf * idf;
dotProduct += qWeight * dWeight;
}
}
foreach (var (token, dtf) in docTf)
{
var df = dfMap.GetValueOrDefault(token, 0);
var idf = Math.Log(1.0 + _totalDocs / (1.0 + df));
var dWeight = dtf * idf;
docNorm += dWeight * dWeight;
}
if (queryNorm == 0 || docNorm == 0) return 0;
return dotProduct / (Math.Sqrt(queryNorm) * Math.Sqrt(docNorm));
}
// ── 토큰화 ──────────────────────────────────────────────────────────
/// <summary>텍스트를 토큰으로 분할하고 빈도를 계산합니다. 스톱워드 제거 포함.</summary>
private static Dictionary<string, int> Tokenize(string text)
{
var tf = new Dictionary<string, int>(StringComparer.OrdinalIgnoreCase);
var words = Regex.Split(text, @"[^a-zA-Z0-9가-힣_]+")
.SelectMany(SplitCamelCase)
.Where(w => w.Length >= 2 && !StopWords.Contains(w));
foreach (var word in words)
{
var lower = word.ToLowerInvariant();
tf.TryGetValue(lower, out var count);
tf[lower] = count + 1;
}
// 바이그램 추가 (구문 검색 품질 향상)
var wordList = words.Select(w => w.ToLowerInvariant()).ToList();
for (int i = 0; i < wordList.Count - 1; i++)
{
var bigram = $"{wordList[i]}_{wordList[i + 1]}";
tf.TryGetValue(bigram, out var bc);
tf[bigram] = bc + 1;
}
return tf;
}
private static IEnumerable<string> SplitCamelCase(string word)
{
if (string.IsNullOrEmpty(word)) yield break;
var sb = new StringBuilder();
foreach (var ch in word)
{
if (char.IsUpper(ch) && sb.Length > 0)
{
yield return sb.ToString();
sb.Clear();
}
sb.Append(ch);
}
if (sb.Length > 0) yield return sb.ToString();
}
// ── Dispose ─────────────────────────────────────────────────────────
public void Dispose()
{
_db?.Dispose();
_db = null;
}
}
/// <summary>인덱싱된 코드 청크.</summary>

View File

@@ -0,0 +1,166 @@
using System.IO;
namespace AxCopilot.Services;
public partial class IndexService
{
// ─── 경로 탐색 헬퍼 + 검색 캐시 ──────────────────────────────────────────
private static string? FindOfficeApp(string exeName)
{
var roots = new[]
{
@"C:\Program Files\Microsoft Office\root\Office16",
@"C:\Program Files (x86)\Microsoft Office\root\Office16",
@"C:\Program Files\Microsoft Office\Office16",
@"C:\Program Files (x86)\Microsoft Office\Office16",
};
foreach (var root in roots)
{
var path = Path.Combine(root, exeName);
if (File.Exists(path)) return path;
}
return null;
}
private static string? FindTeams()
{
var localApp = Environment.GetFolderPath(Environment.SpecialFolder.LocalApplicationData);
var path = Path.Combine(localApp, @"Microsoft\Teams\current\Teams.exe");
if (File.Exists(path)) return path;
// New Teams (MSIX)
var msTeams = Path.Combine(localApp, @"Microsoft\WindowsApps\ms-teams.exe");
return File.Exists(msTeams) ? msTeams : null;
}
private static string? FindInPath(string fileName)
{
var pathEnv = Environment.GetEnvironmentVariable("PATH") ?? "";
foreach (var dir in pathEnv.Split(';'))
{
if (string.IsNullOrWhiteSpace(dir)) continue;
var full = Path.Combine(dir.Trim(), fileName);
if (File.Exists(full)) return full;
}
return null;
}
private static string? FindInLocalAppData(string relativePath)
{
var localApp = Environment.GetFolderPath(Environment.SpecialFolder.LocalApplicationData);
var path = Path.Combine(localApp, relativePath);
return File.Exists(path) ? path : null;
}
/// <summary>ProgramFiles / ProgramFiles(x86) 두 곳을 탐색합니다.</summary>
private static string? FindInProgramFiles(string relativePath)
{
var pf = Environment.GetFolderPath(Environment.SpecialFolder.ProgramFiles);
var pf86 = Environment.GetFolderPath(Environment.SpecialFolder.ProgramFilesX86);
var p1 = Path.Combine(pf, relativePath);
if (File.Exists(p1)) return p1;
var p2 = Path.Combine(pf86, relativePath);
return File.Exists(p2) ? p2 : null;
}
/// <summary>AppData\Roaming 경로를 탐색합니다.</summary>
private static string? FindInRoaming(string relativePath)
{
var roaming = Environment.GetFolderPath(Environment.SpecialFolder.ApplicationData);
var path = Path.Combine(roaming, relativePath);
return File.Exists(path) ? path : null;
}
// ─── 검색 가속 캐시 계산 ──────────────────────────────────────────────────
/// <summary>빌드 완료 후 전체 항목의 검색 캐시를 한 번에 계산합니다.</summary>
private static void ComputeAllSearchCaches(List<IndexEntry> entries)
{
foreach (var e in entries)
ComputeSearchCache(e);
}
/// <summary>항목 1개의 NameLower / NameJamo / NameChosung 캐시를 계산합니다.</summary>
private static void ComputeSearchCache(IndexEntry entry)
{
entry.NameLower = entry.Name.ToLowerInvariant();
// 자모 분리 (FuzzyEngine static 메서드 — 동일 어셈블리 internal 접근)
entry.NameJamo = AxCopilot.Core.FuzzyEngine.DecomposeToJamo(entry.NameLower);
// 초성 문자열 (예: "엑셀" → "ㅇㅅ", "메모장" → "ㅁㅁㅈ")
var sb = new System.Text.StringBuilder(entry.NameLower.Length);
foreach (var c in entry.NameLower)
{
var cho = AxCopilot.Core.FuzzyEngine.GetChosung(c);
if (cho != '\0') sb.Append(cho);
}
entry.NameChosung = sb.ToString();
}
/// <summary>인덱싱 속도에 따른 throttle 간격(ms). N개 파일마다 yield.</summary>
private static (int batchSize, int delayMs) GetThrottle(string speed) => speed switch
{
"fast" => (500, 0), // 최대 속도, CPU 양보 없음
"slow" => (50, 15), // 50개마다 15ms 양보 → PC 부하 최소
_ => (150, 5), // normal: 150개마다 5ms → 적정 균형
};
private static async Task ScanDirectoryAsync(string dir, List<IndexEntry> entries,
HashSet<string> allowedExts, string indexSpeed, CancellationToken ct)
{
var (batchSize, delayMs) = GetThrottle(indexSpeed);
await Task.Run(async () =>
{
try
{
int count = 0;
// 파일 인덱싱 (확장자 필터 적용)
foreach (var file in Directory.EnumerateFiles(dir, "*.*", SearchOption.AllDirectories))
{
ct.ThrowIfCancellationRequested();
var ext = Path.GetExtension(file).ToLowerInvariant();
if (allowedExts.Count > 0 && !allowedExts.Contains(ext)) continue;
var name = Path.GetFileNameWithoutExtension(file);
if (string.IsNullOrEmpty(name)) continue;
var type = ext switch
{
".exe" => IndexEntryType.App,
".lnk" or ".url" => IndexEntryType.File,
_ => IndexEntryType.File
};
entries.Add(new IndexEntry
{
Name = name,
DisplayName = ext is ".exe" or ".lnk" or ".url" ? name : name + ext,
Path = file,
Type = type
});
// 속도 조절: batchSize개마다 CPU 양보
if (delayMs > 0 && ++count % batchSize == 0)
await Task.Delay(delayMs, ct);
}
// 폴더 인덱싱 (1단계 하위 폴더)
foreach (var subDir in Directory.EnumerateDirectories(dir, "*", SearchOption.TopDirectoryOnly))
{
ct.ThrowIfCancellationRequested();
var name = Path.GetFileName(subDir);
if (name.StartsWith(".")) continue;
entries.Add(new IndexEntry
{
Name = name,
DisplayName = name,
Path = subDir,
Type = IndexEntryType.Folder
});
}
}
catch (UnauthorizedAccessException ex)
{
LogService.Warn($"폴더 접근 불가 (건너뜀): {dir} - {ex.Message}");
}
}, ct);
}
}

View File

@@ -8,7 +8,7 @@ namespace AxCopilot.Services;
/// 파일/앱 인덱싱 서비스. Fuzzy 검색의 데이터 소스를 관리합니다.
/// FileSystemWatcher로 변경 시 자동 재빌드(3초 디바운스)합니다.
/// </summary>
public class IndexService : IDisposable
public partial class IndexService : IDisposable
{
private readonly SettingsService _settings;
private List<IndexEntry> _index = new();
@@ -387,163 +387,6 @@ public class IndexService : IDisposable
}
}
private static string? FindOfficeApp(string exeName)
{
var roots = new[]
{
@"C:\Program Files\Microsoft Office\root\Office16",
@"C:\Program Files (x86)\Microsoft Office\root\Office16",
@"C:\Program Files\Microsoft Office\Office16",
@"C:\Program Files (x86)\Microsoft Office\Office16",
};
foreach (var root in roots)
{
var path = Path.Combine(root, exeName);
if (File.Exists(path)) return path;
}
return null;
}
private static string? FindTeams()
{
var localApp = Environment.GetFolderPath(Environment.SpecialFolder.LocalApplicationData);
var path = Path.Combine(localApp, @"Microsoft\Teams\current\Teams.exe");
if (File.Exists(path)) return path;
// New Teams (MSIX)
var msTeams = Path.Combine(localApp, @"Microsoft\WindowsApps\ms-teams.exe");
return File.Exists(msTeams) ? msTeams : null;
}
private static string? FindInPath(string fileName)
{
var pathEnv = Environment.GetEnvironmentVariable("PATH") ?? "";
foreach (var dir in pathEnv.Split(';'))
{
if (string.IsNullOrWhiteSpace(dir)) continue;
var full = Path.Combine(dir.Trim(), fileName);
if (File.Exists(full)) return full;
}
return null;
}
private static string? FindInLocalAppData(string relativePath)
{
var localApp = Environment.GetFolderPath(Environment.SpecialFolder.LocalApplicationData);
var path = Path.Combine(localApp, relativePath);
return File.Exists(path) ? path : null;
}
/// <summary>ProgramFiles / ProgramFiles(x86) 두 곳을 탐색합니다.</summary>
private static string? FindInProgramFiles(string relativePath)
{
var pf = Environment.GetFolderPath(Environment.SpecialFolder.ProgramFiles);
var pf86 = Environment.GetFolderPath(Environment.SpecialFolder.ProgramFilesX86);
var p1 = Path.Combine(pf, relativePath);
if (File.Exists(p1)) return p1;
var p2 = Path.Combine(pf86, relativePath);
return File.Exists(p2) ? p2 : null;
}
/// <summary>AppData\Roaming 경로를 탐색합니다.</summary>
private static string? FindInRoaming(string relativePath)
{
var roaming = Environment.GetFolderPath(Environment.SpecialFolder.ApplicationData);
var path = Path.Combine(roaming, relativePath);
return File.Exists(path) ? path : null;
}
// ─── 검색 가속 캐시 계산 ──────────────────────────────────────────────────
/// <summary>빌드 완료 후 전체 항목의 검색 캐시를 한 번에 계산합니다.</summary>
private static void ComputeAllSearchCaches(List<IndexEntry> entries)
{
foreach (var e in entries)
ComputeSearchCache(e);
}
/// <summary>항목 1개의 NameLower / NameJamo / NameChosung 캐시를 계산합니다.</summary>
private static void ComputeSearchCache(IndexEntry entry)
{
entry.NameLower = entry.Name.ToLowerInvariant();
// 자모 분리 (FuzzyEngine static 메서드 — 동일 어셈블리 internal 접근)
entry.NameJamo = AxCopilot.Core.FuzzyEngine.DecomposeToJamo(entry.NameLower);
// 초성 문자열 (예: "엑셀" → "ㅇㅅ", "메모장" → "ㅁㅁㅈ")
var sb = new System.Text.StringBuilder(entry.NameLower.Length);
foreach (var c in entry.NameLower)
{
var cho = AxCopilot.Core.FuzzyEngine.GetChosung(c);
if (cho != '\0') sb.Append(cho);
}
entry.NameChosung = sb.ToString();
}
/// <summary>인덱싱 속도에 따른 throttle 간격(ms). N개 파일마다 yield.</summary>
private static (int batchSize, int delayMs) GetThrottle(string speed) => speed switch
{
"fast" => (500, 0), // 최대 속도, CPU 양보 없음
"slow" => (50, 15), // 50개마다 15ms 양보 → PC 부하 최소
_ => (150, 5), // normal: 150개마다 5ms → 적정 균형
};
private static async Task ScanDirectoryAsync(string dir, List<IndexEntry> entries,
HashSet<string> allowedExts, string indexSpeed, CancellationToken ct)
{
var (batchSize, delayMs) = GetThrottle(indexSpeed);
await Task.Run(async () =>
{
try
{
int count = 0;
// 파일 인덱싱 (확장자 필터 적용)
foreach (var file in Directory.EnumerateFiles(dir, "*.*", SearchOption.AllDirectories))
{
ct.ThrowIfCancellationRequested();
var ext = Path.GetExtension(file).ToLowerInvariant();
if (allowedExts.Count > 0 && !allowedExts.Contains(ext)) continue;
var name = Path.GetFileNameWithoutExtension(file);
if (string.IsNullOrEmpty(name)) continue;
var type = ext switch
{
".exe" => IndexEntryType.App,
".lnk" or ".url" => IndexEntryType.File,
_ => IndexEntryType.File
};
entries.Add(new IndexEntry
{
Name = name,
DisplayName = ext is ".exe" or ".lnk" or ".url" ? name : name + ext,
Path = file,
Type = type
});
// 속도 조절: batchSize개마다 CPU 양보
if (delayMs > 0 && ++count % batchSize == 0)
await Task.Delay(delayMs, ct);
}
// 폴더 인덱싱 (1단계 하위 폴더)
foreach (var subDir in Directory.EnumerateDirectories(dir, "*", SearchOption.TopDirectoryOnly))
{
ct.ThrowIfCancellationRequested();
var name = Path.GetFileName(subDir);
if (name.StartsWith(".")) continue;
entries.Add(new IndexEntry
{
Name = name,
DisplayName = name,
Path = subDir,
Type = IndexEntryType.Folder
});
}
}
catch (UnauthorizedAccessException ex)
{
LogService.Warn($"폴더 접근 불가 (건너뜀): {dir} - {ex.Message}");
}
}, ct);
}
}
public class IndexEntry

View File

@@ -451,143 +451,4 @@ public partial class LauncherWindow
}
}
}
// ─── 단축키 도움말 ────────────────────────────────────────────────────────
/// <summary>단축키 도움말 팝업</summary>
private void ShowShortcutHelp()
{
var lines = new[]
{
"[ 전역 ]",
"Alt+Space AX Commander 열기/닫기",
"",
"[ 탐색 ]",
"↑ / ↓ 결과 이동",
"Enter 선택 실행",
"Tab 자동완성",
"→ 액션 모드",
"Escape 닫기 / 뒤로",
"",
"[ 기능 ]",
"F1 도움말",
"F2 파일 이름 바꾸기",
"F5 인덱스 새로 고침",
"Delete 항목 제거",
"Ctrl+, 설정",
"Ctrl+L 입력 초기화",
"Ctrl+C 이름 복사",
"Ctrl+H 클립보드 히스토리",
"Ctrl+R 최근 실행",
"Ctrl+B 즐겨찾기",
"Ctrl+K 이 도움말",
"Ctrl+1~9 N번째 실행",
"Ctrl+Shift+C 경로 복사",
"Ctrl+Shift+E 탐색기에서 열기",
"Ctrl+Enter 관리자 실행",
"Alt+Enter 속성 보기",
"Shift+Enter 대형 텍스트",
};
CustomMessageBox.Show(
string.Join("\n", lines),
"AX Commander — 단축키 도움말",
MessageBoxButton.OK,
MessageBoxImage.Information);
}
// ─── 토스트 알림 ──────────────────────────────────────────────────────────
/// <summary>오버레이 토스트 표시 (페이드인 → 2초 대기 → 페이드아웃)</summary>
private void ShowToast(string message, string icon = "\uE73E")
{
ToastText.Text = message;
ToastIcon.Text = icon;
ToastOverlay.Visibility = Visibility.Visible;
ToastOverlay.Opacity = 0;
// 페이드인
var fadeIn = (System.Windows.Media.Animation.Storyboard)FindResource("ToastFadeIn");
fadeIn.Begin(this);
_indexStatusTimer?.Stop();
_indexStatusTimer = new System.Windows.Threading.DispatcherTimer
{
Interval = TimeSpan.FromSeconds(2)
};
_indexStatusTimer.Tick += (_, _) =>
{
_indexStatusTimer.Stop();
// 페이드아웃 후 Collapsed
var fadeOut = (System.Windows.Media.Animation.Storyboard)FindResource("ToastFadeOut");
EventHandler? onCompleted = null;
onCompleted = (__, ___) =>
{
fadeOut.Completed -= onCompleted;
ToastOverlay.Visibility = Visibility.Collapsed;
};
fadeOut.Completed += onCompleted;
fadeOut.Begin(this);
};
_indexStatusTimer.Start();
}
// ─── 특수 액션 처리 ───────────────────────────────────────────────────────
/// <summary>
/// 액션 모드에서 특수 처리가 필요한 동작(삭제/이름변경)을 처리합니다.
/// 처리되면 true 반환 → ExecuteSelectedAsync 호출 생략.
/// </summary>
private bool TryHandleSpecialAction()
{
if (_vm.SelectedItem?.Data is not AxCopilot.ViewModels.FileActionData actionData)
return false;
switch (actionData.Action)
{
case AxCopilot.ViewModels.FileAction.DeleteToRecycleBin:
{
var path = actionData.Path;
var name = System.IO.Path.GetFileName(path);
var r = CustomMessageBox.Show(
$"'{name}'\n\n이 항목을 휴지통으로 보내겠습니까?",
"AX Copilot — 삭제 확인",
MessageBoxButton.OKCancel,
MessageBoxImage.Warning);
if (r == MessageBoxResult.OK)
{
try
{
SendToRecycleBin(path);
_vm.ExitActionMode();
ShowToast("휴지통으로 이동됨", "\uE74D");
}
catch (Exception ex)
{
CustomMessageBox.Show($"삭제 실패: {ex.Message}", "오류",
MessageBoxButton.OK, MessageBoxImage.Error);
}
}
else
{
_vm.ExitActionMode();
}
return true;
}
case AxCopilot.ViewModels.FileAction.Rename:
{
var path = actionData.Path;
_vm.ExitActionMode();
_vm.InputText = $"rename {path}";
Dispatcher.BeginInvoke(() => { InputBox.CaretIndex = InputBox.Text.Length; },
System.Windows.Threading.DispatcherPriority.Input);
return true;
}
default:
return false;
}
}
}

View File

@@ -0,0 +1,148 @@
using System.Windows;
using System.Windows.Input;
using AxCopilot.Services;
using AxCopilot.ViewModels;
namespace AxCopilot.Views;
public partial class LauncherWindow
{
// ─── 단축키 도움말 + 토스트 + 특수 액션 ────────────────────────────────────
/// <summary>단축키 도움말 팝업</summary>
private void ShowShortcutHelp()
{
var lines = new[]
{
"[ 전역 ]",
"Alt+Space AX Commander 열기/닫기",
"",
"[ 탐색 ]",
"↑ / ↓ 결과 이동",
"Enter 선택 실행",
"Tab 자동완성",
"→ 액션 모드",
"Escape 닫기 / 뒤로",
"",
"[ 기능 ]",
"F1 도움말",
"F2 파일 이름 바꾸기",
"F5 인덱스 새로 고침",
"Delete 항목 제거",
"Ctrl+, 설정",
"Ctrl+L 입력 초기화",
"Ctrl+C 이름 복사",
"Ctrl+H 클립보드 히스토리",
"Ctrl+R 최근 실행",
"Ctrl+B 즐겨찾기",
"Ctrl+K 이 도움말",
"Ctrl+1~9 N번째 실행",
"Ctrl+Shift+C 경로 복사",
"Ctrl+Shift+E 탐색기에서 열기",
"Ctrl+Enter 관리자 실행",
"Alt+Enter 속성 보기",
"Shift+Enter 대형 텍스트",
};
CustomMessageBox.Show(
string.Join("\n", lines),
"AX Commander — 단축키 도움말",
MessageBoxButton.OK,
MessageBoxImage.Information);
}
// ─── 토스트 알림 ──────────────────────────────────────────────────────────
/// <summary>오버레이 토스트 표시 (페이드인 → 2초 대기 → 페이드아웃)</summary>
private void ShowToast(string message, string icon = "\uE73E")
{
ToastText.Text = message;
ToastIcon.Text = icon;
ToastOverlay.Visibility = Visibility.Visible;
ToastOverlay.Opacity = 0;
// 페이드인
var fadeIn = (System.Windows.Media.Animation.Storyboard)FindResource("ToastFadeIn");
fadeIn.Begin(this);
_indexStatusTimer?.Stop();
_indexStatusTimer = new System.Windows.Threading.DispatcherTimer
{
Interval = TimeSpan.FromSeconds(2)
};
_indexStatusTimer.Tick += (_, _) =>
{
_indexStatusTimer.Stop();
// 페이드아웃 후 Collapsed
var fadeOut = (System.Windows.Media.Animation.Storyboard)FindResource("ToastFadeOut");
EventHandler? onCompleted = null;
onCompleted = (__, ___) =>
{
fadeOut.Completed -= onCompleted;
ToastOverlay.Visibility = Visibility.Collapsed;
};
fadeOut.Completed += onCompleted;
fadeOut.Begin(this);
};
_indexStatusTimer.Start();
}
// ─── 특수 액션 처리 ───────────────────────────────────────────────────────
/// <summary>
/// 액션 모드에서 특수 처리가 필요한 동작(삭제/이름변경)을 처리합니다.
/// 처리되면 true 반환 → ExecuteSelectedAsync 호출 생략.
/// </summary>
private bool TryHandleSpecialAction()
{
if (_vm.SelectedItem?.Data is not AxCopilot.ViewModels.FileActionData actionData)
return false;
switch (actionData.Action)
{
case AxCopilot.ViewModels.FileAction.DeleteToRecycleBin:
{
var path = actionData.Path;
var name = System.IO.Path.GetFileName(path);
var r = CustomMessageBox.Show(
$"'{name}'\n\n이 항목을 휴지통으로 보내겠습니까?",
"AX Copilot — 삭제 확인",
MessageBoxButton.OKCancel,
MessageBoxImage.Warning);
if (r == MessageBoxResult.OK)
{
try
{
SendToRecycleBin(path);
_vm.ExitActionMode();
ShowToast("휴지통으로 이동됨", "\uE74D");
}
catch (Exception ex)
{
CustomMessageBox.Show($"삭제 실패: {ex.Message}", "오류",
MessageBoxButton.OK, MessageBoxImage.Error);
}
}
else
{
_vm.ExitActionMode();
}
return true;
}
case AxCopilot.ViewModels.FileAction.Rename:
{
var path = actionData.Path;
_vm.ExitActionMode();
_vm.InputText = $"rename {path}";
Dispatcher.BeginInvoke(() => { InputBox.CaretIndex = InputBox.Text.Length; },
System.Windows.Threading.DispatcherPriority.Input);
return true;
}
default:
return false;
}
}
}

View File

@@ -0,0 +1,381 @@
using System.Linq;
using System.Windows;
using System.Windows.Controls;
using System.Windows.Input;
using System.Windows.Media;
using AxCopilot.Services;
using AxCopilot.ViewModels;
namespace AxCopilot.Views;
public partial class SettingsWindow
{
// ─── 스킬 목록 패널 ─────────────────────────────────────────────────────
private void BuildSkillListSection(ref int insertIdx)
{
if (AgentEtcContent == null) return;
var skills = Services.Agent.SkillService.Skills;
if (skills.Count == 0) return;
var accentColor = ThemeResourceHelper.HexColor("#4B5EFC");
var subtleText = ThemeResourceHelper.HexColor("#6666AA");
// 스킬 콘텐츠를 모아서 접기/열기 섹션에 넣기
var skillItems = new List<UIElement>();
// 설명
skillItems.Add(new TextBlock
{
Text = "/ 명령으로 호출할 수 있는 스킬 목록입니다. 앱 내장 + 사용자 추가 스킬이 포함됩니다.\n" +
"(스킬은 사용자가 직접 /명령어를 입력해야 실행됩니다. LLM이 자동 호출하지 않습니다.)",
FontSize = 11,
Foreground = new SolidColorBrush(subtleText),
Margin = new Thickness(2, 0, 0, 10),
TextWrapping = TextWrapping.Wrap,
});
// 내장 스킬 / 고급 스킬 분류
var builtIn = skills.Where(s => string.IsNullOrEmpty(s.Requires)).ToList();
var advanced = skills.Where(s => !string.IsNullOrEmpty(s.Requires)).ToList();
// 내장 스킬 카드
if (builtIn.Count > 0)
{
var card = CreateSkillGroupCard("내장 스킬", "\uE768", "#34D399", builtIn);
skillItems.Add(card);
}
// 고급 스킬 (런타임 의존) 카드
if (advanced.Count > 0)
{
var card = CreateSkillGroupCard("고급 스킬 (런타임 필요)", "\uE9D9", "#A78BFA", advanced);
skillItems.Add(card);
}
// ── 스킬 가져오기/내보내기 버튼 ──
var btnPanel = new StackPanel
{
Orientation = Orientation.Horizontal,
Margin = new Thickness(2, 4, 0, 10),
};
// 가져오기 버튼
var importBtn = new Border
{
Background = ThemeResourceHelper.HexBrush("#4B5EFC"),
CornerRadius = new CornerRadius(8),
Padding = new Thickness(14, 7, 14, 7),
Margin = new Thickness(0, 0, 8, 0),
Cursor = Cursors.Hand,
};
var importContent = new StackPanel { Orientation = Orientation.Horizontal };
importContent.Children.Add(new TextBlock
{
Text = "\uE8B5",
FontFamily = ThemeResourceHelper.SegoeMdl2,
FontSize = 12,
Foreground = Brushes.White,
VerticalAlignment = VerticalAlignment.Center,
Margin = new Thickness(0, 0, 6, 0),
});
importContent.Children.Add(new TextBlock
{
Text = "스킬 가져오기 (.zip)",
FontSize = 12,
FontWeight = FontWeights.SemiBold,
Foreground = Brushes.White,
VerticalAlignment = VerticalAlignment.Center,
});
importBtn.Child = importContent;
importBtn.MouseLeftButtonUp += SkillImport_Click;
importBtn.MouseEnter += (s, _) => ((Border)s).Opacity = 0.85;
importBtn.MouseLeave += (s, _) => ((Border)s).Opacity = 1.0;
btnPanel.Children.Add(importBtn);
// 내보내기 버튼
var exportBtn = new Border
{
Background = ThemeResourceHelper.HexBrush("#F0F1F5"),
CornerRadius = new CornerRadius(8),
Padding = new Thickness(14, 7, 14, 7),
Cursor = Cursors.Hand,
};
var exportContent = new StackPanel { Orientation = Orientation.Horizontal };
exportContent.Children.Add(new TextBlock
{
Text = "\uEDE1",
FontFamily = ThemeResourceHelper.SegoeMdl2,
FontSize = 12,
Foreground = ThemeResourceHelper.HexBrush("#555"),
VerticalAlignment = VerticalAlignment.Center,
Margin = new Thickness(0, 0, 6, 0),
});
exportContent.Children.Add(new TextBlock
{
Text = "스킬 내보내기",
FontSize = 12,
FontWeight = FontWeights.SemiBold,
Foreground = ThemeResourceHelper.HexBrush("#444"),
VerticalAlignment = VerticalAlignment.Center,
});
exportBtn.Child = exportContent;
exportBtn.MouseLeftButtonUp += SkillExport_Click;
exportBtn.MouseEnter += (s, _) => ((Border)s).Opacity = 0.85;
exportBtn.MouseLeave += (s, _) => ((Border)s).Opacity = 1.0;
btnPanel.Children.Add(exportBtn);
skillItems.Add(btnPanel);
// ── 갤러리/통계 링크 버튼 ──
var linkPanel = new StackPanel
{
Orientation = Orientation.Horizontal,
Margin = new Thickness(2, 0, 0, 12),
};
var galleryBtn = new Border
{
CornerRadius = new CornerRadius(8),
Padding = new Thickness(14, 7, 14, 7),
Margin = new Thickness(0, 0, 8, 0),
Background = new SolidColorBrush(Color.FromArgb(0x18, 0x4B, 0x5E, 0xFC)),
Cursor = Cursors.Hand,
};
var galleryContent = new StackPanel { Orientation = Orientation.Horizontal };
galleryContent.Children.Add(new TextBlock
{
Text = "\uE768",
FontFamily = ThemeResourceHelper.SegoeMdl2,
FontSize = 12,
Foreground = ThemeResourceHelper.HexBrush("#4B5EFC"),
VerticalAlignment = VerticalAlignment.Center,
Margin = new Thickness(0, 0, 6, 0),
});
galleryContent.Children.Add(new TextBlock
{
Text = "스킬 갤러리 열기",
FontSize = 12,
FontWeight = FontWeights.SemiBold,
Foreground = ThemeResourceHelper.HexBrush("#4B5EFC"),
});
galleryBtn.Child = galleryContent;
galleryBtn.MouseLeftButtonUp += (_, _) =>
{
var win = new SkillGalleryWindow();
win.Owner = Window.GetWindow(this);
win.ShowDialog();
};
galleryBtn.MouseEnter += (s, _) => ((Border)s).Opacity = 0.8;
galleryBtn.MouseLeave += (s, _) => ((Border)s).Opacity = 1.0;
linkPanel.Children.Add(galleryBtn);
var statsBtn = new Border
{
CornerRadius = new CornerRadius(8),
Padding = new Thickness(14, 7, 14, 7),
Background = new SolidColorBrush(Color.FromArgb(0x18, 0xA7, 0x8B, 0xFA)),
Cursor = Cursors.Hand,
};
var statsContent = new StackPanel { Orientation = Orientation.Horizontal };
statsContent.Children.Add(new TextBlock
{
Text = "\uE9D9",
FontFamily = ThemeResourceHelper.SegoeMdl2,
FontSize = 12,
Foreground = ThemeResourceHelper.HexBrush("#A78BFA"),
VerticalAlignment = VerticalAlignment.Center,
Margin = new Thickness(0, 0, 6, 0),
});
statsContent.Children.Add(new TextBlock
{
Text = "실행 통계 보기",
FontSize = 12,
FontWeight = FontWeights.SemiBold,
Foreground = ThemeResourceHelper.HexBrush("#A78BFA"),
});
statsBtn.Child = statsContent;
statsBtn.MouseLeftButtonUp += (_, _) =>
{
var win = new AgentStatsDashboardWindow();
win.Owner = Window.GetWindow(this);
win.ShowDialog();
};
statsBtn.MouseEnter += (s, _) => ((Border)s).Opacity = 0.8;
statsBtn.MouseLeave += (s, _) => ((Border)s).Opacity = 1.0;
linkPanel.Children.Add(statsBtn);
skillItems.Add(linkPanel);
// ── 스킬 섹션 직접 추가 ──
// 스킬 헤더
var skillHeader = new TextBlock
{
Text = $"슬래시 스킬 ({skills.Count})",
FontSize = 13,
FontWeight = FontWeights.SemiBold,
Foreground = TryFindResource("PrimaryText") as Brush ?? Brushes.White,
Margin = new Thickness(2, 16, 0, 8),
};
AgentEtcContent.Children.Insert(insertIdx++, skillHeader);
foreach (var item in skillItems)
AgentEtcContent.Children.Insert(insertIdx++, item);
}
private Border CreateSkillGroupCard(string title, string icon, string colorHex,
List<Services.Agent.SkillDefinition> skills)
{
var color = ThemeResourceHelper.HexColor(colorHex);
var card = new Border
{
Background = TryFindResource("ItemBackground") as Brush ?? Brushes.White,
CornerRadius = new CornerRadius(10),
Padding = new Thickness(14, 10, 14, 10),
Margin = new Thickness(0, 0, 0, 6),
};
var panel = new StackPanel();
// 스킬 아이템 패널 (접기/열기 대상)
var skillItemsPanel = new StackPanel { Visibility = Visibility.Collapsed };
// 접기/열기 화살표
var arrow = new TextBlock
{
Text = "\uE70D",
FontFamily = ThemeResourceHelper.SegoeMdl2,
FontSize = 9,
Foreground = TryFindResource("SecondaryText") as Brush ?? Brushes.Gray,
VerticalAlignment = VerticalAlignment.Center,
Margin = new Thickness(0, 0, 6, 0),
RenderTransformOrigin = new Point(0.5, 0.5),
RenderTransform = new RotateTransform(0),
};
// 카테고리 헤더 (클릭 가능)
var catHeader = new Border
{
Background = Brushes.Transparent,
Cursor = Cursors.Hand,
};
var catHeaderPanel = new StackPanel { Orientation = Orientation.Horizontal };
catHeaderPanel.Children.Add(arrow);
catHeaderPanel.Children.Add(new TextBlock
{
Text = icon,
FontFamily = ThemeResourceHelper.SegoeMdl2,
FontSize = 14,
Foreground = new SolidColorBrush(color),
VerticalAlignment = VerticalAlignment.Center,
Margin = new Thickness(0, 0, 8, 0),
});
catHeaderPanel.Children.Add(new TextBlock
{
Text = $"{title} ({skills.Count})",
FontSize = 12.5,
FontWeight = FontWeights.SemiBold,
Foreground = TryFindResource("PrimaryText") as Brush
?? ThemeResourceHelper.HexBrush("#1A1B2E"),
VerticalAlignment = VerticalAlignment.Center,
});
catHeader.Child = catHeaderPanel;
// 클릭 시 접기/열기 토글
catHeader.MouseLeftButtonDown += (s, e) =>
{
e.Handled = true;
bool isVisible = skillItemsPanel.Visibility == Visibility.Visible;
skillItemsPanel.Visibility = isVisible ? Visibility.Collapsed : Visibility.Visible;
arrow.RenderTransform = new RotateTransform(isVisible ? 0 : 90);
};
panel.Children.Add(catHeader);
// 구분선
skillItemsPanel.Children.Add(new Border
{
Height = 1,
Background = TryFindResource("BorderColor") as Brush
?? ThemeResourceHelper.HexBrush("#F0F0F4"),
Margin = new Thickness(0, 2, 0, 4),
});
// 스킬 아이템
foreach (var skill in skills)
{
var row = new StackPanel { Margin = new Thickness(0, 4, 0, 4) };
// 위 줄: 스킬 명칭 + 가용성 뱃지
var namePanel = new StackPanel { Orientation = Orientation.Horizontal };
namePanel.Children.Add(new TextBlock
{
Text = $"/{skill.Name}",
FontSize = 12,
FontFamily = ThemeResourceHelper.ConsolasCode,
Foreground = skill.IsAvailable
? (TryFindResource("AccentColor") as Brush ?? ThemeResourceHelper.HexBrush("#4B5EFC"))
: Brushes.Gray,
VerticalAlignment = VerticalAlignment.Center,
Margin = new Thickness(4, 0, 8, 0),
Opacity = skill.IsAvailable ? 1.0 : 0.5,
});
if (!skill.IsAvailable && !string.IsNullOrEmpty(skill.Requires))
{
namePanel.Children.Add(new Border
{
Background = new SolidColorBrush(Color.FromArgb(0x20, 0xF8, 0x71, 0x71)),
CornerRadius = new CornerRadius(4),
Padding = new Thickness(5, 1, 5, 1),
VerticalAlignment = VerticalAlignment.Center,
Child = new TextBlock
{
Text = skill.UnavailableHint,
FontSize = 9,
Foreground = new SolidColorBrush(Color.FromRgb(0xF8, 0x71, 0x71)),
FontWeight = FontWeights.SemiBold,
},
});
}
else if (skill.IsAvailable && !string.IsNullOrEmpty(skill.Requires))
{
namePanel.Children.Add(new Border
{
Background = new SolidColorBrush(Color.FromArgb(0x20, 0x34, 0xD3, 0x99)),
CornerRadius = new CornerRadius(4),
Padding = new Thickness(5, 1, 5, 1),
VerticalAlignment = VerticalAlignment.Center,
Child = new TextBlock
{
Text = "✓ 사용 가능",
FontSize = 9,
Foreground = new SolidColorBrush(Color.FromRgb(0x34, 0xD3, 0x99)),
FontWeight = FontWeights.SemiBold,
},
});
}
row.Children.Add(namePanel);
// 아래 줄: 설명 (뱃지와 구분되도록 위 여백 추가)
row.Children.Add(new TextBlock
{
Text = $"{skill.Label} — {skill.Description}",
FontSize = 11.5,
Foreground = TryFindResource("SecondaryText") as Brush
?? ThemeResourceHelper.HexBrush("#6666AA"),
TextWrapping = TextWrapping.Wrap,
Margin = new Thickness(4, 3, 0, 0),
Opacity = skill.IsAvailable ? 1.0 : 0.5,
});
skillItemsPanel.Children.Add(row);
}
panel.Children.Add(skillItemsPanel);
card.Child = panel;
return card;
}
}

View File

@@ -236,370 +236,4 @@ public partial class SettingsWindow
BuildSkillListSection(ref insertIdx);
}
private void BuildSkillListSection(ref int insertIdx)
{
if (AgentEtcContent == null) return;
var skills = Services.Agent.SkillService.Skills;
if (skills.Count == 0) return;
var accentColor = ThemeResourceHelper.HexColor("#4B5EFC");
var subtleText = ThemeResourceHelper.HexColor("#6666AA");
// 스킬 콘텐츠를 모아서 접기/열기 섹션에 넣기
var skillItems = new List<UIElement>();
// 설명
skillItems.Add(new TextBlock
{
Text = "/ 명령으로 호출할 수 있는 스킬 목록입니다. 앱 내장 + 사용자 추가 스킬이 포함됩니다.\n" +
"(스킬은 사용자가 직접 /명령어를 입력해야 실행됩니다. LLM이 자동 호출하지 않습니다.)",
FontSize = 11,
Foreground = new SolidColorBrush(subtleText),
Margin = new Thickness(2, 0, 0, 10),
TextWrapping = TextWrapping.Wrap,
});
// 내장 스킬 / 고급 스킬 분류
var builtIn = skills.Where(s => string.IsNullOrEmpty(s.Requires)).ToList();
var advanced = skills.Where(s => !string.IsNullOrEmpty(s.Requires)).ToList();
// 내장 스킬 카드
if (builtIn.Count > 0)
{
var card = CreateSkillGroupCard("내장 스킬", "\uE768", "#34D399", builtIn);
skillItems.Add(card);
}
// 고급 스킬 (런타임 의존) 카드
if (advanced.Count > 0)
{
var card = CreateSkillGroupCard("고급 스킬 (런타임 필요)", "\uE9D9", "#A78BFA", advanced);
skillItems.Add(card);
}
// ── 스킬 가져오기/내보내기 버튼 ──
var btnPanel = new StackPanel
{
Orientation = Orientation.Horizontal,
Margin = new Thickness(2, 4, 0, 10),
};
// 가져오기 버튼
var importBtn = new Border
{
Background = ThemeResourceHelper.HexBrush("#4B5EFC"),
CornerRadius = new CornerRadius(8),
Padding = new Thickness(14, 7, 14, 7),
Margin = new Thickness(0, 0, 8, 0),
Cursor = Cursors.Hand,
};
var importContent = new StackPanel { Orientation = Orientation.Horizontal };
importContent.Children.Add(new TextBlock
{
Text = "\uE8B5",
FontFamily = ThemeResourceHelper.SegoeMdl2,
FontSize = 12,
Foreground = Brushes.White,
VerticalAlignment = VerticalAlignment.Center,
Margin = new Thickness(0, 0, 6, 0),
});
importContent.Children.Add(new TextBlock
{
Text = "스킬 가져오기 (.zip)",
FontSize = 12,
FontWeight = FontWeights.SemiBold,
Foreground = Brushes.White,
VerticalAlignment = VerticalAlignment.Center,
});
importBtn.Child = importContent;
importBtn.MouseLeftButtonUp += SkillImport_Click;
importBtn.MouseEnter += (s, _) => ((Border)s).Opacity = 0.85;
importBtn.MouseLeave += (s, _) => ((Border)s).Opacity = 1.0;
btnPanel.Children.Add(importBtn);
// 내보내기 버튼
var exportBtn = new Border
{
Background = ThemeResourceHelper.HexBrush("#F0F1F5"),
CornerRadius = new CornerRadius(8),
Padding = new Thickness(14, 7, 14, 7),
Cursor = Cursors.Hand,
};
var exportContent = new StackPanel { Orientation = Orientation.Horizontal };
exportContent.Children.Add(new TextBlock
{
Text = "\uEDE1",
FontFamily = ThemeResourceHelper.SegoeMdl2,
FontSize = 12,
Foreground = ThemeResourceHelper.HexBrush("#555"),
VerticalAlignment = VerticalAlignment.Center,
Margin = new Thickness(0, 0, 6, 0),
});
exportContent.Children.Add(new TextBlock
{
Text = "스킬 내보내기",
FontSize = 12,
FontWeight = FontWeights.SemiBold,
Foreground = ThemeResourceHelper.HexBrush("#444"),
VerticalAlignment = VerticalAlignment.Center,
});
exportBtn.Child = exportContent;
exportBtn.MouseLeftButtonUp += SkillExport_Click;
exportBtn.MouseEnter += (s, _) => ((Border)s).Opacity = 0.85;
exportBtn.MouseLeave += (s, _) => ((Border)s).Opacity = 1.0;
btnPanel.Children.Add(exportBtn);
skillItems.Add(btnPanel);
// ── 갤러리/통계 링크 버튼 ──
var linkPanel = new StackPanel
{
Orientation = Orientation.Horizontal,
Margin = new Thickness(2, 0, 0, 12),
};
var galleryBtn = new Border
{
CornerRadius = new CornerRadius(8),
Padding = new Thickness(14, 7, 14, 7),
Margin = new Thickness(0, 0, 8, 0),
Background = new SolidColorBrush(Color.FromArgb(0x18, 0x4B, 0x5E, 0xFC)),
Cursor = Cursors.Hand,
};
var galleryContent = new StackPanel { Orientation = Orientation.Horizontal };
galleryContent.Children.Add(new TextBlock
{
Text = "\uE768",
FontFamily = ThemeResourceHelper.SegoeMdl2,
FontSize = 12,
Foreground = ThemeResourceHelper.HexBrush("#4B5EFC"),
VerticalAlignment = VerticalAlignment.Center,
Margin = new Thickness(0, 0, 6, 0),
});
galleryContent.Children.Add(new TextBlock
{
Text = "스킬 갤러리 열기",
FontSize = 12,
FontWeight = FontWeights.SemiBold,
Foreground = ThemeResourceHelper.HexBrush("#4B5EFC"),
});
galleryBtn.Child = galleryContent;
galleryBtn.MouseLeftButtonUp += (_, _) =>
{
var win = new SkillGalleryWindow();
win.Owner = Window.GetWindow(this);
win.ShowDialog();
};
galleryBtn.MouseEnter += (s, _) => ((Border)s).Opacity = 0.8;
galleryBtn.MouseLeave += (s, _) => ((Border)s).Opacity = 1.0;
linkPanel.Children.Add(galleryBtn);
var statsBtn = new Border
{
CornerRadius = new CornerRadius(8),
Padding = new Thickness(14, 7, 14, 7),
Background = new SolidColorBrush(Color.FromArgb(0x18, 0xA7, 0x8B, 0xFA)),
Cursor = Cursors.Hand,
};
var statsContent = new StackPanel { Orientation = Orientation.Horizontal };
statsContent.Children.Add(new TextBlock
{
Text = "\uE9D9",
FontFamily = ThemeResourceHelper.SegoeMdl2,
FontSize = 12,
Foreground = ThemeResourceHelper.HexBrush("#A78BFA"),
VerticalAlignment = VerticalAlignment.Center,
Margin = new Thickness(0, 0, 6, 0),
});
statsContent.Children.Add(new TextBlock
{
Text = "실행 통계 보기",
FontSize = 12,
FontWeight = FontWeights.SemiBold,
Foreground = ThemeResourceHelper.HexBrush("#A78BFA"),
});
statsBtn.Child = statsContent;
statsBtn.MouseLeftButtonUp += (_, _) =>
{
var win = new AgentStatsDashboardWindow();
win.Owner = Window.GetWindow(this);
win.ShowDialog();
};
statsBtn.MouseEnter += (s, _) => ((Border)s).Opacity = 0.8;
statsBtn.MouseLeave += (s, _) => ((Border)s).Opacity = 1.0;
linkPanel.Children.Add(statsBtn);
skillItems.Add(linkPanel);
// ── 스킬 섹션 직접 추가 ──
// 스킬 헤더
var skillHeader = new TextBlock
{
Text = $"슬래시 스킬 ({skills.Count})",
FontSize = 13,
FontWeight = FontWeights.SemiBold,
Foreground = TryFindResource("PrimaryText") as Brush ?? Brushes.White,
Margin = new Thickness(2, 16, 0, 8),
};
AgentEtcContent.Children.Insert(insertIdx++, skillHeader);
foreach (var item in skillItems)
AgentEtcContent.Children.Insert(insertIdx++, item);
}
private Border CreateSkillGroupCard(string title, string icon, string colorHex,
List<Services.Agent.SkillDefinition> skills)
{
var color = ThemeResourceHelper.HexColor(colorHex);
var card = new Border
{
Background = TryFindResource("ItemBackground") as Brush ?? Brushes.White,
CornerRadius = new CornerRadius(10),
Padding = new Thickness(14, 10, 14, 10),
Margin = new Thickness(0, 0, 0, 6),
};
var panel = new StackPanel();
// 스킬 아이템 패널 (접기/열기 대상)
var skillItemsPanel = new StackPanel { Visibility = Visibility.Collapsed };
// 접기/열기 화살표
var arrow = new TextBlock
{
Text = "\uE70D",
FontFamily = ThemeResourceHelper.SegoeMdl2,
FontSize = 9,
Foreground = TryFindResource("SecondaryText") as Brush ?? Brushes.Gray,
VerticalAlignment = VerticalAlignment.Center,
Margin = new Thickness(0, 0, 6, 0),
RenderTransformOrigin = new Point(0.5, 0.5),
RenderTransform = new RotateTransform(0),
};
// 카테고리 헤더 (클릭 가능)
var catHeader = new Border
{
Background = Brushes.Transparent,
Cursor = Cursors.Hand,
};
var catHeaderPanel = new StackPanel { Orientation = Orientation.Horizontal };
catHeaderPanel.Children.Add(arrow);
catHeaderPanel.Children.Add(new TextBlock
{
Text = icon,
FontFamily = ThemeResourceHelper.SegoeMdl2,
FontSize = 14,
Foreground = new SolidColorBrush(color),
VerticalAlignment = VerticalAlignment.Center,
Margin = new Thickness(0, 0, 8, 0),
});
catHeaderPanel.Children.Add(new TextBlock
{
Text = $"{title} ({skills.Count})",
FontSize = 12.5,
FontWeight = FontWeights.SemiBold,
Foreground = TryFindResource("PrimaryText") as Brush
?? ThemeResourceHelper.HexBrush("#1A1B2E"),
VerticalAlignment = VerticalAlignment.Center,
});
catHeader.Child = catHeaderPanel;
// 클릭 시 접기/열기 토글
catHeader.MouseLeftButtonDown += (s, e) =>
{
e.Handled = true;
bool isVisible = skillItemsPanel.Visibility == Visibility.Visible;
skillItemsPanel.Visibility = isVisible ? Visibility.Collapsed : Visibility.Visible;
arrow.RenderTransform = new RotateTransform(isVisible ? 0 : 90);
};
panel.Children.Add(catHeader);
// 구분선
skillItemsPanel.Children.Add(new Border
{
Height = 1,
Background = TryFindResource("BorderColor") as Brush
?? ThemeResourceHelper.HexBrush("#F0F0F4"),
Margin = new Thickness(0, 2, 0, 4),
});
// 스킬 아이템
foreach (var skill in skills)
{
var row = new StackPanel { Margin = new Thickness(0, 4, 0, 4) };
// 위 줄: 스킬 명칭 + 가용성 뱃지
var namePanel = new StackPanel { Orientation = Orientation.Horizontal };
namePanel.Children.Add(new TextBlock
{
Text = $"/{skill.Name}",
FontSize = 12,
FontFamily = ThemeResourceHelper.ConsolasCode,
Foreground = skill.IsAvailable
? (TryFindResource("AccentColor") as Brush ?? ThemeResourceHelper.HexBrush("#4B5EFC"))
: Brushes.Gray,
VerticalAlignment = VerticalAlignment.Center,
Margin = new Thickness(4, 0, 8, 0),
Opacity = skill.IsAvailable ? 1.0 : 0.5,
});
if (!skill.IsAvailable && !string.IsNullOrEmpty(skill.Requires))
{
namePanel.Children.Add(new Border
{
Background = new SolidColorBrush(Color.FromArgb(0x20, 0xF8, 0x71, 0x71)),
CornerRadius = new CornerRadius(4),
Padding = new Thickness(5, 1, 5, 1),
VerticalAlignment = VerticalAlignment.Center,
Child = new TextBlock
{
Text = skill.UnavailableHint,
FontSize = 9,
Foreground = new SolidColorBrush(Color.FromRgb(0xF8, 0x71, 0x71)),
FontWeight = FontWeights.SemiBold,
},
});
}
else if (skill.IsAvailable && !string.IsNullOrEmpty(skill.Requires))
{
namePanel.Children.Add(new Border
{
Background = new SolidColorBrush(Color.FromArgb(0x20, 0x34, 0xD3, 0x99)),
CornerRadius = new CornerRadius(4),
Padding = new Thickness(5, 1, 5, 1),
VerticalAlignment = VerticalAlignment.Center,
Child = new TextBlock
{
Text = "✓ 사용 가능",
FontSize = 9,
Foreground = new SolidColorBrush(Color.FromRgb(0x34, 0xD3, 0x99)),
FontWeight = FontWeights.SemiBold,
},
});
}
row.Children.Add(namePanel);
// 아래 줄: 설명 (뱃지와 구분되도록 위 여백 추가)
row.Children.Add(new TextBlock
{
Text = $"{skill.Label} — {skill.Description}",
FontSize = 11.5,
Foreground = TryFindResource("SecondaryText") as Brush
?? ThemeResourceHelper.HexBrush("#6666AA"),
TextWrapping = TextWrapping.Wrap,
Margin = new Thickness(4, 3, 0, 0),
Opacity = skill.IsAvailable ? 1.0 : 0.5,
});
skillItemsPanel.Children.Add(row);
}
panel.Children.Add(skillItemsPanel);
card.Child = panel;
return card;
}
}