diff --git a/src/AxCopilot/Handlers/CalculatorHandler.cs b/src/AxCopilot/Handlers/CalculatorHandler.cs index 7499add..c11378a 100644 --- a/src/AxCopilot/Handlers/CalculatorHandler.cs +++ b/src/AxCopilot/Handlers/CalculatorHandler.cs @@ -111,335 +111,6 @@ public class CalculatorHandler : IActionHandler } } -// ─── 단위 변환 ───────────────────────────────────────────────────────────────── - -/// -/// "100km in miles", "32f in c", "5lb to kg" 형식의 단위 변환. -/// -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 _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 _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 _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 _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 _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> _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); - } -} - -// ─── 수식 파서 ───────────────────────────────────────────────────────────────── - -/// -/// 재귀 하강 파서 기반 수학 수식 평가기. -/// 지원: +, -, *, /, %, ^ (거듭제곱), 괄호, 단항 음수, -/// sqrt, abs, ceil, floor, round, sin, cos, tan (도 단위), -/// log (밑 10), ln (자연로그), pi, e -/// -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]}'"); - } - } -} - // ─── 통화 변환 ────────────────────────────────────────────────────────────────── /// diff --git a/src/AxCopilot/Handlers/EmojiHandler.Data.cs b/src/AxCopilot/Handlers/EmojiHandler.Data.cs new file mode 100644 index 0000000..c9ec449 --- /dev/null +++ b/src/AxCopilot/Handlers/EmojiHandler.Data.cs @@ -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 바이올린"), + }; +} diff --git a/src/AxCopilot/Handlers/EmojiHandler.cs b/src/AxCopilot/Handlers/EmojiHandler.cs index 5548f68..528cec6 100644 --- a/src/AxCopilot/Handlers/EmojiHandler.cs +++ b/src/AxCopilot/Handlers/EmojiHandler.cs @@ -10,7 +10,7 @@ namespace AxCopilot.Handlers; /// emoji wave → 👋 검색 /// emoji → 자주 쓰는 이모지 목록 /// -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> GetItemsAsync(string query, CancellationToken ct) { IEnumerable<(string Emoji, string Name, string Tags)> matches; diff --git a/src/AxCopilot/Handlers/MathEvaluator.cs b/src/AxCopilot/Handlers/MathEvaluator.cs new file mode 100644 index 0000000..3eb1c8f --- /dev/null +++ b/src/AxCopilot/Handlers/MathEvaluator.cs @@ -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; + +// ─── 수식 파서 ───────────────────────────────────────────────────────────────── + +/// +/// 재귀 하강 파서 기반 수학 수식 평가기. +/// 지원: +, -, *, /, %, ^ (거듭제곱), 괄호, 단항 음수, +/// sqrt, abs, ceil, floor, round, sin, cos, tan (도 단위), +/// log (밑 10), ln (자연로그), pi, e +/// +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]}'"); + } + } +} diff --git a/src/AxCopilot/Handlers/UnitConverter.cs b/src/AxCopilot/Handlers/UnitConverter.cs new file mode 100644 index 0000000..d1b7bd9 --- /dev/null +++ b/src/AxCopilot/Handlers/UnitConverter.cs @@ -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; + +// ─── 단위 변환 ───────────────────────────────────────────────────────────────── + +/// +/// "100km in miles", "32f in c", "5lb to kg" 형식의 단위 변환. +/// +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 _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 _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 _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 _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 _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> _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); + } +} diff --git a/src/AxCopilot/Services/Agent/DocumentPlannerTool.Generators.cs b/src/AxCopilot/Services/Agent/DocumentPlannerTool.Generators.cs new file mode 100644 index 0000000..db0f429 --- /dev/null +++ b/src/AxCopilot/Services/Agent/DocumentPlannerTool.Generators.cs @@ -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 sections) + { + var css = TemplateService.GetCss("professional"); + var sb = new StringBuilder(); + + sb.AppendLine(""); + sb.AppendLine(""); + sb.AppendLine(""); + sb.AppendLine(""); + sb.AppendLine($"{Escape(title)}"); + sb.AppendLine(""); + sb.AppendLine(""); + sb.AppendLine(""); + sb.AppendLine("
"); + sb.AppendLine($"

{Escape(title)}

"); + sb.AppendLine($"
문서 유형: {Escape(GetDocTypeLabel(docType))} | 작성일: {DateTime.Now:yyyy-MM-dd} | 섹션: {sections.Count}개
"); + + // 목차 + if (sections.Count > 1) + { + sb.AppendLine("
"); + sb.AppendLine("

📋 목차

"); + sb.AppendLine(""); + sb.AppendLine("
"); + } + + // 섹션 본문 + for (int i = 0; i < sections.Count; i++) + { + var sec = sections[i]; + sb.AppendLine($"

{Escape(sec.Heading)}

"); + sb.AppendLine("
"); + foreach (var kp in sec.KeyPoints) + { + sb.AppendLine($"
"); + sb.AppendLine($"▸ {Escape(kp)}"); + sb.AppendLine($"

{Escape(kp)}에 대한 상세 내용을 여기에 작성합니다. (목표: 약 {sec.TargetWords / Math.Max(1, sec.KeyPoints.Count)}단어)

"); + sb.AppendLine("
"); + } + sb.AppendLine("
"); + } + + sb.AppendLine("
"); + sb.AppendLine(""); + sb.AppendLine(""); + + File.WriteAllText(path, sb.ToString(), Encoding.UTF8); + } + + // ─── DOCX 생성 ────────────────────────────────────────────────────────── + + private static void GenerateDocx(string path, string title, List 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 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 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(); + 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 + { + 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 + { + 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 + { + 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 + { + 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 + { + 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 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("&", "&").Replace("<", "<").Replace(">", ">"); + + 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 KeyPoints { get; set; } = new(); + } +} diff --git a/src/AxCopilot/Services/Agent/DocumentPlannerTool.cs b/src/AxCopilot/Services/Agent/DocumentPlannerTool.cs index f7dd5fe..1244c7f 100644 --- a/src/AxCopilot/Services/Agent/DocumentPlannerTool.cs +++ b/src/AxCopilot/Services/Agent/DocumentPlannerTool.cs @@ -9,7 +9,7 @@ namespace AxCopilot.Services.Agent; /// - 멀티패스(고품질) ON : 개요만 반환 → LLM이 섹션별로 상세 작성 → document_assemble로 조립 /// - 멀티패스(고품질) OFF: 개요 + 기본 문서를 즉시 로컬 파일로 저장 (LLM 호출 최소) /// -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 sections) - { - var css = TemplateService.GetCss("professional"); - var sb = new StringBuilder(); - - sb.AppendLine(""); - sb.AppendLine(""); - sb.AppendLine(""); - sb.AppendLine(""); - sb.AppendLine($"{Escape(title)}"); - sb.AppendLine(""); - sb.AppendLine(""); - sb.AppendLine(""); - sb.AppendLine("
"); - sb.AppendLine($"

{Escape(title)}

"); - sb.AppendLine($"
문서 유형: {Escape(GetDocTypeLabel(docType))} | 작성일: {DateTime.Now:yyyy-MM-dd} | 섹션: {sections.Count}개
"); - - // 목차 - if (sections.Count > 1) - { - sb.AppendLine("
"); - sb.AppendLine("

📋 목차

"); - sb.AppendLine(""); - sb.AppendLine("
"); - } - - // 섹션 본문 - for (int i = 0; i < sections.Count; i++) - { - var sec = sections[i]; - sb.AppendLine($"

{Escape(sec.Heading)}

"); - sb.AppendLine("
"); - foreach (var kp in sec.KeyPoints) - { - sb.AppendLine($"
"); - sb.AppendLine($"▸ {Escape(kp)}"); - sb.AppendLine($"

{Escape(kp)}에 대한 상세 내용을 여기에 작성합니다. (목표: 약 {sec.TargetWords / Math.Max(1, sec.KeyPoints.Count)}단어)

"); - sb.AppendLine("
"); - } - sb.AppendLine("
"); - } - - sb.AppendLine("
"); - sb.AppendLine(""); - sb.AppendLine(""); - - File.WriteAllText(path, sb.ToString(), Encoding.UTF8); - } - - // ─── DOCX 생성 ────────────────────────────────────────────────────────── - - private static void GenerateDocx(string path, string title, List 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 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 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(); - 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 - { - 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 - { - 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 - { - 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 - { - 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 - { - 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 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("&", "&").Replace("<", "<").Replace(">", ">"); - - 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 KeyPoints { get; set; } = new(); - } } diff --git a/src/AxCopilot/Services/Agent/DocumentReaderTool.Formats.cs b/src/AxCopilot/Services/Agent/DocumentReaderTool.Formats.cs new file mode 100644 index 0000000..6b5086a --- /dev/null +++ b/src/AxCopilot/Services/Agent/DocumentReaderTool.Formats.cs @@ -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>? current = null; + + foreach (var line in lines) + { + if (line.StartsWith("TY -")) + { + current = new Dictionary>(); + 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(); + current[tag].Add(value); + } + } + + sb.AppendLine($"RIS: {entries.Count}개 항목"); + sb.AppendLine(); + + // RIS 태그 → 사람이 읽을 수 있는 이름 + var tagNames = new Dictionary + { + ["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()) + { + 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().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().ToList() ?? []; + + var rows = wsPart.Worksheet.Descendants().ToList(); + sb.AppendLine($"[{targetSheet.Name?.Value}] ({rows.Count} rows)"); + + foreach (var row in rows) + { + var cells = row.Elements().ToList(); + var values = new List(); + 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 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 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", + }; + + /// JsonElement에서 int를 안전하게 추출합니다. string/integer 양쪽 호환. + 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; + } +} diff --git a/src/AxCopilot/Services/Agent/DocumentReaderTool.cs b/src/AxCopilot/Services/Agent/DocumentReaderTool.cs index d53bb98..9174f3e 100644 --- a/src/AxCopilot/Services/Agent/DocumentReaderTool.cs +++ b/src/AxCopilot/Services/Agent/DocumentReaderTool.cs @@ -12,7 +12,7 @@ namespace AxCopilot.Services.Agent; /// 문서 파일을 읽어 텍스트로 반환하는 도구. /// PDF, DOCX, XLSX, CSV, TXT, BibTeX, RIS 등 다양한 형식을 지원합니다. /// -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>? current = null; - - foreach (var line in lines) - { - if (line.StartsWith("TY -")) - { - current = new Dictionary>(); - 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(); - current[tag].Add(value); - } - } - - sb.AppendLine($"RIS: {entries.Count}개 항목"); - sb.AppendLine(); - - // RIS 태그 → 사람이 읽을 수 있는 이름 - var tagNames = new Dictionary - { - ["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()) - { - 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().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().ToList() ?? []; - - var rows = wsPart.Worksheet.Descendants().ToList(); - sb.AppendLine($"[{targetSheet.Name?.Value}] ({rows.Count} rows)"); - - foreach (var row in rows) - { - var cells = row.Elements().ToList(); - var values = new List(); - 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 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 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", - }; - - /// JsonElement에서 int를 안전하게 추출합니다. string/integer 양쪽 호환. - 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; - } } diff --git a/src/AxCopilot/Services/ClipboardHistoryService.ImageCache.cs b/src/AxCopilot/Services/ClipboardHistoryService.ImageCache.cs new file mode 100644 index 0000000..97f0ec9 --- /dev/null +++ b/src/AxCopilot/Services/ClipboardHistoryService.ImageCache.cs @@ -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 ───────────────────────────────────────────── + + /// 원본 이미지를 캐시 폴더에 PNG로 저장합니다. + 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; + } + } + + /// 원본 이미지를 파일에서 로드합니다. + 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; } + } + + /// 이미지 캐시 정리 (30일 초과 + 500MB 초과 시 오래된 파일부터 삭제). + 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; } + } +} diff --git a/src/AxCopilot/Services/ClipboardHistoryService.cs b/src/AxCopilot/Services/ClipboardHistoryService.cs index 8524d1a..bc94c43 100644 --- a/src/AxCopilot/Services/ClipboardHistoryService.cs +++ b/src/AxCopilot/Services/ClipboardHistoryService.cs @@ -16,7 +16,7 @@ namespace AxCopilot.Services; /// 클립보드 변경을 감지하여 히스토리를 관리합니다. /// WM_CLIPBOARDUPDATE 메시지를 수신하기 위해 숨겨진 메시지 창을 생성합니다. /// -public class ClipboardHistoryService : IDisposable +public partial class ClipboardHistoryService : IDisposable { private const int WM_CLIPBOARDUPDATE = 0x031D; @@ -415,113 +415,6 @@ public class ClipboardHistoryService : IDisposable }); } - /// 원본 이미지를 캐시 폴더에 PNG로 저장합니다. - 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; - } - } - - /// 원본 이미지를 파일에서 로드합니다. - 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; } - } - - /// 이미지 캐시 정리 (30일 초과 + 500MB 초과 시 오래된 파일부터 삭제). - 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)] diff --git a/src/AxCopilot/Services/CodeIndexService.Search.cs b/src/AxCopilot/Services/CodeIndexService.Search.cs new file mode 100644 index 0000000..3041986 --- /dev/null +++ b/src/AxCopilot/Services/CodeIndexService.Search.cs @@ -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 ────────────────────────────────────────── + + // ── 검색 ──────────────────────────────────────────────────────────── + + /// 시맨틱 검색: 질문과 가장 관련 있는 코드 청크를 반환합니다. + public List 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(); + 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(); + 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(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(); + 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; + } + + /// 기존 인덱스가 있으면 로드합니다 (앱 재시작 시). + 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 queryTf, + Dictionary docTf, + Dictionary 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)); + } + + // ── 토큰화 ────────────────────────────────────────────────────────── + + /// 텍스트를 토큰으로 분할하고 빈도를 계산합니다. 스톱워드 제거 포함. + private static Dictionary Tokenize(string text) + { + var tf = new Dictionary(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 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; + } +} diff --git a/src/AxCopilot/Services/CodeIndexService.cs b/src/AxCopilot/Services/CodeIndexService.cs index 9743e5e..b92358c 100644 --- a/src/AxCopilot/Services/CodeIndexService.cs +++ b/src/AxCopilot/Services/CodeIndexService.cs @@ -11,7 +11,7 @@ namespace AxCopilot.Services; /// 증분 업데이트와 빠른 재시작을 지원합니다. /// (로컬 전용, 외부 서버 불필요) /// -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(); } - - // ── 검색 ──────────────────────────────────────────────────────────── - - /// 시맨틱 검색: 질문과 가장 관련 있는 코드 청크를 반환합니다. - public List 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(); - 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(); - 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(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(); - 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; - } - - /// 기존 인덱스가 있으면 로드합니다 (앱 재시작 시). - 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 queryTf, - Dictionary docTf, - Dictionary 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)); - } - - // ── 토큰화 ────────────────────────────────────────────────────────── - - /// 텍스트를 토큰으로 분할하고 빈도를 계산합니다. 스톱워드 제거 포함. - private static Dictionary Tokenize(string text) - { - var tf = new Dictionary(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 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; - } } /// 인덱싱된 코드 청크. diff --git a/src/AxCopilot/Services/IndexService.Helpers.cs b/src/AxCopilot/Services/IndexService.Helpers.cs new file mode 100644 index 0000000..43dee33 --- /dev/null +++ b/src/AxCopilot/Services/IndexService.Helpers.cs @@ -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; + } + + /// ProgramFiles / ProgramFiles(x86) 두 곳을 탐색합니다. + 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; + } + + /// AppData\Roaming 경로를 탐색합니다. + 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; + } + + // ─── 검색 가속 캐시 계산 ────────────────────────────────────────────────── + + /// 빌드 완료 후 전체 항목의 검색 캐시를 한 번에 계산합니다. + private static void ComputeAllSearchCaches(List entries) + { + foreach (var e in entries) + ComputeSearchCache(e); + } + + /// 항목 1개의 NameLower / NameJamo / NameChosung 캐시를 계산합니다. + 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(); + } + + /// 인덱싱 속도에 따른 throttle 간격(ms). N개 파일마다 yield. + 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 entries, + HashSet 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); + } +} diff --git a/src/AxCopilot/Services/IndexService.cs b/src/AxCopilot/Services/IndexService.cs index 304143d..4279d8d 100644 --- a/src/AxCopilot/Services/IndexService.cs +++ b/src/AxCopilot/Services/IndexService.cs @@ -8,7 +8,7 @@ namespace AxCopilot.Services; /// 파일/앱 인덱싱 서비스. Fuzzy 검색의 데이터 소스를 관리합니다. /// FileSystemWatcher로 변경 시 자동 재빌드(3초 디바운스)합니다. /// -public class IndexService : IDisposable +public partial class IndexService : IDisposable { private readonly SettingsService _settings; private List _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; - } - - /// ProgramFiles / ProgramFiles(x86) 두 곳을 탐색합니다. - 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; - } - - /// AppData\Roaming 경로를 탐색합니다. - 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; - } - - // ─── 검색 가속 캐시 계산 ────────────────────────────────────────────────── - - /// 빌드 완료 후 전체 항목의 검색 캐시를 한 번에 계산합니다. - private static void ComputeAllSearchCaches(List entries) - { - foreach (var e in entries) - ComputeSearchCache(e); - } - - /// 항목 1개의 NameLower / NameJamo / NameChosung 캐시를 계산합니다. - 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(); - } - - /// 인덱싱 속도에 따른 throttle 간격(ms). N개 파일마다 yield. - 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 entries, - HashSet 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 diff --git a/src/AxCopilot/Views/LauncherWindow.Keyboard.cs b/src/AxCopilot/Views/LauncherWindow.Keyboard.cs index 3130485..749c00b 100644 --- a/src/AxCopilot/Views/LauncherWindow.Keyboard.cs +++ b/src/AxCopilot/Views/LauncherWindow.Keyboard.cs @@ -451,143 +451,4 @@ public partial class LauncherWindow } } } - - // ─── 단축키 도움말 ──────────────────────────────────────────────────────── - - /// 단축키 도움말 팝업 - 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); - } - - // ─── 토스트 알림 ────────────────────────────────────────────────────────── - - /// 오버레이 토스트 표시 (페이드인 → 2초 대기 → 페이드아웃) - 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(); - } - - // ─── 특수 액션 처리 ─────────────────────────────────────────────────────── - - /// - /// 액션 모드에서 특수 처리가 필요한 동작(삭제/이름변경)을 처리합니다. - /// 처리되면 true 반환 → ExecuteSelectedAsync 호출 생략. - /// - 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; - } - } } diff --git a/src/AxCopilot/Views/LauncherWindow.ShortcutHelp.cs b/src/AxCopilot/Views/LauncherWindow.ShortcutHelp.cs new file mode 100644 index 0000000..582d4a4 --- /dev/null +++ b/src/AxCopilot/Views/LauncherWindow.ShortcutHelp.cs @@ -0,0 +1,148 @@ +using System.Windows; +using System.Windows.Input; +using AxCopilot.Services; +using AxCopilot.ViewModels; + +namespace AxCopilot.Views; + +public partial class LauncherWindow +{ + // ─── 단축키 도움말 + 토스트 + 특수 액션 ──────────────────────────────────── + + /// 단축키 도움말 팝업 + 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); + } + + // ─── 토스트 알림 ────────────────────────────────────────────────────────── + + /// 오버레이 토스트 표시 (페이드인 → 2초 대기 → 페이드아웃) + 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(); + } + + // ─── 특수 액션 처리 ─────────────────────────────────────────────────────── + + /// + /// 액션 모드에서 특수 처리가 필요한 동작(삭제/이름변경)을 처리합니다. + /// 처리되면 true 반환 → ExecuteSelectedAsync 호출 생략. + /// + 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; + } + } +} diff --git a/src/AxCopilot/Views/SettingsWindow.SkillListPanel.cs b/src/AxCopilot/Views/SettingsWindow.SkillListPanel.cs new file mode 100644 index 0000000..979ec02 --- /dev/null +++ b/src/AxCopilot/Views/SettingsWindow.SkillListPanel.cs @@ -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(); + + // 설명 + 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 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; + } + +} diff --git a/src/AxCopilot/Views/SettingsWindow.Tools.cs b/src/AxCopilot/Views/SettingsWindow.Tools.cs index 91b259d..4b84a77 100644 --- a/src/AxCopilot/Views/SettingsWindow.Tools.cs +++ b/src/AxCopilot/Views/SettingsWindow.Tools.cs @@ -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(); - - // 설명 - 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 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; - } - }