[Phase51] 대규모 파일 분리 — 9개 파일 → 19개 파일
SettingsWindow.Tools: - SettingsWindow.Tools.cs: 605 → 238줄 (BuildToolRegistryPanel 유지) - SettingsWindow.SkillListPanel.cs (신규): BuildSkillListSection, CreateSkillGroupCard (295줄) LauncherWindow.Keyboard: - LauncherWindow.Keyboard.cs: 593 → 454줄 - LauncherWindow.ShortcutHelp.cs (신규): ShowShortcutHelp, ShowToast, TryHandleSpecialAction (139줄) CalculatorHandler: - CalculatorHandler.cs: 566 → ~240줄 (CalculatorHandler 클래스만 유지) - UnitConverter.cs (신규): 단위변환 클래스 독립 파일 (152줄) - MathEvaluator.cs (신규): 수식파서 클래스 독립 파일 (183줄) EmojiHandler: - EmojiHandler.cs: 553 → 70줄 (핸들러 메서드만 유지) - EmojiHandler.Data.cs (신규): 이모지 데이터베이스 배열 (~490줄) DocumentPlannerTool: - DocumentPlannerTool.cs: 598 → 324줄 (Execute 메서드 유지) - DocumentPlannerTool.Generators.cs (신규): GenerateHtml/Docx/Markdown, BuildSections 등 (274줄) DocumentReaderTool: - DocumentReaderTool.cs: 571 → 338줄 (ExecuteAsync + PDF 메서드 유지) - DocumentReaderTool.Formats.cs (신규): BibTeX/RIS/DOCX/XLSX/Text/Helpers (233줄) CodeIndexService: - CodeIndexService.cs: 588 → 285줄 (DB/인덱싱 유지) - CodeIndexService.Search.cs (신규): Search, TF-IDF, Tokenize, Dispose (199줄) ClipboardHistoryService: - ClipboardHistoryService.cs: 575 → 458줄 - ClipboardHistoryService.ImageCache.cs (신규): 이미지 캐시 유틸리티 (120줄) IndexService: - IndexService.cs: 568 → 412줄 - IndexService.Helpers.cs (신규): 경로 탐색 헬퍼 + 검색 캐시 (163줄) 빌드: 경고 0, 오류 0 Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -111,335 +111,6 @@ public class CalculatorHandler : IActionHandler
|
||||
}
|
||||
}
|
||||
|
||||
// ─── 단위 변환 ─────────────────────────────────────────────────────────────────
|
||||
|
||||
/// <summary>
|
||||
/// "100km in miles", "32f in c", "5lb to kg" 형식의 단위 변환.
|
||||
/// </summary>
|
||||
internal static class UnitConverter
|
||||
{
|
||||
// 패턴: <숫자> <단위> in|to <단위>
|
||||
private static readonly Regex Pattern = new(
|
||||
@"^(-?\d+(?:\.\d+)?)\s*([a-z°/²³µ]+)\s+(?:in|to)\s+([a-z°/²³µ]+)$",
|
||||
RegexOptions.IgnoreCase | RegexOptions.Compiled);
|
||||
|
||||
public static bool TryConvert(string input, out string? result)
|
||||
{
|
||||
result = null;
|
||||
var m = Pattern.Match(input.Trim());
|
||||
if (!m.Success) return false;
|
||||
|
||||
if (!double.TryParse(m.Groups[1].Value,
|
||||
System.Globalization.NumberStyles.Float,
|
||||
System.Globalization.CultureInfo.InvariantCulture,
|
||||
out var value))
|
||||
return false;
|
||||
|
||||
var from = m.Groups[2].Value.ToLowerInvariant();
|
||||
var to = m.Groups[3].Value.ToLowerInvariant();
|
||||
|
||||
// 온도는 비선형 → 별도 처리
|
||||
if (TryConvertTemperature(value, from, to, out var tResult))
|
||||
{
|
||||
result = $"{FormatNum(tResult)} {TemperatureLabel(to)}";
|
||||
return true;
|
||||
}
|
||||
|
||||
// 나머지 범주(선형 변환)
|
||||
foreach (var table in _tables)
|
||||
{
|
||||
if (table.TryGetValue(from, out var fromFactor) &&
|
||||
table.TryGetValue(to, out var toFactor))
|
||||
{
|
||||
var converted = value * fromFactor / toFactor;
|
||||
result = $"{FormatNum(converted)} {to}";
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
// ─── 온도 ────────────────────────────────────────────────────────────────
|
||||
|
||||
private static bool TryConvertTemperature(double v, string from, string to, out double r)
|
||||
{
|
||||
r = 0;
|
||||
// 섭씨 표준화
|
||||
double celsius;
|
||||
switch (from)
|
||||
{
|
||||
case "c": case "°c": case "celsius": celsius = v; break;
|
||||
case "f": case "°f": case "fahrenheit": celsius = (v - 32) * 5 / 9; break;
|
||||
case "k": case "kelvin": celsius = v - 273.15; break;
|
||||
default: return false;
|
||||
}
|
||||
switch (to)
|
||||
{
|
||||
case "c": case "°c": case "celsius": r = celsius; break;
|
||||
case "f": case "°f": case "fahrenheit": r = celsius * 9 / 5 + 32; break;
|
||||
case "k": case "kelvin": r = celsius + 273.15; break;
|
||||
default: return false;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
private static string TemperatureLabel(string unit) => unit switch
|
||||
{
|
||||
"c" or "°c" or "celsius" => "°C",
|
||||
"f" or "°f" or "fahrenheit" => "°F",
|
||||
"k" or "kelvin" => "K",
|
||||
_ => unit
|
||||
};
|
||||
|
||||
// ─── 선형 변환 테이블 (기준 단위 = 1) ────────────────────────────────────
|
||||
|
||||
// 길이 (기준: m)
|
||||
private static readonly Dictionary<string, double> _length = new()
|
||||
{
|
||||
["km"] = 1000, ["m"] = 1, ["cm"] = 0.01, ["mm"] = 0.001,
|
||||
["mi"] = 1609.344, ["mile"] = 1609.344, ["miles"] = 1609.344,
|
||||
["ft"] = 0.3048, ["feet"] = 0.3048, ["foot"] = 0.3048,
|
||||
["in"] = 0.0254, ["inch"] = 0.0254, ["inches"] = 0.0254,
|
||||
["yd"] = 0.9144, ["yard"] = 0.9144, ["yards"] = 0.9144,
|
||||
["nm"] = 1e-9,
|
||||
};
|
||||
|
||||
// 무게 (기준: kg)
|
||||
private static readonly Dictionary<string, double> _weight = new()
|
||||
{
|
||||
["t"] = 1000, ["ton"] = 1000, ["tonnes"] = 1000,
|
||||
["kg"] = 1, ["g"] = 0.001, ["mg"] = 1e-6,
|
||||
["lb"] = 0.453592, ["lbs"] = 0.453592, ["pound"] = 0.453592, ["pounds"] = 0.453592,
|
||||
["oz"] = 0.0283495, ["ounce"] = 0.0283495, ["ounces"] = 0.0283495,
|
||||
};
|
||||
|
||||
// 속도 (기준: m/s)
|
||||
private static readonly Dictionary<string, double> _speed = new()
|
||||
{
|
||||
["m/s"] = 1, ["mps"] = 1,
|
||||
["km/h"] = 1.0 / 3.6, ["kmh"] = 1.0 / 3.6, ["kph"] = 1.0 / 3.6,
|
||||
["mph"] = 0.44704,
|
||||
["kn"] = 0.514444, ["knot"] = 0.514444, ["knots"] = 0.514444,
|
||||
};
|
||||
|
||||
// 데이터 (기준: byte)
|
||||
private static readonly Dictionary<string, double> _data = new()
|
||||
{
|
||||
["b"] = 1, ["byte"] = 1, ["bytes"] = 1,
|
||||
["kb"] = 1024, ["kib"] = 1024,
|
||||
["mb"] = 1024 * 1024, ["mib"] = 1024 * 1024,
|
||||
["gb"] = 1024.0 * 1024 * 1024, ["gib"] = 1024.0 * 1024 * 1024,
|
||||
["tb"] = 1024.0 * 1024 * 1024 * 1024, ["tib"] = 1024.0 * 1024 * 1024 * 1024,
|
||||
["pb"] = 1024.0 * 1024 * 1024 * 1024 * 1024,
|
||||
};
|
||||
|
||||
// 넓이 (기준: m²)
|
||||
private static readonly Dictionary<string, double> _area = new()
|
||||
{
|
||||
["m²"] = 1, ["m2"] = 1,
|
||||
["km²"] = 1e6, ["km2"] = 1e6,
|
||||
["cm²"] = 1e-4, ["cm2"] = 1e-4,
|
||||
["ha"] = 10000,
|
||||
["acre"] = 4046.86, ["acres"] = 4046.86,
|
||||
["ft²"] = 0.092903, ["ft2"] = 0.092903,
|
||||
};
|
||||
|
||||
private static readonly List<Dictionary<string, double>> _tables = new()
|
||||
{ _length, _weight, _speed, _data, _area };
|
||||
|
||||
private static string FormatNum(double v)
|
||||
{
|
||||
if (v == Math.Floor(v) && Math.Abs(v) < 1e12)
|
||||
return ((long)v).ToString("N0", System.Globalization.CultureInfo.CurrentCulture);
|
||||
return v.ToString("G6", System.Globalization.CultureInfo.InvariantCulture);
|
||||
}
|
||||
}
|
||||
|
||||
// ─── 수식 파서 ─────────────────────────────────────────────────────────────────
|
||||
|
||||
/// <summary>
|
||||
/// 재귀 하강 파서 기반 수학 수식 평가기.
|
||||
/// 지원: +, -, *, /, %, ^ (거듭제곱), 괄호, 단항 음수,
|
||||
/// sqrt, abs, ceil, floor, round, sin, cos, tan (도 단위),
|
||||
/// log (밑 10), ln (자연로그), pi, e
|
||||
/// </summary>
|
||||
internal static class MathEvaluator
|
||||
{
|
||||
public static double Evaluate(string expr)
|
||||
{
|
||||
var evaluator = new Evaluator(
|
||||
expr.Replace(" ", "")
|
||||
.Replace("×", "*")
|
||||
.Replace("÷", "/")
|
||||
.Replace(",", ",")
|
||||
.ToLowerInvariant());
|
||||
return evaluator.Parse();
|
||||
}
|
||||
|
||||
private class Evaluator
|
||||
{
|
||||
private readonly string _s;
|
||||
private int _i;
|
||||
|
||||
public Evaluator(string s) { _s = s; _i = 0; }
|
||||
|
||||
public double Parse()
|
||||
{
|
||||
var result = ParseExpr();
|
||||
if (_i < _s.Length)
|
||||
throw new InvalidOperationException($"예기치 않은 문자: '{_s[_i]}'");
|
||||
return result;
|
||||
}
|
||||
|
||||
// 덧셈 / 뺄셈
|
||||
private double ParseExpr()
|
||||
{
|
||||
var left = ParseTerm();
|
||||
while (_i < _s.Length && (_s[_i] == '+' || _s[_i] == '-'))
|
||||
{
|
||||
var op = _s[_i++];
|
||||
var right = ParseTerm();
|
||||
left = op == '+' ? left + right : left - right;
|
||||
}
|
||||
return left;
|
||||
}
|
||||
|
||||
// 곱셈 / 나눗셈 / 나머지
|
||||
private double ParseTerm()
|
||||
{
|
||||
var left = ParsePower();
|
||||
while (_i < _s.Length && (_s[_i] == '*' || _s[_i] == '/' || _s[_i] == '%'))
|
||||
{
|
||||
var op = _s[_i++];
|
||||
var right = ParsePower();
|
||||
left = op == '*' ? left * right
|
||||
: op == '/' ? left / right
|
||||
: left % right;
|
||||
}
|
||||
return left;
|
||||
}
|
||||
|
||||
// 거듭제곱 (오른쪽 결합)
|
||||
private double ParsePower()
|
||||
{
|
||||
var b = ParseUnary();
|
||||
if (_i < _s.Length && _s[_i] == '^')
|
||||
{
|
||||
_i++;
|
||||
var exp = ParseUnary();
|
||||
return Math.Pow(b, exp);
|
||||
}
|
||||
return b;
|
||||
}
|
||||
|
||||
// 단항 부호
|
||||
private double ParseUnary()
|
||||
{
|
||||
if (_i < _s.Length && _s[_i] == '-') { _i++; return -ParsePrimary(); }
|
||||
if (_i < _s.Length && _s[_i] == '+') { _i++; return ParsePrimary(); }
|
||||
return ParsePrimary();
|
||||
}
|
||||
|
||||
// 리터럴 / 괄호 / 함수 호출
|
||||
private double ParsePrimary()
|
||||
{
|
||||
if (_i >= _s.Length)
|
||||
throw new InvalidOperationException("수식이 불완전합니다.");
|
||||
|
||||
// 16진수 리터럴 0x...
|
||||
if (_i + 1 < _s.Length && _s[_i] == '0' && _s[_i + 1] == 'x')
|
||||
{
|
||||
_i += 2;
|
||||
var hexStart = _i;
|
||||
while (_i < _s.Length && "0123456789abcdef".Contains(_s[_i])) _i++;
|
||||
return Convert.ToInt64(_s[hexStart.._i], 16);
|
||||
}
|
||||
|
||||
// 숫자
|
||||
if (char.IsDigit(_s[_i]) || _s[_i] == '.')
|
||||
{
|
||||
var start = _i;
|
||||
while (_i < _s.Length && (char.IsDigit(_s[_i]) || _s[_i] == '.')) _i++;
|
||||
// 과학적 표기: 1.5e3
|
||||
if (_i < _s.Length && _s[_i] == 'e')
|
||||
{
|
||||
_i++;
|
||||
if (_i < _s.Length && (_s[_i] == '+' || _s[_i] == '-')) _i++;
|
||||
while (_i < _s.Length && char.IsDigit(_s[_i])) _i++;
|
||||
}
|
||||
return double.Parse(_s[start.._i],
|
||||
System.Globalization.NumberStyles.Float,
|
||||
System.Globalization.CultureInfo.InvariantCulture);
|
||||
}
|
||||
|
||||
// 괄호
|
||||
if (_s[_i] == '(')
|
||||
{
|
||||
_i++;
|
||||
var val = ParseExpr();
|
||||
if (_i < _s.Length && _s[_i] == ')') _i++;
|
||||
return val;
|
||||
}
|
||||
|
||||
// 식별자 (상수 또는 함수)
|
||||
if (char.IsLetter(_s[_i]))
|
||||
{
|
||||
var start = _i;
|
||||
while (_i < _s.Length && (char.IsLetterOrDigit(_s[_i]) || _s[_i] == '_')) _i++;
|
||||
var name = _s[start.._i];
|
||||
|
||||
// 상수
|
||||
if (name == "pi") return Math.PI;
|
||||
if (name == "e") return Math.E;
|
||||
if (name == "inf") return double.PositiveInfinity;
|
||||
|
||||
// 함수 호출
|
||||
if (_i < _s.Length && _s[_i] == '(')
|
||||
{
|
||||
_i++; // (
|
||||
var arg = ParseExpr();
|
||||
// 두 번째 인자 (pow, log2 등)
|
||||
double? arg2 = null;
|
||||
if (_i < _s.Length && _s[_i] == ',')
|
||||
{
|
||||
_i++;
|
||||
arg2 = ParseExpr();
|
||||
}
|
||||
if (_i < _s.Length && _s[_i] == ')') _i++;
|
||||
|
||||
return name switch
|
||||
{
|
||||
"sqrt" => Math.Sqrt(arg),
|
||||
"abs" => Math.Abs(arg),
|
||||
"ceil" => Math.Ceiling(arg),
|
||||
"floor" => Math.Floor(arg),
|
||||
"round" => arg2.HasValue ? Math.Round(arg, (int)arg2.Value) : Math.Round(arg),
|
||||
"sin" => Math.Sin(arg * Math.PI / 180), // 도 단위
|
||||
"cos" => Math.Cos(arg * Math.PI / 180),
|
||||
"tan" => Math.Tan(arg * Math.PI / 180),
|
||||
"asin" => Math.Asin(arg) * 180 / Math.PI,
|
||||
"acos" => Math.Acos(arg) * 180 / Math.PI,
|
||||
"atan" => Math.Atan(arg) * 180 / Math.PI,
|
||||
"log" => arg2.HasValue ? Math.Log(arg, arg2.Value) : Math.Log10(arg),
|
||||
"log2" => Math.Log2(arg),
|
||||
"ln" => Math.Log(arg),
|
||||
"exp" => Math.Exp(arg),
|
||||
"pow" => arg2.HasValue ? Math.Pow(arg, arg2.Value) : throw new InvalidOperationException("pow(x,y) 형식으로 사용하세요."),
|
||||
"min" => arg2.HasValue ? Math.Min(arg, arg2.Value) : arg,
|
||||
"max" => arg2.HasValue ? Math.Max(arg, arg2.Value) : arg,
|
||||
_ => throw new InvalidOperationException($"알 수 없는 함수: {name}()")
|
||||
};
|
||||
}
|
||||
|
||||
throw new InvalidOperationException($"알 수 없는 식별자: {name}");
|
||||
}
|
||||
|
||||
throw new InvalidOperationException($"예기치 않은 문자: '{_s[_i]}'");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ─── 통화 변환 ──────────────────────────────────────────────────────────────────
|
||||
|
||||
/// <summary>
|
||||
|
||||
488
src/AxCopilot/Handlers/EmojiHandler.Data.cs
Normal file
488
src/AxCopilot/Handlers/EmojiHandler.Data.cs
Normal file
@@ -0,0 +1,488 @@
|
||||
namespace AxCopilot.Handlers;
|
||||
|
||||
public partial class EmojiHandler
|
||||
{
|
||||
// ─── 이모지 데이터베이스 (이모지, 이름(한/영), 태그) ──────────────────────
|
||||
private static readonly (string Emoji, string Name, string Tags)[] _emojis =
|
||||
{
|
||||
// 표정 / 감정
|
||||
("😀", "크게 웃는 얼굴", "smile happy grin 웃음 행복"),
|
||||
("😃", "웃는 얼굴", "smile happy joy 웃음"),
|
||||
("😄", "눈 웃음", "smile laugh 웃음 기쁨"),
|
||||
("😁", "히죽 웃음", "grin beam 씩 웃다"),
|
||||
("😆", "크게 웃음", "laughing 폭소"),
|
||||
("😅", "식은땀 웃음", "sweat smile 안도"),
|
||||
("🤣", "바닥 구르며 웃음", "rofl lol 빵 웃음"),
|
||||
("😂", "눈물 나게 웃음", "joy tears laugh 폭소"),
|
||||
("🙂", "살짝 웃음", "slightly smiling 미소"),
|
||||
("🙃", "거꾸로 웃음", "upside down 뒤집힌"),
|
||||
("😉", "윙크", "wink 윙크"),
|
||||
("😊", "볼 빨개진 웃음", "blush 부끄러움 미소"),
|
||||
("😇", "천사", "angel halo 천사 선량"),
|
||||
("🥰", "사랑스러운 얼굴", "love hearts 사랑 하트"),
|
||||
("😍", "하트 눈", "heart eyes 사랑 반함"),
|
||||
("🤩", "별 눈", "star struck 감동 황홀"),
|
||||
("😘", "뽀뽀", "kiss blow 키스 뽀뽀"),
|
||||
("😗", "오므린 입", "kiss whistle 키스"),
|
||||
("😚", "눈 감고 뽀뽀", "kiss 키스"),
|
||||
("😙", "볼 뽀뽀", "kiss 키스"),
|
||||
("😋", "맛있다", "yum delicious 맛 음식"),
|
||||
("😛", "혀 내밀기", "tongue out 혀 놀림"),
|
||||
("😜", "윙크하며 혀", "wink tongue 장난"),
|
||||
("🤪", "미친 표정", "zany crazy 정신없음"),
|
||||
("😝", "눈 감고 혀", "tongue 혀"),
|
||||
("🤑", "돈 눈", "money face 돈 부자"),
|
||||
("🤗", "포옹", "hugging hug 안아줘 포옹"),
|
||||
("🤭", "입 가리고", "hand over mouth 헉 깜짝"),
|
||||
("🤫", "쉿", "shushing quiet 조용 쉿"),
|
||||
("🤔", "생각 중", "thinking 고민 생각"),
|
||||
("🤐", "입 막음", "zipper mouth 비밀"),
|
||||
("🤨", "의심", "raised eyebrow 의심 의아"),
|
||||
("😐", "무표정", "neutral 무감각 무표정"),
|
||||
("😑", "표정 없음", "expressionless 냉담"),
|
||||
("😶", "입 없는 얼굴", "no mouth 침묵"),
|
||||
("😏", "비웃음", "smirk 비웃 냉소"),
|
||||
("😒", "불만", "unamused 불만 짜증"),
|
||||
("🙄", "눈 굴리기", "eye roll 어이없음"),
|
||||
("😬", "이 드러냄", "grimace 으 민망"),
|
||||
("🤥", "거짓말", "lying pinocchio 거짓말"),
|
||||
("😌", "안도/평온", "relieved 안도 평온"),
|
||||
("😔", "슬픔", "pensive sad 슬픔 우울"),
|
||||
("😪", "졸림", "sleepy 졸음"),
|
||||
("🤤", "침 흘림", "drooling 군침 식욕"),
|
||||
("😴", "잠", "sleeping sleep 수면 잠"),
|
||||
("😷", "마스크", "mask sick 마스크 아픔"),
|
||||
("🤒", "열 나는", "sick fever 열 아픔"),
|
||||
("🤕", "머리 붕대", "injured hurt 부상"),
|
||||
("🤢", "구역질", "nauseated sick 구역 메스꺼움"),
|
||||
("🤮", "토하는", "vomit 구토"),
|
||||
("🤧", "재채기", "sneezing sick 재채기 감기"),
|
||||
("🥵", "더운", "hot overheated 더움 열"),
|
||||
("🥶", "추운", "cold freezing 추움 냉기"),
|
||||
("🥴", "어지러운", "woozy 어지럼 취함"),
|
||||
("😵", "어질어질", "dizzy 어지럼 충격"),
|
||||
("🤯", "머리 폭발", "exploding head 충격 대박"),
|
||||
("🤠", "카우보이", "cowboy hat 카우보이"),
|
||||
("🥸", "변장", "disguise 변장 선글라스"),
|
||||
("😎", "쿨한", "cool sunglasses 선글라스 쿨"),
|
||||
("🤓", "공부벌레", "nerd glasses 공부 안경"),
|
||||
("🧐", "모노클", "monocle curious 고상 탐정"),
|
||||
("😕", "당황", "confused 당황 모호"),
|
||||
("😟", "걱정", "worried concern 걱정"),
|
||||
("🙁", "살짝 찡그림", "frown 슬픔"),
|
||||
("☹️", "찡그린 얼굴", "frown sad 슬픔"),
|
||||
("😮", "입 벌림", "open mouth surprised 놀람"),
|
||||
("😯", "놀람", "hushed surprised 깜짝"),
|
||||
("😲", "충격", "astonished 충격 놀람"),
|
||||
("😳", "얼굴 빨개짐", "flushed embarrassed 부끄럼 당황"),
|
||||
("🥺", "애원", "pleading eyes 부탁 눈빛"),
|
||||
("😦", "찡그리며 벌린 입", "frowning 불안"),
|
||||
("😧", "고통", "anguished 고통"),
|
||||
("😨", "무서움", "fearful scared 무서움 공포"),
|
||||
("😰", "식은땀", "anxious sweat 불안 걱정"),
|
||||
("😥", "눈물 조금", "sad disappointed 실망 눈물"),
|
||||
("😢", "울음", "cry sad 슬픔 눈물"),
|
||||
("😭", "엉엉 울음", "loudly crying sob 통곡"),
|
||||
("😱", "공포에 질림", "screaming fear 비명 공포"),
|
||||
("😖", "혼란", "confounded 혼란"),
|
||||
("😣", "힘듦", "persevering 고생"),
|
||||
("😞", "실망", "disappointed 실망"),
|
||||
("😓", "땀", "downcast sweat 땀 힘듦"),
|
||||
("😩", "피곤", "weary tired 지침 피곤"),
|
||||
("😫", "극도로 지침", "tired exhausted 탈진"),
|
||||
("🥱", "하품", "yawning bored 하품 지루함"),
|
||||
("😤", "콧김", "triumph snort 분노 콧김"),
|
||||
("😡", "화남", "angry mad 화남 분노"),
|
||||
("😠", "성남", "angry 화 성남"),
|
||||
("🤬", "욕", "cursing swearing 욕 분노"),
|
||||
("😈", "나쁜 미소", "smiling devil 악마 장난"),
|
||||
("👿", "화난 악마", "angry devil 악마"),
|
||||
("💀", "해골", "skull death 해골 죽음"),
|
||||
("☠️", "해골 십자", "skull crossbones 독"),
|
||||
("💩", "응가", "poop 똥 응가"),
|
||||
("🤡", "피에로", "clown 광대"),
|
||||
("👹", "도깨비", "ogre 도깨비 귀신"),
|
||||
("👺", "텐구", "goblin 텐구"),
|
||||
("👻", "유령", "ghost 유령 귀신"),
|
||||
("👾", "우주인", "alien monster 외계인 게임"),
|
||||
("🤖", "로봇", "robot 로봇"),
|
||||
|
||||
// 손 / 몸
|
||||
("👋", "손 흔들기", "wave waving hi bye 안녕"),
|
||||
("🤚", "손 뒤", "raised back hand 손"),
|
||||
("🖐️", "손바닥", "hand palm 다섯 손가락"),
|
||||
("✋", "손 들기", "raised hand 손 들기 멈춤"),
|
||||
("🖖", "스팍 손인사", "vulcan salute 스타트렉"),
|
||||
("👌", "오케이", "ok perfect 오케이 좋아"),
|
||||
("🤌", "손가락 모아", "pinched fingers 이탈리아"),
|
||||
("✌️", "브이", "victory peace v 브이 평화"),
|
||||
("🤞", "행운 손가락", "crossed fingers lucky 행운 기도"),
|
||||
("🤟", "아이 러브 유", "love you 사랑해"),
|
||||
("🤘", "록 손", "rock on metal 록"),
|
||||
("🤙", "전화해", "call me shaka 전화 샤카"),
|
||||
("👈", "왼쪽 가리킴", "backhand left 왼쪽"),
|
||||
("👉", "오른쪽 가리킴", "backhand right 오른쪽"),
|
||||
("👆", "위 가리킴", "backhand up 위"),
|
||||
("🖕", "욕", "middle finger 욕"),
|
||||
("👇", "아래 가리킴", "backhand down 아래"),
|
||||
("☝️", "검지 들기", "index pointing up 하나 포인트"),
|
||||
("👍", "좋아요", "thumbs up like good 좋아 최고"),
|
||||
("👎", "싫어요", "thumbs down dislike 싫어 별로"),
|
||||
("✊", "주먹", "fist punch 주먹"),
|
||||
("👊", "주먹 치기", "punch fist 주먹"),
|
||||
("🤛", "왼 주먹", "left fist 주먹"),
|
||||
("🤜", "오른 주먹", "right fist 주먹"),
|
||||
("👏", "박수", "clapping applause 박수 응원"),
|
||||
("🙌", "만세", "raising hands celebrate 만세"),
|
||||
("👐", "양손 펼침", "open hands 환영"),
|
||||
("🤲", "두 손 모음", "palms up together 기도 바람"),
|
||||
("🙏", "두 손 합장", "pray please thanks 감사 부탁 기도"),
|
||||
("✍️", "글쓰기", "writing pen 글쓰기"),
|
||||
("💅", "네일", "nail polish manicure 네일 손톱"),
|
||||
("🤳", "셀카", "selfie 셀카"),
|
||||
("💪", "근육", "muscle strong 근육 힘"),
|
||||
("🦾", "기계 팔", "mechanical arm 로봇 팔"),
|
||||
("🦿", "기계 다리", "mechanical leg 로봇 다리"),
|
||||
("🦵", "다리", "leg kick 다리"),
|
||||
("🦶", "발", "foot kick 발"),
|
||||
("👂", "귀", "ear hear 귀"),
|
||||
("🦻", "보청기 귀", "ear hearing aid 보청기"),
|
||||
("👃", "코", "nose smell 코"),
|
||||
("🫀", "심장", "heart anatomical 심장"),
|
||||
("🫁", "폐", "lungs 폐"),
|
||||
("🧠", "뇌", "brain mind 뇌 지능"),
|
||||
("🦷", "치아", "tooth dental 치아"),
|
||||
("🦴", "뼈", "bone 뼈"),
|
||||
("👀", "눈", "eyes look see 눈 보기"),
|
||||
("👁️", "한쪽 눈", "eye 눈"),
|
||||
("👅", "혀", "tongue 혀"),
|
||||
("👄", "입술", "lips mouth 입술"),
|
||||
("💋", "입맞춤", "kiss lips 키스 입술"),
|
||||
("🩸", "피", "blood drop 피 혈액"),
|
||||
|
||||
// 하트 / 감정 기호
|
||||
("❤️", "빨간 하트", "red heart love 사랑 빨강"),
|
||||
("🧡", "주황 하트", "orange heart 사랑"),
|
||||
("💛", "노란 하트", "yellow heart 사랑"),
|
||||
("💚", "초록 하트", "green heart 사랑"),
|
||||
("💙", "파란 하트", "blue heart 사랑"),
|
||||
("💜", "보라 하트", "purple heart 사랑"),
|
||||
("🖤", "검은 하트", "black heart 사랑 다크"),
|
||||
("🤍", "흰 하트", "white heart 사랑"),
|
||||
("🤎", "갈색 하트", "brown heart 사랑"),
|
||||
("💔", "깨진 하트", "broken heart 이별 상처"),
|
||||
("❣️", "느낌표 하트", "heart exclamation 사랑"),
|
||||
("💕", "두 하트", "two hearts 사랑"),
|
||||
("💞", "회전 하트", "revolving hearts 사랑"),
|
||||
("💓", "뛰는 하트", "beating heart 설렘"),
|
||||
("💗", "성장 하트", "growing heart 사랑"),
|
||||
("💖", "반짝 하트", "sparkling heart 사랑"),
|
||||
("💘", "화살 하트", "heart arrow 큐피드"),
|
||||
("💝", "리본 하트", "heart ribbon 선물 사랑"),
|
||||
("💟", "하트 장식", "heart decoration 사랑"),
|
||||
("☮️", "평화", "peace 평화"),
|
||||
("✝️", "십자가", "cross 기독교"),
|
||||
("☯️", "음양", "yin yang 음양 균형"),
|
||||
("🔮", "수정구", "crystal ball magic 마법 점"),
|
||||
("✨", "반짝임", "sparkles glitter 빛 반짝"),
|
||||
("⭐", "별", "star 별"),
|
||||
("🌟", "빛나는 별", "glowing star 별빛"),
|
||||
("💫", "현기증", "dizzy star 빙글"),
|
||||
("⚡", "번개", "lightning bolt 번개 전기"),
|
||||
("🔥", "불", "fire hot 불 열정"),
|
||||
("💥", "폭발", "explosion boom 폭발"),
|
||||
("❄️", "눈송이", "snowflake cold 눈 추위"),
|
||||
("🌈", "무지개", "rainbow 무지개"),
|
||||
("☀️", "태양", "sun sunny 태양 맑음"),
|
||||
("🌙", "달", "moon crescent 달"),
|
||||
("🌊", "파도", "wave ocean 파도 바다"),
|
||||
("💨", "바람", "wind dash 바람"),
|
||||
("💦", "물방울", "sweat droplets water 물"),
|
||||
("🌸", "벚꽃", "cherry blossom 벚꽃 봄"),
|
||||
("🌹", "장미", "rose 장미 꽃"),
|
||||
("🌺", "히비스커스", "hibiscus 꽃"),
|
||||
("🌻", "해바라기", "sunflower 해바라기"),
|
||||
("🌼", "꽃", "blossom flower 꽃"),
|
||||
("🌷", "튤립", "tulip 튤립"),
|
||||
("💐", "꽃다발", "bouquet flowers 꽃다발"),
|
||||
("🍀", "네잎클로버", "four leaf clover lucky 행운"),
|
||||
("🌿", "허브", "herb green 풀 허브"),
|
||||
("🍃", "잎사귀", "leaf 잎"),
|
||||
|
||||
// 음식
|
||||
("🍕", "피자", "pizza 피자"),
|
||||
("🍔", "햄버거", "hamburger burger 버거"),
|
||||
("🌮", "타코", "taco 타코"),
|
||||
("🍜", "라면", "ramen noodles 라면 국수"),
|
||||
("🍱", "도시락", "bento box 도시락"),
|
||||
("🍣", "초밥", "sushi 초밥"),
|
||||
("🍚", "밥", "rice 밥"),
|
||||
("🍛", "카레", "curry rice 카레"),
|
||||
("🍝", "파스타", "pasta spaghetti 파스타"),
|
||||
("🍦", "소프트 아이스크림", "ice cream soft serve 아이스크림"),
|
||||
("🎂", "생일 케이크", "cake birthday 생일 케이크"),
|
||||
("🍰", "케이크 조각", "cake slice 케이크"),
|
||||
("🧁", "컵케이크", "cupcake 컵케이크"),
|
||||
("🍩", "도넛", "donut 도넛"),
|
||||
("🍪", "쿠키", "cookie 쿠키"),
|
||||
("🍫", "초콜릿", "chocolate bar 초콜릿"),
|
||||
("🍬", "사탕", "candy 사탕"),
|
||||
("🍭", "막대 사탕", "lollipop 막대사탕"),
|
||||
("🍺", "맥주", "beer mug 맥주"),
|
||||
("🍻", "건배", "clinking beer 건배"),
|
||||
("🥂", "샴페인 건배", "champagne 샴페인 건배"),
|
||||
("🍷", "와인", "wine 와인"),
|
||||
("☕", "커피", "coffee hot 커피"),
|
||||
("🧃", "주스", "juice 주스"),
|
||||
("🥤", "음료", "drink cup 음료 컵"),
|
||||
("🧋", "버블티", "bubble tea boba 버블티"),
|
||||
("🍵", "녹차", "tea matcha 차 녹차"),
|
||||
|
||||
// 동물
|
||||
("🐶", "강아지", "dog puppy 강아지 개"),
|
||||
("🐱", "고양이", "cat kitten 고양이"),
|
||||
("🐭", "쥐", "mouse 쥐"),
|
||||
("🐹", "햄스터", "hamster 햄스터"),
|
||||
("🐰", "토끼", "rabbit bunny 토끼"),
|
||||
("🦊", "여우", "fox 여우"),
|
||||
("🐻", "곰", "bear 곰"),
|
||||
("🐼", "판다", "panda 판다"),
|
||||
("🐨", "코알라", "koala 코알라"),
|
||||
("🐯", "호랑이", "tiger 호랑이"),
|
||||
("🦁", "사자", "lion 사자"),
|
||||
("🐮", "소", "cow 소"),
|
||||
("🐷", "돼지", "pig 돼지"),
|
||||
("🐸", "개구리", "frog 개구리"),
|
||||
("🐵", "원숭이", "monkey 원숭이"),
|
||||
("🙈", "눈 가린 원숭이", "see no evil monkey 안 봐"),
|
||||
("🙉", "귀 가린 원숭이", "hear no evil monkey 안 들어"),
|
||||
("🙊", "입 가린 원숭이", "speak no evil monkey 안 말해"),
|
||||
("🐔", "닭", "chicken 닭"),
|
||||
("🐧", "펭귄", "penguin 펭귄"),
|
||||
("🐦", "새", "bird 새"),
|
||||
("🦆", "오리", "duck 오리"),
|
||||
("🦅", "독수리", "eagle 독수리"),
|
||||
("🦉", "부엉이", "owl 부엉이"),
|
||||
("🐍", "뱀", "snake 뱀"),
|
||||
("🐢", "거북이", "turtle 거북이"),
|
||||
("🦋", "나비", "butterfly 나비"),
|
||||
("🐌", "달팽이", "snail 달팽이"),
|
||||
("🐛", "애벌레", "bug caterpillar 애벌레"),
|
||||
("🐝", "꿀벌", "bee honeybee 벌"),
|
||||
("🦑", "오징어", "squid 오징어"),
|
||||
("🐙", "문어", "octopus 문어"),
|
||||
("🐠", "열대어", "tropical fish 열대어"),
|
||||
("🐡", "복어", "blowfish puffer 복어"),
|
||||
("🦈", "상어", "shark 상어"),
|
||||
("🐬", "돌고래", "dolphin 돌고래"),
|
||||
("🐳", "고래", "whale 고래"),
|
||||
("🐲", "용", "dragon 용"),
|
||||
("🦄", "유니콘", "unicorn 유니콘"),
|
||||
|
||||
// 물건 / 도구
|
||||
("📱", "스마트폰", "phone mobile smartphone 폰"),
|
||||
("💻", "노트북", "laptop computer 노트북"),
|
||||
("🖥️", "데스크톱", "desktop computer 컴퓨터"),
|
||||
("⌨️", "키보드", "keyboard 키보드"),
|
||||
("🖱️", "마우스", "mouse 마우스"),
|
||||
("🖨️", "프린터", "printer 프린터"),
|
||||
("📷", "카메라", "camera 카메라"),
|
||||
("📸", "플래시 카메라", "camera flash 사진"),
|
||||
("📹", "비디오 카메라", "video camera 동영상"),
|
||||
("🎥", "영화 카메라", "movie camera film 영화"),
|
||||
("📺", "TV", "television tv 텔레비전"),
|
||||
("📻", "라디오", "radio 라디오"),
|
||||
("🎙️", "마이크", "microphone studio 마이크"),
|
||||
("🎤", "마이크 핸드헬드", "microphone karaoke 마이크"),
|
||||
("🎧", "헤드폰", "headphones 헤드폰"),
|
||||
("📡", "안테나", "satellite antenna 안테나"),
|
||||
("🔋", "배터리", "battery 배터리"),
|
||||
("🔌", "전원 플러그", "plug electric 플러그"),
|
||||
("💡", "전구", "bulb idea light 전구 아이디어"),
|
||||
("🔦", "손전등", "flashlight torch 손전등"),
|
||||
("🕯️", "양초", "candle 양초"),
|
||||
("📚", "책", "books stack 책"),
|
||||
("📖", "열린 책", "open book read 독서"),
|
||||
("📝", "메모", "memo note pencil 메모 노트"),
|
||||
("✏️", "연필", "pencil 연필"),
|
||||
("🖊️", "펜", "pen 펜"),
|
||||
("📌", "압정", "pushpin pin 압정"),
|
||||
("📎", "클립", "paperclip 클립"),
|
||||
("✂️", "가위", "scissors cut 가위"),
|
||||
("🗂️", "파일 폴더", "card index dividers folder 파일"),
|
||||
("📁", "폴더", "folder 폴더"),
|
||||
("📂", "열린 폴더", "open folder 폴더"),
|
||||
("🗃️", "파일 박스", "card file box 서류함"),
|
||||
("🗑️", "휴지통", "wastebasket trash 휴지통"),
|
||||
("🔒", "잠금", "locked lock 잠금"),
|
||||
("🔓", "열림", "unlocked 열림"),
|
||||
("🔑", "열쇠", "key 열쇠"),
|
||||
("🗝️", "구식 열쇠", "old key 열쇠"),
|
||||
("🔨", "망치", "hammer 망치"),
|
||||
("🔧", "렌치", "wrench tool 렌치"),
|
||||
("🔩", "나사", "nut bolt 나사"),
|
||||
("⚙️", "톱니바퀴", "gear settings 설정 톱니"),
|
||||
("🛠️", "도구", "tools hammer wrench 도구"),
|
||||
("💊", "알약", "pill medicine 약 알약"),
|
||||
("💉", "주사기", "syringe injection 주사"),
|
||||
("🩺", "청진기", "stethoscope doctor 청진기"),
|
||||
("🏆", "트로피", "trophy award 트로피 우승"),
|
||||
("🥇", "금메달", "first gold medal 금메달"),
|
||||
("🥈", "은메달", "second silver 은메달"),
|
||||
("🥉", "동메달", "third bronze 동메달"),
|
||||
("🎖️", "훈장", "medal military 훈장"),
|
||||
("🎗️", "리본", "ribbon awareness 리본"),
|
||||
("🎫", "티켓", "ticket admission 티켓"),
|
||||
("🎟️", "입장권", "admission tickets 티켓"),
|
||||
("🎪", "서커스", "circus tent 서커스"),
|
||||
("🎨", "팔레트", "art palette paint 그림 예술"),
|
||||
("🎭", "연극", "performing arts theater 연극"),
|
||||
("🎬", "클래퍼보드", "clapper film 영화 촬영"),
|
||||
("🎮", "게임 컨트롤러", "video game controller 게임"),
|
||||
("🎲", "주사위", "dice game 주사위"),
|
||||
("🎯", "다트", "bullseye target dart 다트 목표"),
|
||||
("🎳", "볼링", "bowling 볼링"),
|
||||
("⚽", "축구", "soccer football 축구"),
|
||||
("🏀", "농구", "basketball 농구"),
|
||||
("🏈", "미식축구", "american football 미식축구"),
|
||||
("⚾", "야구", "baseball 야구"),
|
||||
("🎾", "테니스", "tennis 테니스"),
|
||||
("🏐", "배구", "volleyball 배구"),
|
||||
("🏉", "럭비", "rugby 럭비"),
|
||||
("🎱", "당구", "billiards pool 당구"),
|
||||
("🏓", "탁구", "ping pong table tennis 탁구"),
|
||||
("🏸", "배드민턴", "badminton 배드민턴"),
|
||||
("🥊", "권투 장갑", "boxing glove 권투"),
|
||||
("🎣", "낚시", "fishing 낚시"),
|
||||
("🏋️", "역도", "weightlifting gym 헬스 역도"),
|
||||
("🧘", "명상", "yoga meditation 명상 요가"),
|
||||
|
||||
// 이동수단
|
||||
("🚗", "자동차", "car automobile 자동차"),
|
||||
("🚕", "택시", "taxi cab 택시"),
|
||||
("🚙", "SUV", "suv car 차"),
|
||||
("🚌", "버스", "bus 버스"),
|
||||
("🚎", "무궤도 전차", "trolleybus 버스"),
|
||||
("🏎️", "레이싱카", "racing car 레이싱"),
|
||||
("🚓", "경찰차", "police car 경찰"),
|
||||
("🚑", "구급차", "ambulance 구급차"),
|
||||
("🚒", "소방차", "fire truck 소방차"),
|
||||
("🚐", "미니밴", "minibus van 밴"),
|
||||
("🚚", "트럭", "truck delivery 트럭"),
|
||||
("✈️", "비행기", "airplane flight plane 비행기"),
|
||||
("🚀", "로켓", "rocket space launch 로켓"),
|
||||
("🛸", "UFO", "flying saucer ufo 유에프오"),
|
||||
("🚁", "헬리콥터", "helicopter 헬리콥터"),
|
||||
("🚂", "기차", "train locomotive 기차"),
|
||||
("🚆", "고속열차", "train 기차"),
|
||||
("🚇", "지하철", "metro subway 지하철"),
|
||||
("⛵", "돛단배", "sailboat 요트"),
|
||||
("🚢", "배", "ship cruise 배"),
|
||||
("🚲", "자전거", "bicycle bike 자전거"),
|
||||
("🛵", "스쿠터", "scooter moped 스쿠터"),
|
||||
("🏍️", "오토바이", "motorcycle 오토바이"),
|
||||
|
||||
// 장소
|
||||
("🏠", "집", "house home 집"),
|
||||
("🏡", "마당 있는 집", "house garden 집"),
|
||||
("🏢", "빌딩", "office building 빌딩"),
|
||||
("🏣", "우체국", "post office 우체국"),
|
||||
("🏥", "병원", "hospital 병원"),
|
||||
("🏦", "은행", "bank 은행"),
|
||||
("🏨", "호텔", "hotel 호텔"),
|
||||
("🏫", "학교", "school 학교"),
|
||||
("🏪", "편의점", "convenience store shop 편의점"),
|
||||
("🏬", "백화점", "department store 백화점"),
|
||||
("🏰", "성", "castle 성"),
|
||||
("⛪", "교회", "church 교회"),
|
||||
("🕌", "모스크", "mosque 모스크"),
|
||||
("🗼", "에펠탑", "eiffel tower paris 파리"),
|
||||
("🗽", "자유의 여신상", "statue of liberty new york 뉴욕"),
|
||||
("🏔️", "산", "mountain snow 산"),
|
||||
("🌋", "화산", "volcano 화산"),
|
||||
("🗻", "후지산", "mount fuji japan 후지산"),
|
||||
("🏕️", "캠핑", "camping tent 캠핑"),
|
||||
("🏖️", "해변", "beach summer 해변 해수욕"),
|
||||
("🌏", "지구", "earth globe asia 지구"),
|
||||
|
||||
// 기호 / 숫자
|
||||
("💯", "100점", "hundred percent perfect 완벽 100"),
|
||||
("🔢", "숫자", "numbers 숫자"),
|
||||
("🆗", "OK", "ok button 오케이"),
|
||||
("🆙", "업", "up button 업"),
|
||||
("🆒", "쿨", "cool button 쿨"),
|
||||
("🆕", "새것", "new button 새"),
|
||||
("🆓", "무료", "free button 무료"),
|
||||
("🆘", "SOS", "sos emergency 긴급 구조"),
|
||||
("⚠️", "경고", "warning caution 경고 주의"),
|
||||
("🚫", "금지", "prohibited no 금지"),
|
||||
("✅", "체크", "check mark done 완료 확인"),
|
||||
("❌", "엑스", "x cross error 실패 오류"),
|
||||
("❓", "물음표", "question mark 물음표"),
|
||||
("❗", "느낌표", "exclamation mark 느낌표"),
|
||||
("➕", "더하기", "plus add 더하기"),
|
||||
("➖", "빼기", "minus subtract 빼기"),
|
||||
("➗", "나누기", "divide 나누기"),
|
||||
("✖️", "곱하기", "multiply times 곱하기"),
|
||||
("♾️", "무한대", "infinity 무한"),
|
||||
("🔁", "반복", "repeat loop 반복"),
|
||||
("🔀", "셔플", "shuffle random 랜덤"),
|
||||
("▶️", "재생", "play 재생"),
|
||||
("⏸️", "일시정지", "pause 일시정지"),
|
||||
("⏹️", "정지", "stop 정지"),
|
||||
("⏩", "빨리 감기", "fast forward 빨리감기"),
|
||||
("⏪", "되감기", "rewind 되감기"),
|
||||
("🔔", "알림", "bell notification 알림 벨"),
|
||||
("🔕", "알림 끔", "bell off 알림끔"),
|
||||
("🔊", "볼륨 크게", "loud speaker volume up 볼륨"),
|
||||
("🔇", "음소거", "muted speaker 음소거"),
|
||||
("📣", "메가폰", "megaphone loud 확성기"),
|
||||
("📢", "스피커", "loudspeaker 스피커"),
|
||||
("💬", "말풍선", "speech bubble chat 대화"),
|
||||
("💭", "생각 말풍선", "thought bubble thinking 생각"),
|
||||
("📧", "이메일", "email mail 이메일 메일"),
|
||||
("📨", "수신 봉투", "incoming envelope 수신"),
|
||||
("📩", "발신 봉투", "envelope outbox 발신"),
|
||||
("📬", "우편함", "mailbox 우편함"),
|
||||
("📦", "택배 박스", "package box parcel 택배 상자"),
|
||||
("🎁", "선물", "gift present 선물"),
|
||||
("🎀", "리본 묶음", "ribbon bow 리본"),
|
||||
("🎊", "색종이", "confetti 파티 축하"),
|
||||
("🎉", "파티 폭죽", "party popper celebrate 파티 축하"),
|
||||
("🎈", "풍선", "balloon party 풍선"),
|
||||
("🕐", "1시", "one o'clock 1시 시간"),
|
||||
("🕒", "3시", "three o'clock 3시 시간"),
|
||||
("🕔", "4시", "four o'clock 4시 시간"),
|
||||
("⏰", "알람 시계", "alarm clock 알람 시계"),
|
||||
("⏱️", "스톱워치", "stopwatch timer 스톱워치 타이머"),
|
||||
("📅", "달력", "calendar date 달력 날짜"),
|
||||
("📆", "찢는 달력", "tear-off calendar 달력"),
|
||||
("💰", "돈 가방", "money bag 돈 부자"),
|
||||
("💳", "신용카드", "credit card payment 카드 결제"),
|
||||
("💵", "달러", "dollar banknote 달러"),
|
||||
("💴", "엔화", "yen banknote 엔"),
|
||||
("💶", "유로", "euro banknote 유로"),
|
||||
("💷", "파운드", "pound banknote 파운드"),
|
||||
("📊", "막대 그래프", "bar chart graph 그래프"),
|
||||
("📈", "상승 그래프", "chart increasing trend 상승 트렌드"),
|
||||
("📉", "하락 그래프", "chart decreasing trend 하락"),
|
||||
("🔍", "돋보기", "magnifying glass search 검색 돋보기"),
|
||||
("🔎", "오른쪽 돋보기", "magnifying glass right search 검색"),
|
||||
("🏳️", "흰 깃발", "white flag 항복"),
|
||||
("🏴", "검은 깃발", "black flag 해적"),
|
||||
("🚩", "빨간 삼각기", "triangular flag 경고 깃발"),
|
||||
("🏁", "체크무늬 깃발", "chequered flag finish race 결승"),
|
||||
("🌐", "지구본", "globe internet web 인터넷 웹"),
|
||||
("⚓", "닻", "anchor 닻"),
|
||||
("🎵", "음표", "music note 음악 음표"),
|
||||
("🎶", "음표들", "musical notes 음악"),
|
||||
("🎼", "악보", "musical score 악보"),
|
||||
("🎹", "피아노", "piano keyboard 피아노"),
|
||||
("🎸", "기타", "guitar 기타"),
|
||||
("🥁", "드럼", "drum 드럼"),
|
||||
("🪗", "아코디언", "accordion 아코디언"),
|
||||
("🎷", "색소폰", "saxophone 색소폰"),
|
||||
("🎺", "트럼펫", "trumpet 트럼펫"),
|
||||
("🎻", "바이올린", "violin 바이올린"),
|
||||
};
|
||||
}
|
||||
@@ -10,7 +10,7 @@ namespace AxCopilot.Handlers;
|
||||
/// emoji wave → 👋 검색
|
||||
/// emoji → 자주 쓰는 이모지 목록
|
||||
/// </summary>
|
||||
public class EmojiHandler : IActionHandler
|
||||
public partial class EmojiHandler : IActionHandler
|
||||
{
|
||||
public string? Prefix => "emoji";
|
||||
|
||||
@@ -20,490 +20,6 @@ public class EmojiHandler : IActionHandler
|
||||
"1.0",
|
||||
"AX");
|
||||
|
||||
// ─── 이모지 데이터베이스 (이모지, 이름(한/영), 태그) ──────────────────────
|
||||
private static readonly (string Emoji, string Name, string Tags)[] _emojis =
|
||||
{
|
||||
// 표정 / 감정
|
||||
("😀", "크게 웃는 얼굴", "smile happy grin 웃음 행복"),
|
||||
("😃", "웃는 얼굴", "smile happy joy 웃음"),
|
||||
("😄", "눈 웃음", "smile laugh 웃음 기쁨"),
|
||||
("😁", "히죽 웃음", "grin beam 씩 웃다"),
|
||||
("😆", "크게 웃음", "laughing 폭소"),
|
||||
("😅", "식은땀 웃음", "sweat smile 안도"),
|
||||
("🤣", "바닥 구르며 웃음", "rofl lol 빵 웃음"),
|
||||
("😂", "눈물 나게 웃음", "joy tears laugh 폭소"),
|
||||
("🙂", "살짝 웃음", "slightly smiling 미소"),
|
||||
("🙃", "거꾸로 웃음", "upside down 뒤집힌"),
|
||||
("😉", "윙크", "wink 윙크"),
|
||||
("😊", "볼 빨개진 웃음", "blush 부끄러움 미소"),
|
||||
("😇", "천사", "angel halo 천사 선량"),
|
||||
("🥰", "사랑스러운 얼굴", "love hearts 사랑 하트"),
|
||||
("😍", "하트 눈", "heart eyes 사랑 반함"),
|
||||
("🤩", "별 눈", "star struck 감동 황홀"),
|
||||
("😘", "뽀뽀", "kiss blow 키스 뽀뽀"),
|
||||
("😗", "오므린 입", "kiss whistle 키스"),
|
||||
("😚", "눈 감고 뽀뽀", "kiss 키스"),
|
||||
("😙", "볼 뽀뽀", "kiss 키스"),
|
||||
("😋", "맛있다", "yum delicious 맛 음식"),
|
||||
("😛", "혀 내밀기", "tongue out 혀 놀림"),
|
||||
("😜", "윙크하며 혀", "wink tongue 장난"),
|
||||
("🤪", "미친 표정", "zany crazy 정신없음"),
|
||||
("😝", "눈 감고 혀", "tongue 혀"),
|
||||
("🤑", "돈 눈", "money face 돈 부자"),
|
||||
("🤗", "포옹", "hugging hug 안아줘 포옹"),
|
||||
("🤭", "입 가리고", "hand over mouth 헉 깜짝"),
|
||||
("🤫", "쉿", "shushing quiet 조용 쉿"),
|
||||
("🤔", "생각 중", "thinking 고민 생각"),
|
||||
("🤐", "입 막음", "zipper mouth 비밀"),
|
||||
("🤨", "의심", "raised eyebrow 의심 의아"),
|
||||
("😐", "무표정", "neutral 무감각 무표정"),
|
||||
("😑", "표정 없음", "expressionless 냉담"),
|
||||
("😶", "입 없는 얼굴", "no mouth 침묵"),
|
||||
("😏", "비웃음", "smirk 비웃 냉소"),
|
||||
("😒", "불만", "unamused 불만 짜증"),
|
||||
("🙄", "눈 굴리기", "eye roll 어이없음"),
|
||||
("😬", "이 드러냄", "grimace 으 민망"),
|
||||
("🤥", "거짓말", "lying pinocchio 거짓말"),
|
||||
("😌", "안도/평온", "relieved 안도 평온"),
|
||||
("😔", "슬픔", "pensive sad 슬픔 우울"),
|
||||
("😪", "졸림", "sleepy 졸음"),
|
||||
("🤤", "침 흘림", "drooling 군침 식욕"),
|
||||
("😴", "잠", "sleeping sleep 수면 잠"),
|
||||
("😷", "마스크", "mask sick 마스크 아픔"),
|
||||
("🤒", "열 나는", "sick fever 열 아픔"),
|
||||
("🤕", "머리 붕대", "injured hurt 부상"),
|
||||
("🤢", "구역질", "nauseated sick 구역 메스꺼움"),
|
||||
("🤮", "토하는", "vomit 구토"),
|
||||
("🤧", "재채기", "sneezing sick 재채기 감기"),
|
||||
("🥵", "더운", "hot overheated 더움 열"),
|
||||
("🥶", "추운", "cold freezing 추움 냉기"),
|
||||
("🥴", "어지러운", "woozy 어지럼 취함"),
|
||||
("😵", "어질어질", "dizzy 어지럼 충격"),
|
||||
("🤯", "머리 폭발", "exploding head 충격 대박"),
|
||||
("🤠", "카우보이", "cowboy hat 카우보이"),
|
||||
("🥸", "변장", "disguise 변장 선글라스"),
|
||||
("😎", "쿨한", "cool sunglasses 선글라스 쿨"),
|
||||
("🤓", "공부벌레", "nerd glasses 공부 안경"),
|
||||
("🧐", "모노클", "monocle curious 고상 탐정"),
|
||||
("😕", "당황", "confused 당황 모호"),
|
||||
("😟", "걱정", "worried concern 걱정"),
|
||||
("🙁", "살짝 찡그림", "frown 슬픔"),
|
||||
("☹️", "찡그린 얼굴", "frown sad 슬픔"),
|
||||
("😮", "입 벌림", "open mouth surprised 놀람"),
|
||||
("😯", "놀람", "hushed surprised 깜짝"),
|
||||
("😲", "충격", "astonished 충격 놀람"),
|
||||
("😳", "얼굴 빨개짐", "flushed embarrassed 부끄럼 당황"),
|
||||
("🥺", "애원", "pleading eyes 부탁 눈빛"),
|
||||
("😦", "찡그리며 벌린 입", "frowning 불안"),
|
||||
("😧", "고통", "anguished 고통"),
|
||||
("😨", "무서움", "fearful scared 무서움 공포"),
|
||||
("😰", "식은땀", "anxious sweat 불안 걱정"),
|
||||
("😥", "눈물 조금", "sad disappointed 실망 눈물"),
|
||||
("😢", "울음", "cry sad 슬픔 눈물"),
|
||||
("😭", "엉엉 울음", "loudly crying sob 통곡"),
|
||||
("😱", "공포에 질림", "screaming fear 비명 공포"),
|
||||
("😖", "혼란", "confounded 혼란"),
|
||||
("😣", "힘듦", "persevering 고생"),
|
||||
("😞", "실망", "disappointed 실망"),
|
||||
("😓", "땀", "downcast sweat 땀 힘듦"),
|
||||
("😩", "피곤", "weary tired 지침 피곤"),
|
||||
("😫", "극도로 지침", "tired exhausted 탈진"),
|
||||
("🥱", "하품", "yawning bored 하품 지루함"),
|
||||
("😤", "콧김", "triumph snort 분노 콧김"),
|
||||
("😡", "화남", "angry mad 화남 분노"),
|
||||
("😠", "성남", "angry 화 성남"),
|
||||
("🤬", "욕", "cursing swearing 욕 분노"),
|
||||
("😈", "나쁜 미소", "smiling devil 악마 장난"),
|
||||
("👿", "화난 악마", "angry devil 악마"),
|
||||
("💀", "해골", "skull death 해골 죽음"),
|
||||
("☠️", "해골 십자", "skull crossbones 독"),
|
||||
("💩", "응가", "poop 똥 응가"),
|
||||
("🤡", "피에로", "clown 광대"),
|
||||
("👹", "도깨비", "ogre 도깨비 귀신"),
|
||||
("👺", "텐구", "goblin 텐구"),
|
||||
("👻", "유령", "ghost 유령 귀신"),
|
||||
("👾", "우주인", "alien monster 외계인 게임"),
|
||||
("🤖", "로봇", "robot 로봇"),
|
||||
|
||||
// 손 / 몸
|
||||
("👋", "손 흔들기", "wave waving hi bye 안녕"),
|
||||
("🤚", "손 뒤", "raised back hand 손"),
|
||||
("🖐️", "손바닥", "hand palm 다섯 손가락"),
|
||||
("✋", "손 들기", "raised hand 손 들기 멈춤"),
|
||||
("🖖", "스팍 손인사", "vulcan salute 스타트렉"),
|
||||
("👌", "오케이", "ok perfect 오케이 좋아"),
|
||||
("🤌", "손가락 모아", "pinched fingers 이탈리아"),
|
||||
("✌️", "브이", "victory peace v 브이 평화"),
|
||||
("🤞", "행운 손가락", "crossed fingers lucky 행운 기도"),
|
||||
("🤟", "아이 러브 유", "love you 사랑해"),
|
||||
("🤘", "록 손", "rock on metal 록"),
|
||||
("🤙", "전화해", "call me shaka 전화 샤카"),
|
||||
("👈", "왼쪽 가리킴", "backhand left 왼쪽"),
|
||||
("👉", "오른쪽 가리킴", "backhand right 오른쪽"),
|
||||
("👆", "위 가리킴", "backhand up 위"),
|
||||
("🖕", "욕", "middle finger 욕"),
|
||||
("👇", "아래 가리킴", "backhand down 아래"),
|
||||
("☝️", "검지 들기", "index pointing up 하나 포인트"),
|
||||
("👍", "좋아요", "thumbs up like good 좋아 최고"),
|
||||
("👎", "싫어요", "thumbs down dislike 싫어 별로"),
|
||||
("✊", "주먹", "fist punch 주먹"),
|
||||
("👊", "주먹 치기", "punch fist 주먹"),
|
||||
("🤛", "왼 주먹", "left fist 주먹"),
|
||||
("🤜", "오른 주먹", "right fist 주먹"),
|
||||
("👏", "박수", "clapping applause 박수 응원"),
|
||||
("🙌", "만세", "raising hands celebrate 만세"),
|
||||
("👐", "양손 펼침", "open hands 환영"),
|
||||
("🤲", "두 손 모음", "palms up together 기도 바람"),
|
||||
("🙏", "두 손 합장", "pray please thanks 감사 부탁 기도"),
|
||||
("✍️", "글쓰기", "writing pen 글쓰기"),
|
||||
("💅", "네일", "nail polish manicure 네일 손톱"),
|
||||
("🤳", "셀카", "selfie 셀카"),
|
||||
("💪", "근육", "muscle strong 근육 힘"),
|
||||
("🦾", "기계 팔", "mechanical arm 로봇 팔"),
|
||||
("🦿", "기계 다리", "mechanical leg 로봇 다리"),
|
||||
("🦵", "다리", "leg kick 다리"),
|
||||
("🦶", "발", "foot kick 발"),
|
||||
("👂", "귀", "ear hear 귀"),
|
||||
("🦻", "보청기 귀", "ear hearing aid 보청기"),
|
||||
("👃", "코", "nose smell 코"),
|
||||
("🫀", "심장", "heart anatomical 심장"),
|
||||
("🫁", "폐", "lungs 폐"),
|
||||
("🧠", "뇌", "brain mind 뇌 지능"),
|
||||
("🦷", "치아", "tooth dental 치아"),
|
||||
("🦴", "뼈", "bone 뼈"),
|
||||
("👀", "눈", "eyes look see 눈 보기"),
|
||||
("👁️", "한쪽 눈", "eye 눈"),
|
||||
("👅", "혀", "tongue 혀"),
|
||||
("👄", "입술", "lips mouth 입술"),
|
||||
("💋", "입맞춤", "kiss lips 키스 입술"),
|
||||
("🩸", "피", "blood drop 피 혈액"),
|
||||
|
||||
// 하트 / 감정 기호
|
||||
("❤️", "빨간 하트", "red heart love 사랑 빨강"),
|
||||
("🧡", "주황 하트", "orange heart 사랑"),
|
||||
("💛", "노란 하트", "yellow heart 사랑"),
|
||||
("💚", "초록 하트", "green heart 사랑"),
|
||||
("💙", "파란 하트", "blue heart 사랑"),
|
||||
("💜", "보라 하트", "purple heart 사랑"),
|
||||
("🖤", "검은 하트", "black heart 사랑 다크"),
|
||||
("🤍", "흰 하트", "white heart 사랑"),
|
||||
("🤎", "갈색 하트", "brown heart 사랑"),
|
||||
("💔", "깨진 하트", "broken heart 이별 상처"),
|
||||
("❣️", "느낌표 하트", "heart exclamation 사랑"),
|
||||
("💕", "두 하트", "two hearts 사랑"),
|
||||
("💞", "회전 하트", "revolving hearts 사랑"),
|
||||
("💓", "뛰는 하트", "beating heart 설렘"),
|
||||
("💗", "성장 하트", "growing heart 사랑"),
|
||||
("💖", "반짝 하트", "sparkling heart 사랑"),
|
||||
("💘", "화살 하트", "heart arrow 큐피드"),
|
||||
("💝", "리본 하트", "heart ribbon 선물 사랑"),
|
||||
("💟", "하트 장식", "heart decoration 사랑"),
|
||||
("☮️", "평화", "peace 평화"),
|
||||
("✝️", "십자가", "cross 기독교"),
|
||||
("☯️", "음양", "yin yang 음양 균형"),
|
||||
("🔮", "수정구", "crystal ball magic 마법 점"),
|
||||
("✨", "반짝임", "sparkles glitter 빛 반짝"),
|
||||
("⭐", "별", "star 별"),
|
||||
("🌟", "빛나는 별", "glowing star 별빛"),
|
||||
("💫", "현기증", "dizzy star 빙글"),
|
||||
("⚡", "번개", "lightning bolt 번개 전기"),
|
||||
("🔥", "불", "fire hot 불 열정"),
|
||||
("💥", "폭발", "explosion boom 폭발"),
|
||||
("❄️", "눈송이", "snowflake cold 눈 추위"),
|
||||
("🌈", "무지개", "rainbow 무지개"),
|
||||
("☀️", "태양", "sun sunny 태양 맑음"),
|
||||
("🌙", "달", "moon crescent 달"),
|
||||
("🌊", "파도", "wave ocean 파도 바다"),
|
||||
("💨", "바람", "wind dash 바람"),
|
||||
("💦", "물방울", "sweat droplets water 물"),
|
||||
("🌸", "벚꽃", "cherry blossom 벚꽃 봄"),
|
||||
("🌹", "장미", "rose 장미 꽃"),
|
||||
("🌺", "히비스커스", "hibiscus 꽃"),
|
||||
("🌻", "해바라기", "sunflower 해바라기"),
|
||||
("🌼", "꽃", "blossom flower 꽃"),
|
||||
("🌷", "튤립", "tulip 튤립"),
|
||||
("💐", "꽃다발", "bouquet flowers 꽃다발"),
|
||||
("🍀", "네잎클로버", "four leaf clover lucky 행운"),
|
||||
("🌿", "허브", "herb green 풀 허브"),
|
||||
("🍃", "잎사귀", "leaf 잎"),
|
||||
|
||||
// 음식
|
||||
("🍕", "피자", "pizza 피자"),
|
||||
("🍔", "햄버거", "hamburger burger 버거"),
|
||||
("🌮", "타코", "taco 타코"),
|
||||
("🍜", "라면", "ramen noodles 라면 국수"),
|
||||
("🍱", "도시락", "bento box 도시락"),
|
||||
("🍣", "초밥", "sushi 초밥"),
|
||||
("🍚", "밥", "rice 밥"),
|
||||
("🍛", "카레", "curry rice 카레"),
|
||||
("🍝", "파스타", "pasta spaghetti 파스타"),
|
||||
("🍦", "소프트 아이스크림", "ice cream soft serve 아이스크림"),
|
||||
("🎂", "생일 케이크", "cake birthday 생일 케이크"),
|
||||
("🍰", "케이크 조각", "cake slice 케이크"),
|
||||
("🧁", "컵케이크", "cupcake 컵케이크"),
|
||||
("🍩", "도넛", "donut 도넛"),
|
||||
("🍪", "쿠키", "cookie 쿠키"),
|
||||
("🍫", "초콜릿", "chocolate bar 초콜릿"),
|
||||
("🍬", "사탕", "candy 사탕"),
|
||||
("🍭", "막대 사탕", "lollipop 막대사탕"),
|
||||
("🍺", "맥주", "beer mug 맥주"),
|
||||
("🍻", "건배", "clinking beer 건배"),
|
||||
("🥂", "샴페인 건배", "champagne 샴페인 건배"),
|
||||
("🍷", "와인", "wine 와인"),
|
||||
("☕", "커피", "coffee hot 커피"),
|
||||
("🧃", "주스", "juice 주스"),
|
||||
("🥤", "음료", "drink cup 음료 컵"),
|
||||
("🧋", "버블티", "bubble tea boba 버블티"),
|
||||
("🍵", "녹차", "tea matcha 차 녹차"),
|
||||
|
||||
// 동물
|
||||
("🐶", "강아지", "dog puppy 강아지 개"),
|
||||
("🐱", "고양이", "cat kitten 고양이"),
|
||||
("🐭", "쥐", "mouse 쥐"),
|
||||
("🐹", "햄스터", "hamster 햄스터"),
|
||||
("🐰", "토끼", "rabbit bunny 토끼"),
|
||||
("🦊", "여우", "fox 여우"),
|
||||
("🐻", "곰", "bear 곰"),
|
||||
("🐼", "판다", "panda 판다"),
|
||||
("🐨", "코알라", "koala 코알라"),
|
||||
("🐯", "호랑이", "tiger 호랑이"),
|
||||
("🦁", "사자", "lion 사자"),
|
||||
("🐮", "소", "cow 소"),
|
||||
("🐷", "돼지", "pig 돼지"),
|
||||
("🐸", "개구리", "frog 개구리"),
|
||||
("🐵", "원숭이", "monkey 원숭이"),
|
||||
("🙈", "눈 가린 원숭이", "see no evil monkey 안 봐"),
|
||||
("🙉", "귀 가린 원숭이", "hear no evil monkey 안 들어"),
|
||||
("🙊", "입 가린 원숭이", "speak no evil monkey 안 말해"),
|
||||
("🐔", "닭", "chicken 닭"),
|
||||
("🐧", "펭귄", "penguin 펭귄"),
|
||||
("🐦", "새", "bird 새"),
|
||||
("🦆", "오리", "duck 오리"),
|
||||
("🦅", "독수리", "eagle 독수리"),
|
||||
("🦉", "부엉이", "owl 부엉이"),
|
||||
("🐍", "뱀", "snake 뱀"),
|
||||
("🐢", "거북이", "turtle 거북이"),
|
||||
("🦋", "나비", "butterfly 나비"),
|
||||
("🐌", "달팽이", "snail 달팽이"),
|
||||
("🐛", "애벌레", "bug caterpillar 애벌레"),
|
||||
("🐝", "꿀벌", "bee honeybee 벌"),
|
||||
("🦑", "오징어", "squid 오징어"),
|
||||
("🐙", "문어", "octopus 문어"),
|
||||
("🐠", "열대어", "tropical fish 열대어"),
|
||||
("🐡", "복어", "blowfish puffer 복어"),
|
||||
("🦈", "상어", "shark 상어"),
|
||||
("🐬", "돌고래", "dolphin 돌고래"),
|
||||
("🐳", "고래", "whale 고래"),
|
||||
("🐲", "용", "dragon 용"),
|
||||
("🦄", "유니콘", "unicorn 유니콘"),
|
||||
|
||||
// 물건 / 도구
|
||||
("📱", "스마트폰", "phone mobile smartphone 폰"),
|
||||
("💻", "노트북", "laptop computer 노트북"),
|
||||
("🖥️", "데스크톱", "desktop computer 컴퓨터"),
|
||||
("⌨️", "키보드", "keyboard 키보드"),
|
||||
("🖱️", "마우스", "mouse 마우스"),
|
||||
("🖨️", "프린터", "printer 프린터"),
|
||||
("📷", "카메라", "camera 카메라"),
|
||||
("📸", "플래시 카메라", "camera flash 사진"),
|
||||
("📹", "비디오 카메라", "video camera 동영상"),
|
||||
("🎥", "영화 카메라", "movie camera film 영화"),
|
||||
("📺", "TV", "television tv 텔레비전"),
|
||||
("📻", "라디오", "radio 라디오"),
|
||||
("🎙️", "마이크", "microphone studio 마이크"),
|
||||
("🎤", "마이크 핸드헬드", "microphone karaoke 마이크"),
|
||||
("🎧", "헤드폰", "headphones 헤드폰"),
|
||||
("📡", "안테나", "satellite antenna 안테나"),
|
||||
("🔋", "배터리", "battery 배터리"),
|
||||
("🔌", "전원 플러그", "plug electric 플러그"),
|
||||
("💡", "전구", "bulb idea light 전구 아이디어"),
|
||||
("🔦", "손전등", "flashlight torch 손전등"),
|
||||
("🕯️", "양초", "candle 양초"),
|
||||
("📚", "책", "books stack 책"),
|
||||
("📖", "열린 책", "open book read 독서"),
|
||||
("📝", "메모", "memo note pencil 메모 노트"),
|
||||
("✏️", "연필", "pencil 연필"),
|
||||
("🖊️", "펜", "pen 펜"),
|
||||
("📌", "압정", "pushpin pin 압정"),
|
||||
("📎", "클립", "paperclip 클립"),
|
||||
("✂️", "가위", "scissors cut 가위"),
|
||||
("🗂️", "파일 폴더", "card index dividers folder 파일"),
|
||||
("📁", "폴더", "folder 폴더"),
|
||||
("📂", "열린 폴더", "open folder 폴더"),
|
||||
("🗃️", "파일 박스", "card file box 서류함"),
|
||||
("🗑️", "휴지통", "wastebasket trash 휴지통"),
|
||||
("🔒", "잠금", "locked lock 잠금"),
|
||||
("🔓", "열림", "unlocked 열림"),
|
||||
("🔑", "열쇠", "key 열쇠"),
|
||||
("🗝️", "구식 열쇠", "old key 열쇠"),
|
||||
("🔨", "망치", "hammer 망치"),
|
||||
("🔧", "렌치", "wrench tool 렌치"),
|
||||
("🔩", "나사", "nut bolt 나사"),
|
||||
("⚙️", "톱니바퀴", "gear settings 설정 톱니"),
|
||||
("🛠️", "도구", "tools hammer wrench 도구"),
|
||||
("💊", "알약", "pill medicine 약 알약"),
|
||||
("💉", "주사기", "syringe injection 주사"),
|
||||
("🩺", "청진기", "stethoscope doctor 청진기"),
|
||||
("🏆", "트로피", "trophy award 트로피 우승"),
|
||||
("🥇", "금메달", "first gold medal 금메달"),
|
||||
("🥈", "은메달", "second silver 은메달"),
|
||||
("🥉", "동메달", "third bronze 동메달"),
|
||||
("🎖️", "훈장", "medal military 훈장"),
|
||||
("🎗️", "리본", "ribbon awareness 리본"),
|
||||
("🎫", "티켓", "ticket admission 티켓"),
|
||||
("🎟️", "입장권", "admission tickets 티켓"),
|
||||
("🎪", "서커스", "circus tent 서커스"),
|
||||
("🎨", "팔레트", "art palette paint 그림 예술"),
|
||||
("🎭", "연극", "performing arts theater 연극"),
|
||||
("🎬", "클래퍼보드", "clapper film 영화 촬영"),
|
||||
("🎮", "게임 컨트롤러", "video game controller 게임"),
|
||||
("🎲", "주사위", "dice game 주사위"),
|
||||
("🎯", "다트", "bullseye target dart 다트 목표"),
|
||||
("🎳", "볼링", "bowling 볼링"),
|
||||
("⚽", "축구", "soccer football 축구"),
|
||||
("🏀", "농구", "basketball 농구"),
|
||||
("🏈", "미식축구", "american football 미식축구"),
|
||||
("⚾", "야구", "baseball 야구"),
|
||||
("🎾", "테니스", "tennis 테니스"),
|
||||
("🏐", "배구", "volleyball 배구"),
|
||||
("🏉", "럭비", "rugby 럭비"),
|
||||
("🎱", "당구", "billiards pool 당구"),
|
||||
("🏓", "탁구", "ping pong table tennis 탁구"),
|
||||
("🏸", "배드민턴", "badminton 배드민턴"),
|
||||
("🥊", "권투 장갑", "boxing glove 권투"),
|
||||
("🎣", "낚시", "fishing 낚시"),
|
||||
("🏋️", "역도", "weightlifting gym 헬스 역도"),
|
||||
("🧘", "명상", "yoga meditation 명상 요가"),
|
||||
|
||||
// 이동수단
|
||||
("🚗", "자동차", "car automobile 자동차"),
|
||||
("🚕", "택시", "taxi cab 택시"),
|
||||
("🚙", "SUV", "suv car 차"),
|
||||
("🚌", "버스", "bus 버스"),
|
||||
("🚎", "무궤도 전차", "trolleybus 버스"),
|
||||
("🏎️", "레이싱카", "racing car 레이싱"),
|
||||
("🚓", "경찰차", "police car 경찰"),
|
||||
("🚑", "구급차", "ambulance 구급차"),
|
||||
("🚒", "소방차", "fire truck 소방차"),
|
||||
("🚐", "미니밴", "minibus van 밴"),
|
||||
("🚚", "트럭", "truck delivery 트럭"),
|
||||
("✈️", "비행기", "airplane flight plane 비행기"),
|
||||
("🚀", "로켓", "rocket space launch 로켓"),
|
||||
("🛸", "UFO", "flying saucer ufo 유에프오"),
|
||||
("🚁", "헬리콥터", "helicopter 헬리콥터"),
|
||||
("🚂", "기차", "train locomotive 기차"),
|
||||
("🚆", "고속열차", "train 기차"),
|
||||
("🚇", "지하철", "metro subway 지하철"),
|
||||
("⛵", "돛단배", "sailboat 요트"),
|
||||
("🚢", "배", "ship cruise 배"),
|
||||
("🚲", "자전거", "bicycle bike 자전거"),
|
||||
("🛵", "스쿠터", "scooter moped 스쿠터"),
|
||||
("🏍️", "오토바이", "motorcycle 오토바이"),
|
||||
|
||||
// 장소
|
||||
("🏠", "집", "house home 집"),
|
||||
("🏡", "마당 있는 집", "house garden 집"),
|
||||
("🏢", "빌딩", "office building 빌딩"),
|
||||
("🏣", "우체국", "post office 우체국"),
|
||||
("🏥", "병원", "hospital 병원"),
|
||||
("🏦", "은행", "bank 은행"),
|
||||
("🏨", "호텔", "hotel 호텔"),
|
||||
("🏫", "학교", "school 학교"),
|
||||
("🏪", "편의점", "convenience store shop 편의점"),
|
||||
("🏬", "백화점", "department store 백화점"),
|
||||
("🏰", "성", "castle 성"),
|
||||
("⛪", "교회", "church 교회"),
|
||||
("🕌", "모스크", "mosque 모스크"),
|
||||
("🗼", "에펠탑", "eiffel tower paris 파리"),
|
||||
("🗽", "자유의 여신상", "statue of liberty new york 뉴욕"),
|
||||
("🏔️", "산", "mountain snow 산"),
|
||||
("🌋", "화산", "volcano 화산"),
|
||||
("🗻", "후지산", "mount fuji japan 후지산"),
|
||||
("🏕️", "캠핑", "camping tent 캠핑"),
|
||||
("🏖️", "해변", "beach summer 해변 해수욕"),
|
||||
("🌏", "지구", "earth globe asia 지구"),
|
||||
|
||||
// 기호 / 숫자
|
||||
("💯", "100점", "hundred percent perfect 완벽 100"),
|
||||
("🔢", "숫자", "numbers 숫자"),
|
||||
("🆗", "OK", "ok button 오케이"),
|
||||
("🆙", "업", "up button 업"),
|
||||
("🆒", "쿨", "cool button 쿨"),
|
||||
("🆕", "새것", "new button 새"),
|
||||
("🆓", "무료", "free button 무료"),
|
||||
("🆘", "SOS", "sos emergency 긴급 구조"),
|
||||
("⚠️", "경고", "warning caution 경고 주의"),
|
||||
("🚫", "금지", "prohibited no 금지"),
|
||||
("✅", "체크", "check mark done 완료 확인"),
|
||||
("❌", "엑스", "x cross error 실패 오류"),
|
||||
("❓", "물음표", "question mark 물음표"),
|
||||
("❗", "느낌표", "exclamation mark 느낌표"),
|
||||
("➕", "더하기", "plus add 더하기"),
|
||||
("➖", "빼기", "minus subtract 빼기"),
|
||||
("➗", "나누기", "divide 나누기"),
|
||||
("✖️", "곱하기", "multiply times 곱하기"),
|
||||
("♾️", "무한대", "infinity 무한"),
|
||||
("🔁", "반복", "repeat loop 반복"),
|
||||
("🔀", "셔플", "shuffle random 랜덤"),
|
||||
("▶️", "재생", "play 재생"),
|
||||
("⏸️", "일시정지", "pause 일시정지"),
|
||||
("⏹️", "정지", "stop 정지"),
|
||||
("⏩", "빨리 감기", "fast forward 빨리감기"),
|
||||
("⏪", "되감기", "rewind 되감기"),
|
||||
("🔔", "알림", "bell notification 알림 벨"),
|
||||
("🔕", "알림 끔", "bell off 알림끔"),
|
||||
("🔊", "볼륨 크게", "loud speaker volume up 볼륨"),
|
||||
("🔇", "음소거", "muted speaker 음소거"),
|
||||
("📣", "메가폰", "megaphone loud 확성기"),
|
||||
("📢", "스피커", "loudspeaker 스피커"),
|
||||
("💬", "말풍선", "speech bubble chat 대화"),
|
||||
("💭", "생각 말풍선", "thought bubble thinking 생각"),
|
||||
("📧", "이메일", "email mail 이메일 메일"),
|
||||
("📨", "수신 봉투", "incoming envelope 수신"),
|
||||
("📩", "발신 봉투", "envelope outbox 발신"),
|
||||
("📬", "우편함", "mailbox 우편함"),
|
||||
("📦", "택배 박스", "package box parcel 택배 상자"),
|
||||
("🎁", "선물", "gift present 선물"),
|
||||
("🎀", "리본 묶음", "ribbon bow 리본"),
|
||||
("🎊", "색종이", "confetti 파티 축하"),
|
||||
("🎉", "파티 폭죽", "party popper celebrate 파티 축하"),
|
||||
("🎈", "풍선", "balloon party 풍선"),
|
||||
("🕐", "1시", "one o'clock 1시 시간"),
|
||||
("🕒", "3시", "three o'clock 3시 시간"),
|
||||
("🕔", "4시", "four o'clock 4시 시간"),
|
||||
("⏰", "알람 시계", "alarm clock 알람 시계"),
|
||||
("⏱️", "스톱워치", "stopwatch timer 스톱워치 타이머"),
|
||||
("📅", "달력", "calendar date 달력 날짜"),
|
||||
("📆", "찢는 달력", "tear-off calendar 달력"),
|
||||
("💰", "돈 가방", "money bag 돈 부자"),
|
||||
("💳", "신용카드", "credit card payment 카드 결제"),
|
||||
("💵", "달러", "dollar banknote 달러"),
|
||||
("💴", "엔화", "yen banknote 엔"),
|
||||
("💶", "유로", "euro banknote 유로"),
|
||||
("💷", "파운드", "pound banknote 파운드"),
|
||||
("📊", "막대 그래프", "bar chart graph 그래프"),
|
||||
("📈", "상승 그래프", "chart increasing trend 상승 트렌드"),
|
||||
("📉", "하락 그래프", "chart decreasing trend 하락"),
|
||||
("🔍", "돋보기", "magnifying glass search 검색 돋보기"),
|
||||
("🔎", "오른쪽 돋보기", "magnifying glass right search 검색"),
|
||||
("🏳️", "흰 깃발", "white flag 항복"),
|
||||
("🏴", "검은 깃발", "black flag 해적"),
|
||||
("🚩", "빨간 삼각기", "triangular flag 경고 깃발"),
|
||||
("🏁", "체크무늬 깃발", "chequered flag finish race 결승"),
|
||||
("🌐", "지구본", "globe internet web 인터넷 웹"),
|
||||
("⚓", "닻", "anchor 닻"),
|
||||
("🎵", "음표", "music note 음악 음표"),
|
||||
("🎶", "음표들", "musical notes 음악"),
|
||||
("🎼", "악보", "musical score 악보"),
|
||||
("🎹", "피아노", "piano keyboard 피아노"),
|
||||
("🎸", "기타", "guitar 기타"),
|
||||
("🥁", "드럼", "drum 드럼"),
|
||||
("🪗", "아코디언", "accordion 아코디언"),
|
||||
("🎷", "색소폰", "saxophone 색소폰"),
|
||||
("🎺", "트럼펫", "trumpet 트럼펫"),
|
||||
("🎻", "바이올린", "violin 바이올린"),
|
||||
};
|
||||
|
||||
public Task<IEnumerable<LauncherItem>> GetItemsAsync(string query, CancellationToken ct)
|
||||
{
|
||||
IEnumerable<(string Emoji, string Name, string Tags)> matches;
|
||||
|
||||
193
src/AxCopilot/Handlers/MathEvaluator.cs
Normal file
193
src/AxCopilot/Handlers/MathEvaluator.cs
Normal file
@@ -0,0 +1,193 @@
|
||||
using AxCopilot.SDK;
|
||||
using AxCopilot.Services;
|
||||
using AxCopilot.Themes;
|
||||
using System.Net.Http;
|
||||
using System.Text.Json;
|
||||
using System.Text.RegularExpressions;
|
||||
using System.Windows;
|
||||
|
||||
namespace AxCopilot.Handlers;
|
||||
|
||||
// ─── 수식 파서 ─────────────────────────────────────────────────────────────────
|
||||
|
||||
/// <summary>
|
||||
/// 재귀 하강 파서 기반 수학 수식 평가기.
|
||||
/// 지원: +, -, *, /, %, ^ (거듭제곱), 괄호, 단항 음수,
|
||||
/// sqrt, abs, ceil, floor, round, sin, cos, tan (도 단위),
|
||||
/// log (밑 10), ln (자연로그), pi, e
|
||||
/// </summary>
|
||||
internal static class MathEvaluator
|
||||
{
|
||||
public static double Evaluate(string expr)
|
||||
{
|
||||
var evaluator = new Evaluator(
|
||||
expr.Replace(" ", "")
|
||||
.Replace("×", "*")
|
||||
.Replace("÷", "/")
|
||||
.Replace(",", ",")
|
||||
.ToLowerInvariant());
|
||||
return evaluator.Parse();
|
||||
}
|
||||
|
||||
private class Evaluator
|
||||
{
|
||||
private readonly string _s;
|
||||
private int _i;
|
||||
|
||||
public Evaluator(string s) { _s = s; _i = 0; }
|
||||
|
||||
public double Parse()
|
||||
{
|
||||
var result = ParseExpr();
|
||||
if (_i < _s.Length)
|
||||
throw new InvalidOperationException($"예기치 않은 문자: '{_s[_i]}'");
|
||||
return result;
|
||||
}
|
||||
|
||||
// 덧셈 / 뺄셈
|
||||
private double ParseExpr()
|
||||
{
|
||||
var left = ParseTerm();
|
||||
while (_i < _s.Length && (_s[_i] == '+' || _s[_i] == '-'))
|
||||
{
|
||||
var op = _s[_i++];
|
||||
var right = ParseTerm();
|
||||
left = op == '+' ? left + right : left - right;
|
||||
}
|
||||
return left;
|
||||
}
|
||||
|
||||
// 곱셈 / 나눗셈 / 나머지
|
||||
private double ParseTerm()
|
||||
{
|
||||
var left = ParsePower();
|
||||
while (_i < _s.Length && (_s[_i] == '*' || _s[_i] == '/' || _s[_i] == '%'))
|
||||
{
|
||||
var op = _s[_i++];
|
||||
var right = ParsePower();
|
||||
left = op == '*' ? left * right
|
||||
: op == '/' ? left / right
|
||||
: left % right;
|
||||
}
|
||||
return left;
|
||||
}
|
||||
|
||||
// 거듭제곱 (오른쪽 결합)
|
||||
private double ParsePower()
|
||||
{
|
||||
var b = ParseUnary();
|
||||
if (_i < _s.Length && _s[_i] == '^')
|
||||
{
|
||||
_i++;
|
||||
var exp = ParseUnary();
|
||||
return Math.Pow(b, exp);
|
||||
}
|
||||
return b;
|
||||
}
|
||||
|
||||
// 단항 부호
|
||||
private double ParseUnary()
|
||||
{
|
||||
if (_i < _s.Length && _s[_i] == '-') { _i++; return -ParsePrimary(); }
|
||||
if (_i < _s.Length && _s[_i] == '+') { _i++; return ParsePrimary(); }
|
||||
return ParsePrimary();
|
||||
}
|
||||
|
||||
// 리터럴 / 괄호 / 함수 호출
|
||||
private double ParsePrimary()
|
||||
{
|
||||
if (_i >= _s.Length)
|
||||
throw new InvalidOperationException("수식이 불완전합니다.");
|
||||
|
||||
// 16진수 리터럴 0x...
|
||||
if (_i + 1 < _s.Length && _s[_i] == '0' && _s[_i + 1] == 'x')
|
||||
{
|
||||
_i += 2;
|
||||
var hexStart = _i;
|
||||
while (_i < _s.Length && "0123456789abcdef".Contains(_s[_i])) _i++;
|
||||
return Convert.ToInt64(_s[hexStart.._i], 16);
|
||||
}
|
||||
|
||||
// 숫자
|
||||
if (char.IsDigit(_s[_i]) || _s[_i] == '.')
|
||||
{
|
||||
var start = _i;
|
||||
while (_i < _s.Length && (char.IsDigit(_s[_i]) || _s[_i] == '.')) _i++;
|
||||
// 과학적 표기: 1.5e3
|
||||
if (_i < _s.Length && _s[_i] == 'e')
|
||||
{
|
||||
_i++;
|
||||
if (_i < _s.Length && (_s[_i] == '+' || _s[_i] == '-')) _i++;
|
||||
while (_i < _s.Length && char.IsDigit(_s[_i])) _i++;
|
||||
}
|
||||
return double.Parse(_s[start.._i],
|
||||
System.Globalization.NumberStyles.Float,
|
||||
System.Globalization.CultureInfo.InvariantCulture);
|
||||
}
|
||||
|
||||
// 괄호
|
||||
if (_s[_i] == '(')
|
||||
{
|
||||
_i++;
|
||||
var val = ParseExpr();
|
||||
if (_i < _s.Length && _s[_i] == ')') _i++;
|
||||
return val;
|
||||
}
|
||||
|
||||
// 식별자 (상수 또는 함수)
|
||||
if (char.IsLetter(_s[_i]))
|
||||
{
|
||||
var start = _i;
|
||||
while (_i < _s.Length && (char.IsLetterOrDigit(_s[_i]) || _s[_i] == '_')) _i++;
|
||||
var name = _s[start.._i];
|
||||
|
||||
// 상수
|
||||
if (name == "pi") return Math.PI;
|
||||
if (name == "e") return Math.E;
|
||||
if (name == "inf") return double.PositiveInfinity;
|
||||
|
||||
// 함수 호출
|
||||
if (_i < _s.Length && _s[_i] == '(')
|
||||
{
|
||||
_i++; // (
|
||||
var arg = ParseExpr();
|
||||
// 두 번째 인자 (pow, log2 등)
|
||||
double? arg2 = null;
|
||||
if (_i < _s.Length && _s[_i] == ',')
|
||||
{
|
||||
_i++;
|
||||
arg2 = ParseExpr();
|
||||
}
|
||||
if (_i < _s.Length && _s[_i] == ')') _i++;
|
||||
|
||||
return name switch
|
||||
{
|
||||
"sqrt" => Math.Sqrt(arg),
|
||||
"abs" => Math.Abs(arg),
|
||||
"ceil" => Math.Ceiling(arg),
|
||||
"floor" => Math.Floor(arg),
|
||||
"round" => arg2.HasValue ? Math.Round(arg, (int)arg2.Value) : Math.Round(arg),
|
||||
"sin" => Math.Sin(arg * Math.PI / 180), // 도 단위
|
||||
"cos" => Math.Cos(arg * Math.PI / 180),
|
||||
"tan" => Math.Tan(arg * Math.PI / 180),
|
||||
"asin" => Math.Asin(arg) * 180 / Math.PI,
|
||||
"acos" => Math.Acos(arg) * 180 / Math.PI,
|
||||
"atan" => Math.Atan(arg) * 180 / Math.PI,
|
||||
"log" => arg2.HasValue ? Math.Log(arg, arg2.Value) : Math.Log10(arg),
|
||||
"log2" => Math.Log2(arg),
|
||||
"ln" => Math.Log(arg),
|
||||
"exp" => Math.Exp(arg),
|
||||
"pow" => arg2.HasValue ? Math.Pow(arg, arg2.Value) : throw new InvalidOperationException("pow(x,y) 형식으로 사용하세요."),
|
||||
"min" => arg2.HasValue ? Math.Min(arg, arg2.Value) : arg,
|
||||
"max" => arg2.HasValue ? Math.Max(arg, arg2.Value) : arg,
|
||||
_ => throw new InvalidOperationException($"알 수 없는 함수: {name}()")
|
||||
};
|
||||
}
|
||||
|
||||
throw new InvalidOperationException($"알 수 없는 식별자: {name}");
|
||||
}
|
||||
|
||||
throw new InvalidOperationException($"예기치 않은 문자: '{_s[_i]}'");
|
||||
}
|
||||
}
|
||||
}
|
||||
154
src/AxCopilot/Handlers/UnitConverter.cs
Normal file
154
src/AxCopilot/Handlers/UnitConverter.cs
Normal file
@@ -0,0 +1,154 @@
|
||||
using AxCopilot.SDK;
|
||||
using AxCopilot.Services;
|
||||
using AxCopilot.Themes;
|
||||
using System.Net.Http;
|
||||
using System.Text.Json;
|
||||
using System.Text.RegularExpressions;
|
||||
using System.Windows;
|
||||
|
||||
namespace AxCopilot.Handlers;
|
||||
|
||||
// ─── 단위 변환 ─────────────────────────────────────────────────────────────────
|
||||
|
||||
/// <summary>
|
||||
/// "100km in miles", "32f in c", "5lb to kg" 형식의 단위 변환.
|
||||
/// </summary>
|
||||
internal static class UnitConverter
|
||||
{
|
||||
// 패턴: <숫자> <단위> in|to <단위>
|
||||
private static readonly Regex Pattern = new(
|
||||
@"^(-?\d+(?:\.\d+)?)\s*([a-z°/²³µ]+)\s+(?:in|to)\s+([a-z°/²³µ]+)$",
|
||||
RegexOptions.IgnoreCase | RegexOptions.Compiled);
|
||||
|
||||
public static bool TryConvert(string input, out string? result)
|
||||
{
|
||||
result = null;
|
||||
var m = Pattern.Match(input.Trim());
|
||||
if (!m.Success) return false;
|
||||
|
||||
if (!double.TryParse(m.Groups[1].Value,
|
||||
System.Globalization.NumberStyles.Float,
|
||||
System.Globalization.CultureInfo.InvariantCulture,
|
||||
out var value))
|
||||
return false;
|
||||
|
||||
var from = m.Groups[2].Value.ToLowerInvariant();
|
||||
var to = m.Groups[3].Value.ToLowerInvariant();
|
||||
|
||||
// 온도는 비선형 → 별도 처리
|
||||
if (TryConvertTemperature(value, from, to, out var tResult))
|
||||
{
|
||||
result = $"{FormatNum(tResult)} {TemperatureLabel(to)}";
|
||||
return true;
|
||||
}
|
||||
|
||||
// 나머지 범주(선형 변환)
|
||||
foreach (var table in _tables)
|
||||
{
|
||||
if (table.TryGetValue(from, out var fromFactor) &&
|
||||
table.TryGetValue(to, out var toFactor))
|
||||
{
|
||||
var converted = value * fromFactor / toFactor;
|
||||
result = $"{FormatNum(converted)} {to}";
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
// ─── 온도 ────────────────────────────────────────────────────────────────
|
||||
|
||||
private static bool TryConvertTemperature(double v, string from, string to, out double r)
|
||||
{
|
||||
r = 0;
|
||||
// 섭씨 표준화
|
||||
double celsius;
|
||||
switch (from)
|
||||
{
|
||||
case "c": case "°c": case "celsius": celsius = v; break;
|
||||
case "f": case "°f": case "fahrenheit": celsius = (v - 32) * 5 / 9; break;
|
||||
case "k": case "kelvin": celsius = v - 273.15; break;
|
||||
default: return false;
|
||||
}
|
||||
switch (to)
|
||||
{
|
||||
case "c": case "°c": case "celsius": r = celsius; break;
|
||||
case "f": case "°f": case "fahrenheit": r = celsius * 9 / 5 + 32; break;
|
||||
case "k": case "kelvin": r = celsius + 273.15; break;
|
||||
default: return false;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
private static string TemperatureLabel(string unit) => unit switch
|
||||
{
|
||||
"c" or "°c" or "celsius" => "°C",
|
||||
"f" or "°f" or "fahrenheit" => "°F",
|
||||
"k" or "kelvin" => "K",
|
||||
_ => unit
|
||||
};
|
||||
|
||||
// ─── 선형 변환 테이블 (기준 단위 = 1) ────────────────────────────────────
|
||||
|
||||
// 길이 (기준: m)
|
||||
private static readonly Dictionary<string, double> _length = new()
|
||||
{
|
||||
["km"] = 1000, ["m"] = 1, ["cm"] = 0.01, ["mm"] = 0.001,
|
||||
["mi"] = 1609.344, ["mile"] = 1609.344, ["miles"] = 1609.344,
|
||||
["ft"] = 0.3048, ["feet"] = 0.3048, ["foot"] = 0.3048,
|
||||
["in"] = 0.0254, ["inch"] = 0.0254, ["inches"] = 0.0254,
|
||||
["yd"] = 0.9144, ["yard"] = 0.9144, ["yards"] = 0.9144,
|
||||
["nm"] = 1e-9,
|
||||
};
|
||||
|
||||
// 무게 (기준: kg)
|
||||
private static readonly Dictionary<string, double> _weight = new()
|
||||
{
|
||||
["t"] = 1000, ["ton"] = 1000, ["tonnes"] = 1000,
|
||||
["kg"] = 1, ["g"] = 0.001, ["mg"] = 1e-6,
|
||||
["lb"] = 0.453592, ["lbs"] = 0.453592, ["pound"] = 0.453592, ["pounds"] = 0.453592,
|
||||
["oz"] = 0.0283495, ["ounce"] = 0.0283495, ["ounces"] = 0.0283495,
|
||||
};
|
||||
|
||||
// 속도 (기준: m/s)
|
||||
private static readonly Dictionary<string, double> _speed = new()
|
||||
{
|
||||
["m/s"] = 1, ["mps"] = 1,
|
||||
["km/h"] = 1.0 / 3.6, ["kmh"] = 1.0 / 3.6, ["kph"] = 1.0 / 3.6,
|
||||
["mph"] = 0.44704,
|
||||
["kn"] = 0.514444, ["knot"] = 0.514444, ["knots"] = 0.514444,
|
||||
};
|
||||
|
||||
// 데이터 (기준: byte)
|
||||
private static readonly Dictionary<string, double> _data = new()
|
||||
{
|
||||
["b"] = 1, ["byte"] = 1, ["bytes"] = 1,
|
||||
["kb"] = 1024, ["kib"] = 1024,
|
||||
["mb"] = 1024 * 1024, ["mib"] = 1024 * 1024,
|
||||
["gb"] = 1024.0 * 1024 * 1024, ["gib"] = 1024.0 * 1024 * 1024,
|
||||
["tb"] = 1024.0 * 1024 * 1024 * 1024, ["tib"] = 1024.0 * 1024 * 1024 * 1024,
|
||||
["pb"] = 1024.0 * 1024 * 1024 * 1024 * 1024,
|
||||
};
|
||||
|
||||
// 넓이 (기준: m²)
|
||||
private static readonly Dictionary<string, double> _area = new()
|
||||
{
|
||||
["m²"] = 1, ["m2"] = 1,
|
||||
["km²"] = 1e6, ["km2"] = 1e6,
|
||||
["cm²"] = 1e-4, ["cm2"] = 1e-4,
|
||||
["ha"] = 10000,
|
||||
["acre"] = 4046.86, ["acres"] = 4046.86,
|
||||
["ft²"] = 0.092903, ["ft2"] = 0.092903,
|
||||
};
|
||||
|
||||
private static readonly List<Dictionary<string, double>> _tables = new()
|
||||
{ _length, _weight, _speed, _data, _area };
|
||||
|
||||
private static string FormatNum(double v)
|
||||
{
|
||||
if (v == Math.Floor(v) && Math.Abs(v) < 1e12)
|
||||
return ((long)v).ToString("N0", System.Globalization.CultureInfo.CurrentCulture);
|
||||
return v.ToString("G6", System.Globalization.CultureInfo.InvariantCulture);
|
||||
}
|
||||
}
|
||||
283
src/AxCopilot/Services/Agent/DocumentPlannerTool.Generators.cs
Normal file
283
src/AxCopilot/Services/Agent/DocumentPlannerTool.Generators.cs
Normal file
@@ -0,0 +1,283 @@
|
||||
using System.IO;
|
||||
using System.Text;
|
||||
using System.Text.Json;
|
||||
|
||||
namespace AxCopilot.Services.Agent;
|
||||
|
||||
public partial class DocumentPlannerTool
|
||||
{
|
||||
// ─── 파일 생성: HTML / DOCX / Markdown ──────────────────────────────────
|
||||
|
||||
private static void GenerateHtml(string path, string title, string docType, List<SectionPlan> sections)
|
||||
{
|
||||
var css = TemplateService.GetCss("professional");
|
||||
var sb = new StringBuilder();
|
||||
|
||||
sb.AppendLine("<!DOCTYPE html>");
|
||||
sb.AppendLine("<html lang=\"ko\">");
|
||||
sb.AppendLine("<head>");
|
||||
sb.AppendLine("<meta charset=\"utf-8\">");
|
||||
sb.AppendLine($"<title>{Escape(title)}</title>");
|
||||
sb.AppendLine("<style>");
|
||||
sb.AppendLine(css);
|
||||
sb.AppendLine(@"
|
||||
.doc { max-width: 900px; margin: 0 auto; padding: 40px 30px 60px; }
|
||||
.doc h1 { font-size: 28px; margin-bottom: 8px; border-bottom: 3px solid var(--accent, #4B5EFC); padding-bottom: 10px; }
|
||||
.doc h2 { font-size: 22px; margin-top: 36px; margin-bottom: 12px; border-bottom: 2px solid var(--accent, #4B5EFC); padding-bottom: 6px; }
|
||||
.doc .meta { color: #888; font-size: 13px; margin-bottom: 24px; }
|
||||
.doc .section { line-height: 1.8; margin-bottom: 20px; }
|
||||
.doc .toc { background: #f8f9fa; border-radius: 12px; padding: 20px 28px; margin: 24px 0 32px; }
|
||||
.doc .toc h3 { margin-top: 0; }
|
||||
.doc .toc a { color: inherit; text-decoration: none; }
|
||||
.doc .toc a:hover { text-decoration: underline; }
|
||||
.doc .toc ul { list-style: none; padding-left: 0; }
|
||||
.doc .toc li { padding: 4px 0; }
|
||||
.doc .key-point { background: #f0f4ff; border-left: 4px solid var(--accent, #4B5EFC); padding: 12px 16px; margin: 12px 0; border-radius: 0 8px 8px 0; }
|
||||
@media print { .doc h2 { page-break-before: auto; } }
|
||||
");
|
||||
sb.AppendLine("</style>");
|
||||
sb.AppendLine("</head>");
|
||||
sb.AppendLine("<body>");
|
||||
sb.AppendLine("<div class=\"doc\">");
|
||||
sb.AppendLine($"<h1>{Escape(title)}</h1>");
|
||||
sb.AppendLine($"<div class=\"meta\">문서 유형: {Escape(GetDocTypeLabel(docType))} | 작성일: {DateTime.Now:yyyy-MM-dd} | 섹션: {sections.Count}개</div>");
|
||||
|
||||
// 목차
|
||||
if (sections.Count > 1)
|
||||
{
|
||||
sb.AppendLine("<div class=\"toc\">");
|
||||
sb.AppendLine("<h3>📋 목차</h3>");
|
||||
sb.AppendLine("<ul>");
|
||||
for (int i = 0; i < sections.Count; i++)
|
||||
sb.AppendLine($"<li><a href=\"#sec-{i + 1}\">{Escape(sections[i].Heading)}</a></li>");
|
||||
sb.AppendLine("</ul>");
|
||||
sb.AppendLine("</div>");
|
||||
}
|
||||
|
||||
// 섹션 본문
|
||||
for (int i = 0; i < sections.Count; i++)
|
||||
{
|
||||
var sec = sections[i];
|
||||
sb.AppendLine($"<h2 id=\"sec-{i + 1}\">{Escape(sec.Heading)}</h2>");
|
||||
sb.AppendLine("<div class=\"section\">");
|
||||
foreach (var kp in sec.KeyPoints)
|
||||
{
|
||||
sb.AppendLine($"<div class=\"key-point\">");
|
||||
sb.AppendLine($"<strong>▸ {Escape(kp)}</strong>");
|
||||
sb.AppendLine($"<p>{Escape(kp)}에 대한 상세 내용을 여기에 작성합니다. (목표: 약 {sec.TargetWords / Math.Max(1, sec.KeyPoints.Count)}단어)</p>");
|
||||
sb.AppendLine("</div>");
|
||||
}
|
||||
sb.AppendLine("</div>");
|
||||
}
|
||||
|
||||
sb.AppendLine("</div>");
|
||||
sb.AppendLine("</body>");
|
||||
sb.AppendLine("</html>");
|
||||
|
||||
File.WriteAllText(path, sb.ToString(), Encoding.UTF8);
|
||||
}
|
||||
|
||||
// ─── DOCX 생성 ──────────────────────────────────────────────────────────
|
||||
|
||||
private static void GenerateDocx(string path, string title, List<SectionPlan> sections)
|
||||
{
|
||||
using var doc = DocumentFormat.OpenXml.Packaging.WordprocessingDocument.Create(
|
||||
path, DocumentFormat.OpenXml.WordprocessingDocumentType.Document);
|
||||
|
||||
var mainPart = doc.AddMainDocumentPart();
|
||||
mainPart.Document = new DocumentFormat.OpenXml.Wordprocessing.Document();
|
||||
var body = mainPart.Document.AppendChild(new DocumentFormat.OpenXml.Wordprocessing.Body());
|
||||
|
||||
// 제목
|
||||
AddDocxParagraph(body, title, bold: true, fontSize: "48");
|
||||
AddDocxParagraph(body, $"작성일: {DateTime.Now:yyyy-MM-dd}", fontSize: "20", color: "888888");
|
||||
AddDocxParagraph(body, ""); // 빈 줄
|
||||
|
||||
foreach (var sec in sections)
|
||||
{
|
||||
AddDocxParagraph(body, sec.Heading, bold: true, fontSize: "32", color: "2B579A");
|
||||
foreach (var kp in sec.KeyPoints)
|
||||
{
|
||||
AddDocxParagraph(body, $"▸ {kp}", bold: true, fontSize: "22");
|
||||
AddDocxParagraph(body, $"{kp}에 대한 상세 내용을 여기에 작성합니다.");
|
||||
}
|
||||
AddDocxParagraph(body, ""); // 섹션 간 빈 줄
|
||||
}
|
||||
}
|
||||
|
||||
private static void AddDocxParagraph(DocumentFormat.OpenXml.Wordprocessing.Body body,
|
||||
string text, bool bold = false, string fontSize = "22", string? color = null)
|
||||
{
|
||||
var para = new DocumentFormat.OpenXml.Wordprocessing.Paragraph();
|
||||
var run = new DocumentFormat.OpenXml.Wordprocessing.Run();
|
||||
var props = new DocumentFormat.OpenXml.Wordprocessing.RunProperties
|
||||
{
|
||||
FontSize = new DocumentFormat.OpenXml.Wordprocessing.FontSize { Val = fontSize }
|
||||
};
|
||||
if (bold) props.Bold = new DocumentFormat.OpenXml.Wordprocessing.Bold();
|
||||
if (color != null) props.Color = new DocumentFormat.OpenXml.Wordprocessing.Color { Val = color };
|
||||
run.AppendChild(props);
|
||||
run.AppendChild(new DocumentFormat.OpenXml.Wordprocessing.Text(text)
|
||||
{
|
||||
Space = DocumentFormat.OpenXml.SpaceProcessingModeValues.Preserve
|
||||
});
|
||||
para.AppendChild(run);
|
||||
body.AppendChild(para);
|
||||
}
|
||||
|
||||
// ─── Markdown 생성 ──────────────────────────────────────────────────────
|
||||
|
||||
private static void GenerateMarkdown(string path, string title, List<SectionPlan> sections)
|
||||
{
|
||||
var sb = new StringBuilder();
|
||||
sb.AppendLine($"# {title}");
|
||||
sb.AppendLine();
|
||||
sb.AppendLine($"*작성일: {DateTime.Now:yyyy-MM-dd}*");
|
||||
sb.AppendLine();
|
||||
|
||||
// 목차
|
||||
if (sections.Count > 1)
|
||||
{
|
||||
sb.AppendLine("## 목차");
|
||||
sb.AppendLine();
|
||||
foreach (var sec in sections)
|
||||
sb.AppendLine($"- [{sec.Heading}](#{sec.Heading.Replace(" ", "-").ToLowerInvariant()})");
|
||||
sb.AppendLine();
|
||||
sb.AppendLine("---");
|
||||
sb.AppendLine();
|
||||
}
|
||||
|
||||
foreach (var sec in sections)
|
||||
{
|
||||
sb.AppendLine($"## {sec.Heading}");
|
||||
sb.AppendLine();
|
||||
foreach (var kp in sec.KeyPoints)
|
||||
{
|
||||
sb.AppendLine($"### ▸ {kp}");
|
||||
sb.AppendLine();
|
||||
sb.AppendLine($"{kp}에 대한 상세 내용을 여기에 작성합니다.");
|
||||
sb.AppendLine();
|
||||
}
|
||||
}
|
||||
|
||||
File.WriteAllText(path, sb.ToString(), Encoding.UTF8);
|
||||
}
|
||||
|
||||
// ─── 헬퍼 ────────────────────────────────────────────────────────────────
|
||||
|
||||
private static List<SectionPlan> BuildSections(string docType, int pages, string hint, string refSummary)
|
||||
{
|
||||
if (!string.IsNullOrWhiteSpace(hint))
|
||||
{
|
||||
var hintSections = hint.Split(new[] { ',', '/', '→', '\n' }, StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries);
|
||||
var result = new List<SectionPlan>();
|
||||
for (int i = 0; i < hintSections.Length; i++)
|
||||
{
|
||||
result.Add(new SectionPlan
|
||||
{
|
||||
Id = $"sec-{i + 1}",
|
||||
Heading = $"{i + 1}. {hintSections[i].TrimStart("0123456789. ".ToCharArray())}",
|
||||
Level = 1,
|
||||
KeyPoints = [$"{hintSections[i]} 관련 핵심 내용을 상세히 작성"],
|
||||
});
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
return docType switch
|
||||
{
|
||||
"proposal" => new List<SectionPlan>
|
||||
{
|
||||
new() { Id = "sec-1", Heading = "1. 개요", Level = 1, KeyPoints = ["배경", "목적", "범위"] },
|
||||
new() { Id = "sec-2", Heading = "2. 현황 분석", Level = 1, KeyPoints = ["현재 상황", "문제점 식별"] },
|
||||
new() { Id = "sec-3", Heading = "3. 제안 내용", Level = 1, KeyPoints = ["핵심 제안", "기대 효과", "실행 방안"] },
|
||||
new() { Id = "sec-4", Heading = "4. 추진 일정", Level = 1, KeyPoints = ["단계별 일정", "마일스톤"] },
|
||||
new() { Id = "sec-5", Heading = "5. 소요 자원", Level = 1, KeyPoints = ["인력", "예산", "장비"] },
|
||||
new() { Id = "sec-6", Heading = "6. 기대 효과 및 결론", Level = 1, KeyPoints = ["정량적 효과", "정성적 효과", "결론"] },
|
||||
},
|
||||
"analysis" => new List<SectionPlan>
|
||||
{
|
||||
new() { Id = "sec-1", Heading = "1. 분석 개요", Level = 1, KeyPoints = ["분석 목적", "분석 범위", "방법론"] },
|
||||
new() { Id = "sec-2", Heading = "2. 데이터 현황", Level = 1, KeyPoints = ["데이터 출처", "기본 통계", "데이터 품질"] },
|
||||
new() { Id = "sec-3", Heading = "3. 정량 분석", Level = 1, KeyPoints = ["수치 분석", "추세", "비교"] },
|
||||
new() { Id = "sec-4", Heading = "4. 정성 분석", Level = 1, KeyPoints = ["패턴", "인사이트", "이상치"] },
|
||||
new() { Id = "sec-5", Heading = "5. 종합 해석", Level = 1, KeyPoints = ["핵심 발견", "시사점"] },
|
||||
new() { Id = "sec-6", Heading = "6. 결론 및 권장사항", Level = 1, KeyPoints = ["결론", "조치 방안", "추가 분석 필요 항목"] },
|
||||
},
|
||||
"manual" or "guide" => new List<SectionPlan>
|
||||
{
|
||||
new() { Id = "sec-1", Heading = "1. 소개", Level = 1, KeyPoints = ["목적", "대상 독자", "사전 요구사항"] },
|
||||
new() { Id = "sec-2", Heading = "2. 시작하기", Level = 1, KeyPoints = ["설치", "초기 설정", "기본 사용법"] },
|
||||
new() { Id = "sec-3", Heading = "3. 주요 기능", Level = 1, KeyPoints = ["기능별 상세 설명", "사용 예시"] },
|
||||
new() { Id = "sec-4", Heading = "4. 고급 기능", Level = 1, KeyPoints = ["고급 설정", "커스터마이징", "자동화"] },
|
||||
new() { Id = "sec-5", Heading = "5. 문제 해결", Level = 1, KeyPoints = ["자주 묻는 질문", "에러 대응", "지원 정보"] },
|
||||
},
|
||||
"minutes" => new List<SectionPlan>
|
||||
{
|
||||
new() { Id = "sec-1", Heading = "1. 회의 정보", Level = 1, KeyPoints = ["일시", "참석자", "장소"] },
|
||||
new() { Id = "sec-2", Heading = "2. 안건 및 논의", Level = 1, KeyPoints = ["안건별 논의 내용", "주요 의견"] },
|
||||
new() { Id = "sec-3", Heading = "3. 결정 사항", Level = 1, KeyPoints = ["합의 내용", "변경 사항"] },
|
||||
new() { Id = "sec-4", Heading = "4. 액션 아이템", Level = 1, KeyPoints = ["담당자", "기한", "세부 내용"] },
|
||||
new() { Id = "sec-5", Heading = "5. 다음 회의", Level = 1, KeyPoints = ["예정일", "주요 안건"] },
|
||||
},
|
||||
_ => new List<SectionPlan>
|
||||
{
|
||||
new() { Id = "sec-1", Heading = "1. 개요", Level = 1, KeyPoints = ["배경", "목적", "범위"] },
|
||||
new() { Id = "sec-2", Heading = "2. 현황", Level = 1, KeyPoints = ["현재 상태", "주요 지표"] },
|
||||
new() { Id = "sec-3", Heading = "3. 분석", Level = 1, KeyPoints = ["데이터 분석", "비교", "추세"] },
|
||||
new() { Id = "sec-4", Heading = "4. 주요 발견", Level = 1, KeyPoints = ["핵심 인사이트", "문제점", "기회"] },
|
||||
new() { Id = "sec-5", Heading = "5. 제안", Level = 1, KeyPoints = ["개선 방안", "실행 계획"] },
|
||||
new() { Id = "sec-6", Heading = "6. 결론", Level = 1, KeyPoints = ["요약", "기대 효과", "향후 과제"] },
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
private static void DistributeWordCount(List<SectionPlan> sections, int totalWords)
|
||||
{
|
||||
if (sections.Count == 0) return;
|
||||
var weights = new double[sections.Count];
|
||||
for (int i = 0; i < sections.Count; i++)
|
||||
weights[i] = (i == 0 || i == sections.Count - 1) ? 0.7 : 1.2;
|
||||
|
||||
var totalWeight = weights.Sum();
|
||||
for (int i = 0; i < sections.Count; i++)
|
||||
sections[i].TargetWords = Math.Max(100, (int)(totalWords * weights[i] / totalWeight));
|
||||
}
|
||||
|
||||
private static string SanitizeFileName(string name)
|
||||
{
|
||||
var safe = name.Length > 60 ? name[..60] : name;
|
||||
foreach (var c in Path.GetInvalidFileNameChars())
|
||||
safe = safe.Replace(c, '_');
|
||||
return safe.Trim().TrimEnd('.');
|
||||
}
|
||||
|
||||
private static string GetDocTypeLabel(string docType) => docType switch
|
||||
{
|
||||
"proposal" => "제안서",
|
||||
"analysis" => "분석 보고서",
|
||||
"manual" or "guide" => "매뉴얼/가이드",
|
||||
"minutes" => "회의록",
|
||||
"presentation" => "프레젠테이션",
|
||||
_ => "보고서",
|
||||
};
|
||||
|
||||
private static string Escape(string text)
|
||||
=> text.Replace("&", "&").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<string> KeyPoints { get; set; } = new();
|
||||
}
|
||||
}
|
||||
@@ -9,7 +9,7 @@ namespace AxCopilot.Services.Agent;
|
||||
/// - 멀티패스(고품질) ON : 개요만 반환 → LLM이 섹션별로 상세 작성 → document_assemble로 조립
|
||||
/// - 멀티패스(고품질) OFF: 개요 + 기본 문서를 즉시 로컬 파일로 저장 (LLM 호출 최소)
|
||||
/// </summary>
|
||||
public class DocumentPlannerTool : IAgentTool
|
||||
public partial class DocumentPlannerTool : IAgentTool
|
||||
{
|
||||
private static bool IsMultiPassEnabled(AgentContext context)
|
||||
{
|
||||
@@ -321,278 +321,4 @@ public class DocumentPlannerTool : IAgentTool
|
||||
$"작성 완료 후 {createTool}로 '{suggestedPath}' 파일에 저장하세요.\n\n{json}"));
|
||||
}
|
||||
|
||||
// ─── HTML 생성 ───────────────────────────────────────────────────────────
|
||||
|
||||
private static void GenerateHtml(string path, string title, string docType, List<SectionPlan> sections)
|
||||
{
|
||||
var css = TemplateService.GetCss("professional");
|
||||
var sb = new StringBuilder();
|
||||
|
||||
sb.AppendLine("<!DOCTYPE html>");
|
||||
sb.AppendLine("<html lang=\"ko\">");
|
||||
sb.AppendLine("<head>");
|
||||
sb.AppendLine("<meta charset=\"utf-8\">");
|
||||
sb.AppendLine($"<title>{Escape(title)}</title>");
|
||||
sb.AppendLine("<style>");
|
||||
sb.AppendLine(css);
|
||||
sb.AppendLine(@"
|
||||
.doc { max-width: 900px; margin: 0 auto; padding: 40px 30px 60px; }
|
||||
.doc h1 { font-size: 28px; margin-bottom: 8px; border-bottom: 3px solid var(--accent, #4B5EFC); padding-bottom: 10px; }
|
||||
.doc h2 { font-size: 22px; margin-top: 36px; margin-bottom: 12px; border-bottom: 2px solid var(--accent, #4B5EFC); padding-bottom: 6px; }
|
||||
.doc .meta { color: #888; font-size: 13px; margin-bottom: 24px; }
|
||||
.doc .section { line-height: 1.8; margin-bottom: 20px; }
|
||||
.doc .toc { background: #f8f9fa; border-radius: 12px; padding: 20px 28px; margin: 24px 0 32px; }
|
||||
.doc .toc h3 { margin-top: 0; }
|
||||
.doc .toc a { color: inherit; text-decoration: none; }
|
||||
.doc .toc a:hover { text-decoration: underline; }
|
||||
.doc .toc ul { list-style: none; padding-left: 0; }
|
||||
.doc .toc li { padding: 4px 0; }
|
||||
.doc .key-point { background: #f0f4ff; border-left: 4px solid var(--accent, #4B5EFC); padding: 12px 16px; margin: 12px 0; border-radius: 0 8px 8px 0; }
|
||||
@media print { .doc h2 { page-break-before: auto; } }
|
||||
");
|
||||
sb.AppendLine("</style>");
|
||||
sb.AppendLine("</head>");
|
||||
sb.AppendLine("<body>");
|
||||
sb.AppendLine("<div class=\"doc\">");
|
||||
sb.AppendLine($"<h1>{Escape(title)}</h1>");
|
||||
sb.AppendLine($"<div class=\"meta\">문서 유형: {Escape(GetDocTypeLabel(docType))} | 작성일: {DateTime.Now:yyyy-MM-dd} | 섹션: {sections.Count}개</div>");
|
||||
|
||||
// 목차
|
||||
if (sections.Count > 1)
|
||||
{
|
||||
sb.AppendLine("<div class=\"toc\">");
|
||||
sb.AppendLine("<h3>📋 목차</h3>");
|
||||
sb.AppendLine("<ul>");
|
||||
for (int i = 0; i < sections.Count; i++)
|
||||
sb.AppendLine($"<li><a href=\"#sec-{i + 1}\">{Escape(sections[i].Heading)}</a></li>");
|
||||
sb.AppendLine("</ul>");
|
||||
sb.AppendLine("</div>");
|
||||
}
|
||||
|
||||
// 섹션 본문
|
||||
for (int i = 0; i < sections.Count; i++)
|
||||
{
|
||||
var sec = sections[i];
|
||||
sb.AppendLine($"<h2 id=\"sec-{i + 1}\">{Escape(sec.Heading)}</h2>");
|
||||
sb.AppendLine("<div class=\"section\">");
|
||||
foreach (var kp in sec.KeyPoints)
|
||||
{
|
||||
sb.AppendLine($"<div class=\"key-point\">");
|
||||
sb.AppendLine($"<strong>▸ {Escape(kp)}</strong>");
|
||||
sb.AppendLine($"<p>{Escape(kp)}에 대한 상세 내용을 여기에 작성합니다. (목표: 약 {sec.TargetWords / Math.Max(1, sec.KeyPoints.Count)}단어)</p>");
|
||||
sb.AppendLine("</div>");
|
||||
}
|
||||
sb.AppendLine("</div>");
|
||||
}
|
||||
|
||||
sb.AppendLine("</div>");
|
||||
sb.AppendLine("</body>");
|
||||
sb.AppendLine("</html>");
|
||||
|
||||
File.WriteAllText(path, sb.ToString(), Encoding.UTF8);
|
||||
}
|
||||
|
||||
// ─── DOCX 생성 ──────────────────────────────────────────────────────────
|
||||
|
||||
private static void GenerateDocx(string path, string title, List<SectionPlan> sections)
|
||||
{
|
||||
using var doc = DocumentFormat.OpenXml.Packaging.WordprocessingDocument.Create(
|
||||
path, DocumentFormat.OpenXml.WordprocessingDocumentType.Document);
|
||||
|
||||
var mainPart = doc.AddMainDocumentPart();
|
||||
mainPart.Document = new DocumentFormat.OpenXml.Wordprocessing.Document();
|
||||
var body = mainPart.Document.AppendChild(new DocumentFormat.OpenXml.Wordprocessing.Body());
|
||||
|
||||
// 제목
|
||||
AddDocxParagraph(body, title, bold: true, fontSize: "48");
|
||||
AddDocxParagraph(body, $"작성일: {DateTime.Now:yyyy-MM-dd}", fontSize: "20", color: "888888");
|
||||
AddDocxParagraph(body, ""); // 빈 줄
|
||||
|
||||
foreach (var sec in sections)
|
||||
{
|
||||
AddDocxParagraph(body, sec.Heading, bold: true, fontSize: "32", color: "2B579A");
|
||||
foreach (var kp in sec.KeyPoints)
|
||||
{
|
||||
AddDocxParagraph(body, $"▸ {kp}", bold: true, fontSize: "22");
|
||||
AddDocxParagraph(body, $"{kp}에 대한 상세 내용을 여기에 작성합니다.");
|
||||
}
|
||||
AddDocxParagraph(body, ""); // 섹션 간 빈 줄
|
||||
}
|
||||
}
|
||||
|
||||
private static void AddDocxParagraph(DocumentFormat.OpenXml.Wordprocessing.Body body,
|
||||
string text, bool bold = false, string fontSize = "22", string? color = null)
|
||||
{
|
||||
var para = new DocumentFormat.OpenXml.Wordprocessing.Paragraph();
|
||||
var run = new DocumentFormat.OpenXml.Wordprocessing.Run();
|
||||
var props = new DocumentFormat.OpenXml.Wordprocessing.RunProperties
|
||||
{
|
||||
FontSize = new DocumentFormat.OpenXml.Wordprocessing.FontSize { Val = fontSize }
|
||||
};
|
||||
if (bold) props.Bold = new DocumentFormat.OpenXml.Wordprocessing.Bold();
|
||||
if (color != null) props.Color = new DocumentFormat.OpenXml.Wordprocessing.Color { Val = color };
|
||||
run.AppendChild(props);
|
||||
run.AppendChild(new DocumentFormat.OpenXml.Wordprocessing.Text(text)
|
||||
{
|
||||
Space = DocumentFormat.OpenXml.SpaceProcessingModeValues.Preserve
|
||||
});
|
||||
para.AppendChild(run);
|
||||
body.AppendChild(para);
|
||||
}
|
||||
|
||||
// ─── Markdown 생성 ──────────────────────────────────────────────────────
|
||||
|
||||
private static void GenerateMarkdown(string path, string title, List<SectionPlan> sections)
|
||||
{
|
||||
var sb = new StringBuilder();
|
||||
sb.AppendLine($"# {title}");
|
||||
sb.AppendLine();
|
||||
sb.AppendLine($"*작성일: {DateTime.Now:yyyy-MM-dd}*");
|
||||
sb.AppendLine();
|
||||
|
||||
// 목차
|
||||
if (sections.Count > 1)
|
||||
{
|
||||
sb.AppendLine("## 목차");
|
||||
sb.AppendLine();
|
||||
foreach (var sec in sections)
|
||||
sb.AppendLine($"- [{sec.Heading}](#{sec.Heading.Replace(" ", "-").ToLowerInvariant()})");
|
||||
sb.AppendLine();
|
||||
sb.AppendLine("---");
|
||||
sb.AppendLine();
|
||||
}
|
||||
|
||||
foreach (var sec in sections)
|
||||
{
|
||||
sb.AppendLine($"## {sec.Heading}");
|
||||
sb.AppendLine();
|
||||
foreach (var kp in sec.KeyPoints)
|
||||
{
|
||||
sb.AppendLine($"### ▸ {kp}");
|
||||
sb.AppendLine();
|
||||
sb.AppendLine($"{kp}에 대한 상세 내용을 여기에 작성합니다.");
|
||||
sb.AppendLine();
|
||||
}
|
||||
}
|
||||
|
||||
File.WriteAllText(path, sb.ToString(), Encoding.UTF8);
|
||||
}
|
||||
|
||||
// ─── 헬퍼 ────────────────────────────────────────────────────────────────
|
||||
|
||||
private static List<SectionPlan> BuildSections(string docType, int pages, string hint, string refSummary)
|
||||
{
|
||||
if (!string.IsNullOrWhiteSpace(hint))
|
||||
{
|
||||
var hintSections = hint.Split(new[] { ',', '/', '→', '\n' }, StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries);
|
||||
var result = new List<SectionPlan>();
|
||||
for (int i = 0; i < hintSections.Length; i++)
|
||||
{
|
||||
result.Add(new SectionPlan
|
||||
{
|
||||
Id = $"sec-{i + 1}",
|
||||
Heading = $"{i + 1}. {hintSections[i].TrimStart("0123456789. ".ToCharArray())}",
|
||||
Level = 1,
|
||||
KeyPoints = [$"{hintSections[i]} 관련 핵심 내용을 상세히 작성"],
|
||||
});
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
return docType switch
|
||||
{
|
||||
"proposal" => new List<SectionPlan>
|
||||
{
|
||||
new() { Id = "sec-1", Heading = "1. 개요", Level = 1, KeyPoints = ["배경", "목적", "범위"] },
|
||||
new() { Id = "sec-2", Heading = "2. 현황 분석", Level = 1, KeyPoints = ["현재 상황", "문제점 식별"] },
|
||||
new() { Id = "sec-3", Heading = "3. 제안 내용", Level = 1, KeyPoints = ["핵심 제안", "기대 효과", "실행 방안"] },
|
||||
new() { Id = "sec-4", Heading = "4. 추진 일정", Level = 1, KeyPoints = ["단계별 일정", "마일스톤"] },
|
||||
new() { Id = "sec-5", Heading = "5. 소요 자원", Level = 1, KeyPoints = ["인력", "예산", "장비"] },
|
||||
new() { Id = "sec-6", Heading = "6. 기대 효과 및 결론", Level = 1, KeyPoints = ["정량적 효과", "정성적 효과", "결론"] },
|
||||
},
|
||||
"analysis" => new List<SectionPlan>
|
||||
{
|
||||
new() { Id = "sec-1", Heading = "1. 분석 개요", Level = 1, KeyPoints = ["분석 목적", "분석 범위", "방법론"] },
|
||||
new() { Id = "sec-2", Heading = "2. 데이터 현황", Level = 1, KeyPoints = ["데이터 출처", "기본 통계", "데이터 품질"] },
|
||||
new() { Id = "sec-3", Heading = "3. 정량 분석", Level = 1, KeyPoints = ["수치 분석", "추세", "비교"] },
|
||||
new() { Id = "sec-4", Heading = "4. 정성 분석", Level = 1, KeyPoints = ["패턴", "인사이트", "이상치"] },
|
||||
new() { Id = "sec-5", Heading = "5. 종합 해석", Level = 1, KeyPoints = ["핵심 발견", "시사점"] },
|
||||
new() { Id = "sec-6", Heading = "6. 결론 및 권장사항", Level = 1, KeyPoints = ["결론", "조치 방안", "추가 분석 필요 항목"] },
|
||||
},
|
||||
"manual" or "guide" => new List<SectionPlan>
|
||||
{
|
||||
new() { Id = "sec-1", Heading = "1. 소개", Level = 1, KeyPoints = ["목적", "대상 독자", "사전 요구사항"] },
|
||||
new() { Id = "sec-2", Heading = "2. 시작하기", Level = 1, KeyPoints = ["설치", "초기 설정", "기본 사용법"] },
|
||||
new() { Id = "sec-3", Heading = "3. 주요 기능", Level = 1, KeyPoints = ["기능별 상세 설명", "사용 예시"] },
|
||||
new() { Id = "sec-4", Heading = "4. 고급 기능", Level = 1, KeyPoints = ["고급 설정", "커스터마이징", "자동화"] },
|
||||
new() { Id = "sec-5", Heading = "5. 문제 해결", Level = 1, KeyPoints = ["자주 묻는 질문", "에러 대응", "지원 정보"] },
|
||||
},
|
||||
"minutes" => new List<SectionPlan>
|
||||
{
|
||||
new() { Id = "sec-1", Heading = "1. 회의 정보", Level = 1, KeyPoints = ["일시", "참석자", "장소"] },
|
||||
new() { Id = "sec-2", Heading = "2. 안건 및 논의", Level = 1, KeyPoints = ["안건별 논의 내용", "주요 의견"] },
|
||||
new() { Id = "sec-3", Heading = "3. 결정 사항", Level = 1, KeyPoints = ["합의 내용", "변경 사항"] },
|
||||
new() { Id = "sec-4", Heading = "4. 액션 아이템", Level = 1, KeyPoints = ["담당자", "기한", "세부 내용"] },
|
||||
new() { Id = "sec-5", Heading = "5. 다음 회의", Level = 1, KeyPoints = ["예정일", "주요 안건"] },
|
||||
},
|
||||
_ => new List<SectionPlan>
|
||||
{
|
||||
new() { Id = "sec-1", Heading = "1. 개요", Level = 1, KeyPoints = ["배경", "목적", "범위"] },
|
||||
new() { Id = "sec-2", Heading = "2. 현황", Level = 1, KeyPoints = ["현재 상태", "주요 지표"] },
|
||||
new() { Id = "sec-3", Heading = "3. 분석", Level = 1, KeyPoints = ["데이터 분석", "비교", "추세"] },
|
||||
new() { Id = "sec-4", Heading = "4. 주요 발견", Level = 1, KeyPoints = ["핵심 인사이트", "문제점", "기회"] },
|
||||
new() { Id = "sec-5", Heading = "5. 제안", Level = 1, KeyPoints = ["개선 방안", "실행 계획"] },
|
||||
new() { Id = "sec-6", Heading = "6. 결론", Level = 1, KeyPoints = ["요약", "기대 효과", "향후 과제"] },
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
private static void DistributeWordCount(List<SectionPlan> sections, int totalWords)
|
||||
{
|
||||
if (sections.Count == 0) return;
|
||||
var weights = new double[sections.Count];
|
||||
for (int i = 0; i < sections.Count; i++)
|
||||
weights[i] = (i == 0 || i == sections.Count - 1) ? 0.7 : 1.2;
|
||||
|
||||
var totalWeight = weights.Sum();
|
||||
for (int i = 0; i < sections.Count; i++)
|
||||
sections[i].TargetWords = Math.Max(100, (int)(totalWords * weights[i] / totalWeight));
|
||||
}
|
||||
|
||||
private static string SanitizeFileName(string name)
|
||||
{
|
||||
var safe = name.Length > 60 ? name[..60] : name;
|
||||
foreach (var c in Path.GetInvalidFileNameChars())
|
||||
safe = safe.Replace(c, '_');
|
||||
return safe.Trim().TrimEnd('.');
|
||||
}
|
||||
|
||||
private static string GetDocTypeLabel(string docType) => docType switch
|
||||
{
|
||||
"proposal" => "제안서",
|
||||
"analysis" => "분석 보고서",
|
||||
"manual" or "guide" => "매뉴얼/가이드",
|
||||
"minutes" => "회의록",
|
||||
"presentation" => "프레젠테이션",
|
||||
_ => "보고서",
|
||||
};
|
||||
|
||||
private static string Escape(string text)
|
||||
=> text.Replace("&", "&").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<string> KeyPoints { get; set; } = new();
|
||||
}
|
||||
}
|
||||
|
||||
248
src/AxCopilot/Services/Agent/DocumentReaderTool.Formats.cs
Normal file
248
src/AxCopilot/Services/Agent/DocumentReaderTool.Formats.cs
Normal file
@@ -0,0 +1,248 @@
|
||||
using System.IO;
|
||||
using System.Text;
|
||||
using System.Text.Json;
|
||||
using System.Text.RegularExpressions;
|
||||
using DocumentFormat.OpenXml.Packaging;
|
||||
using DocumentFormat.OpenXml.Spreadsheet;
|
||||
using UglyToad.PdfPig;
|
||||
|
||||
namespace AxCopilot.Services.Agent;
|
||||
|
||||
public partial class DocumentReaderTool
|
||||
{
|
||||
// ─── BibTeX / RIS / DOCX / XLSX / Text / Helpers ─────────────────────
|
||||
|
||||
// ─── BibTeX ─────────────────────────────────────────────────────────────
|
||||
|
||||
private static string ReadBibTeX(string path, int maxChars)
|
||||
{
|
||||
var content = File.ReadAllText(path, Encoding.UTF8);
|
||||
var sb = new StringBuilder();
|
||||
|
||||
var entryPattern = new Regex(
|
||||
@"@(\w+)\s*\{\s*([^,\s]+)\s*,\s*(.*?)\n\s*\}",
|
||||
RegexOptions.Singleline);
|
||||
|
||||
var fieldPattern = new Regex(
|
||||
@"(\w+)\s*=\s*[\{""](.*?)[\}""]",
|
||||
RegexOptions.Singleline);
|
||||
|
||||
var matches = entryPattern.Matches(content);
|
||||
sb.AppendLine($"BibTeX: {matches.Count}개 항목");
|
||||
sb.AppendLine();
|
||||
|
||||
int idx = 0;
|
||||
foreach (Match m in matches)
|
||||
{
|
||||
if (sb.Length >= maxChars) break;
|
||||
idx++;
|
||||
|
||||
var entryType = m.Groups[1].Value;
|
||||
var citeKey = m.Groups[2].Value;
|
||||
var body = m.Groups[3].Value;
|
||||
|
||||
sb.AppendLine($"[{idx}] @{entryType}{{{citeKey}}}");
|
||||
|
||||
var fields = fieldPattern.Matches(body);
|
||||
foreach (Match f in fields)
|
||||
{
|
||||
var fieldName = f.Groups[1].Value.ToLower();
|
||||
var fieldValue = f.Groups[2].Value.Trim();
|
||||
|
||||
// 핵심 필드만 표시
|
||||
if (fieldName is "author" or "title" or "journal" or "booktitle"
|
||||
or "year" or "volume" or "number" or "pages" or "doi"
|
||||
or "publisher" or "url")
|
||||
{
|
||||
sb.AppendLine($" {fieldName}: {fieldValue}");
|
||||
}
|
||||
}
|
||||
sb.AppendLine();
|
||||
}
|
||||
|
||||
if (matches.Count == 0)
|
||||
{
|
||||
sb.AppendLine("(BibTeX 항목을 파싱하지 못했습니다. 원문을 반환합니다.)");
|
||||
sb.AppendLine(Truncate(content, maxChars - sb.Length));
|
||||
}
|
||||
|
||||
return Truncate(sb.ToString(), maxChars);
|
||||
}
|
||||
|
||||
// ─── RIS ────────────────────────────────────────────────────────────────
|
||||
|
||||
private static string ReadRis(string path, int maxChars)
|
||||
{
|
||||
var lines = File.ReadAllLines(path, Encoding.UTF8);
|
||||
var sb = new StringBuilder();
|
||||
|
||||
var entries = new List<Dictionary<string, List<string>>>();
|
||||
Dictionary<string, List<string>>? current = null;
|
||||
|
||||
foreach (var line in lines)
|
||||
{
|
||||
if (line.StartsWith("TY -"))
|
||||
{
|
||||
current = new Dictionary<string, List<string>>();
|
||||
entries.Add(current);
|
||||
}
|
||||
else if (line.StartsWith("ER -"))
|
||||
{
|
||||
current = null;
|
||||
continue;
|
||||
}
|
||||
|
||||
if (current != null && line.Length >= 6 && line[2] == ' ' && line[3] == ' ' && line[4] == '-' && line[5] == ' ')
|
||||
{
|
||||
var tag = line[..2].Trim();
|
||||
var value = line[6..].Trim();
|
||||
if (!current.ContainsKey(tag))
|
||||
current[tag] = new List<string>();
|
||||
current[tag].Add(value);
|
||||
}
|
||||
}
|
||||
|
||||
sb.AppendLine($"RIS: {entries.Count}개 항목");
|
||||
sb.AppendLine();
|
||||
|
||||
// RIS 태그 → 사람이 읽을 수 있는 이름
|
||||
var tagNames = new Dictionary<string, string>
|
||||
{
|
||||
["TY"] = "Type", ["AU"] = "Author", ["TI"] = "Title", ["T1"] = "Title",
|
||||
["JO"] = "Journal", ["JF"] = "Journal", ["PY"] = "Year", ["Y1"] = "Year",
|
||||
["VL"] = "Volume", ["IS"] = "Issue", ["SP"] = "Start Page", ["EP"] = "End Page",
|
||||
["DO"] = "DOI", ["UR"] = "URL", ["PB"] = "Publisher", ["AB"] = "Abstract",
|
||||
["KW"] = "Keyword", ["SN"] = "ISSN/ISBN",
|
||||
};
|
||||
|
||||
for (int i = 0; i < entries.Count && sb.Length < maxChars; i++)
|
||||
{
|
||||
sb.AppendLine($"[{i + 1}]");
|
||||
var entry = entries[i];
|
||||
foreach (var (tag, values) in entry)
|
||||
{
|
||||
var label = tagNames.GetValueOrDefault(tag, tag);
|
||||
if (tag is "AU" or "KW")
|
||||
sb.AppendLine($" {label}: {string.Join("; ", values)}");
|
||||
else
|
||||
sb.AppendLine($" {label}: {string.Join(" ", values)}");
|
||||
}
|
||||
sb.AppendLine();
|
||||
}
|
||||
|
||||
return Truncate(sb.ToString(), maxChars);
|
||||
}
|
||||
|
||||
// ─── DOCX ───────────────────────────────────────────────────────────────
|
||||
|
||||
private static string ReadDocx(string path, int maxChars)
|
||||
{
|
||||
var sb = new StringBuilder();
|
||||
using var doc = WordprocessingDocument.Open(path, false);
|
||||
var body = doc.MainDocumentPart?.Document.Body;
|
||||
if (body == null) return "(빈 문서)";
|
||||
|
||||
foreach (var para in body.Elements<DocumentFormat.OpenXml.Wordprocessing.Paragraph>())
|
||||
{
|
||||
var text = para.InnerText;
|
||||
if (!string.IsNullOrWhiteSpace(text))
|
||||
{
|
||||
sb.AppendLine(text);
|
||||
if (sb.Length >= maxChars) break;
|
||||
}
|
||||
}
|
||||
|
||||
return Truncate(sb.ToString(), maxChars);
|
||||
}
|
||||
|
||||
// ─── XLSX ───────────────────────────────────────────────────────────────
|
||||
|
||||
private static string ReadXlsx(string path, string sheetParam, int maxChars)
|
||||
{
|
||||
var sb = new StringBuilder();
|
||||
using var doc = SpreadsheetDocument.Open(path, false);
|
||||
var workbook = doc.WorkbookPart;
|
||||
if (workbook == null) return "(빈 스프레드시트)";
|
||||
|
||||
var sheets = workbook.Workbook.Sheets?.Elements<Sheet>().ToList() ?? [];
|
||||
if (sheets.Count == 0) return "(시트 없음)";
|
||||
|
||||
sb.AppendLine($"Excel: {sheets.Count}개 시트 ({string.Join(", ", sheets.Select(s => s.Name?.Value))})");
|
||||
sb.AppendLine();
|
||||
|
||||
Sheet? targetSheet = null;
|
||||
if (!string.IsNullOrEmpty(sheetParam))
|
||||
{
|
||||
if (int.TryParse(sheetParam, out var idx) && idx >= 1 && idx <= sheets.Count)
|
||||
targetSheet = sheets[idx - 1];
|
||||
else
|
||||
targetSheet = sheets.FirstOrDefault(s =>
|
||||
string.Equals(s.Name?.Value, sheetParam, StringComparison.OrdinalIgnoreCase));
|
||||
}
|
||||
targetSheet ??= sheets[0];
|
||||
|
||||
var sheetId = targetSheet.Id?.Value;
|
||||
if (sheetId == null) return "(시트 ID 없음)";
|
||||
|
||||
var wsPart = (WorksheetPart)workbook.GetPartById(sheetId);
|
||||
var sharedStrings = workbook.SharedStringTablePart?.SharedStringTable
|
||||
.Elements<SharedStringItem>().ToList() ?? [];
|
||||
|
||||
var rows = wsPart.Worksheet.Descendants<Row>().ToList();
|
||||
sb.AppendLine($"[{targetSheet.Name?.Value}] ({rows.Count} rows)");
|
||||
|
||||
foreach (var row in rows)
|
||||
{
|
||||
var cells = row.Elements<Cell>().ToList();
|
||||
var values = new List<string>();
|
||||
foreach (var cell in cells)
|
||||
values.Add(GetCellValue(cell, sharedStrings));
|
||||
sb.AppendLine(string.Join("\t", values));
|
||||
if (sb.Length >= maxChars) break;
|
||||
}
|
||||
|
||||
return Truncate(sb.ToString(), maxChars);
|
||||
}
|
||||
|
||||
private static string GetCellValue(Cell cell, List<SharedStringItem> sharedStrings)
|
||||
{
|
||||
var value = cell.CellValue?.Text ?? "";
|
||||
if (cell.DataType?.Value == CellValues.SharedString)
|
||||
{
|
||||
if (int.TryParse(value, out var idx) && idx >= 0 && idx < sharedStrings.Count)
|
||||
return sharedStrings[idx].InnerText;
|
||||
}
|
||||
return value;
|
||||
}
|
||||
|
||||
// ─── Text ───────────────────────────────────────────────────────────────
|
||||
|
||||
private static async Task<string> ReadTextFile(string path, int maxChars, CancellationToken ct)
|
||||
{
|
||||
var text = await File.ReadAllTextAsync(path, Encoding.UTF8, ct);
|
||||
return Truncate(text, maxChars);
|
||||
}
|
||||
|
||||
// ─── Helpers ────────────────────────────────────────────────────────────
|
||||
|
||||
private static string Truncate(string text, int maxChars)
|
||||
{
|
||||
if (text.Length <= maxChars) return text;
|
||||
return text[..maxChars] + "\n\n... (내용 잘림 — pages 또는 section 파라미터로 특정 부분을 읽을 수 있습니다)";
|
||||
}
|
||||
|
||||
private static string FormatSize(long bytes) => bytes switch
|
||||
{
|
||||
< 1024 => $"{bytes} B",
|
||||
< 1024 * 1024 => $"{bytes / 1024.0:F1} KB",
|
||||
_ => $"{bytes / (1024.0 * 1024.0):F1} MB",
|
||||
};
|
||||
|
||||
/// <summary>JsonElement에서 int를 안전하게 추출합니다. string/integer 양쪽 호환.</summary>
|
||||
private static int GetIntValue(JsonElement el, int defaultValue)
|
||||
{
|
||||
if (el.ValueKind == JsonValueKind.Number) return el.GetInt32();
|
||||
if (el.ValueKind == JsonValueKind.String && int.TryParse(el.GetString(), out var v)) return v;
|
||||
return defaultValue;
|
||||
}
|
||||
}
|
||||
@@ -12,7 +12,7 @@ namespace AxCopilot.Services.Agent;
|
||||
/// 문서 파일을 읽어 텍스트로 반환하는 도구.
|
||||
/// PDF, DOCX, XLSX, CSV, TXT, BibTeX, RIS 등 다양한 형식을 지원합니다.
|
||||
/// </summary>
|
||||
public class DocumentReaderTool : IAgentTool
|
||||
public partial class DocumentReaderTool : IAgentTool
|
||||
{
|
||||
public string Name => "document_read";
|
||||
public string Description =>
|
||||
@@ -335,237 +335,4 @@ public class DocumentReaderTool : IAgentTool
|
||||
return entries;
|
||||
}
|
||||
|
||||
// ─── BibTeX ─────────────────────────────────────────────────────────────
|
||||
|
||||
private static string ReadBibTeX(string path, int maxChars)
|
||||
{
|
||||
var content = File.ReadAllText(path, Encoding.UTF8);
|
||||
var sb = new StringBuilder();
|
||||
|
||||
var entryPattern = new Regex(
|
||||
@"@(\w+)\s*\{\s*([^,\s]+)\s*,\s*(.*?)\n\s*\}",
|
||||
RegexOptions.Singleline);
|
||||
|
||||
var fieldPattern = new Regex(
|
||||
@"(\w+)\s*=\s*[\{""](.*?)[\}""]",
|
||||
RegexOptions.Singleline);
|
||||
|
||||
var matches = entryPattern.Matches(content);
|
||||
sb.AppendLine($"BibTeX: {matches.Count}개 항목");
|
||||
sb.AppendLine();
|
||||
|
||||
int idx = 0;
|
||||
foreach (Match m in matches)
|
||||
{
|
||||
if (sb.Length >= maxChars) break;
|
||||
idx++;
|
||||
|
||||
var entryType = m.Groups[1].Value;
|
||||
var citeKey = m.Groups[2].Value;
|
||||
var body = m.Groups[3].Value;
|
||||
|
||||
sb.AppendLine($"[{idx}] @{entryType}{{{citeKey}}}");
|
||||
|
||||
var fields = fieldPattern.Matches(body);
|
||||
foreach (Match f in fields)
|
||||
{
|
||||
var fieldName = f.Groups[1].Value.ToLower();
|
||||
var fieldValue = f.Groups[2].Value.Trim();
|
||||
|
||||
// 핵심 필드만 표시
|
||||
if (fieldName is "author" or "title" or "journal" or "booktitle"
|
||||
or "year" or "volume" or "number" or "pages" or "doi"
|
||||
or "publisher" or "url")
|
||||
{
|
||||
sb.AppendLine($" {fieldName}: {fieldValue}");
|
||||
}
|
||||
}
|
||||
sb.AppendLine();
|
||||
}
|
||||
|
||||
if (matches.Count == 0)
|
||||
{
|
||||
sb.AppendLine("(BibTeX 항목을 파싱하지 못했습니다. 원문을 반환합니다.)");
|
||||
sb.AppendLine(Truncate(content, maxChars - sb.Length));
|
||||
}
|
||||
|
||||
return Truncate(sb.ToString(), maxChars);
|
||||
}
|
||||
|
||||
// ─── RIS ────────────────────────────────────────────────────────────────
|
||||
|
||||
private static string ReadRis(string path, int maxChars)
|
||||
{
|
||||
var lines = File.ReadAllLines(path, Encoding.UTF8);
|
||||
var sb = new StringBuilder();
|
||||
|
||||
var entries = new List<Dictionary<string, List<string>>>();
|
||||
Dictionary<string, List<string>>? current = null;
|
||||
|
||||
foreach (var line in lines)
|
||||
{
|
||||
if (line.StartsWith("TY -"))
|
||||
{
|
||||
current = new Dictionary<string, List<string>>();
|
||||
entries.Add(current);
|
||||
}
|
||||
else if (line.StartsWith("ER -"))
|
||||
{
|
||||
current = null;
|
||||
continue;
|
||||
}
|
||||
|
||||
if (current != null && line.Length >= 6 && line[2] == ' ' && line[3] == ' ' && line[4] == '-' && line[5] == ' ')
|
||||
{
|
||||
var tag = line[..2].Trim();
|
||||
var value = line[6..].Trim();
|
||||
if (!current.ContainsKey(tag))
|
||||
current[tag] = new List<string>();
|
||||
current[tag].Add(value);
|
||||
}
|
||||
}
|
||||
|
||||
sb.AppendLine($"RIS: {entries.Count}개 항목");
|
||||
sb.AppendLine();
|
||||
|
||||
// RIS 태그 → 사람이 읽을 수 있는 이름
|
||||
var tagNames = new Dictionary<string, string>
|
||||
{
|
||||
["TY"] = "Type", ["AU"] = "Author", ["TI"] = "Title", ["T1"] = "Title",
|
||||
["JO"] = "Journal", ["JF"] = "Journal", ["PY"] = "Year", ["Y1"] = "Year",
|
||||
["VL"] = "Volume", ["IS"] = "Issue", ["SP"] = "Start Page", ["EP"] = "End Page",
|
||||
["DO"] = "DOI", ["UR"] = "URL", ["PB"] = "Publisher", ["AB"] = "Abstract",
|
||||
["KW"] = "Keyword", ["SN"] = "ISSN/ISBN",
|
||||
};
|
||||
|
||||
for (int i = 0; i < entries.Count && sb.Length < maxChars; i++)
|
||||
{
|
||||
sb.AppendLine($"[{i + 1}]");
|
||||
var entry = entries[i];
|
||||
foreach (var (tag, values) in entry)
|
||||
{
|
||||
var label = tagNames.GetValueOrDefault(tag, tag);
|
||||
if (tag is "AU" or "KW")
|
||||
sb.AppendLine($" {label}: {string.Join("; ", values)}");
|
||||
else
|
||||
sb.AppendLine($" {label}: {string.Join(" ", values)}");
|
||||
}
|
||||
sb.AppendLine();
|
||||
}
|
||||
|
||||
return Truncate(sb.ToString(), maxChars);
|
||||
}
|
||||
|
||||
// ─── DOCX ───────────────────────────────────────────────────────────────
|
||||
|
||||
private static string ReadDocx(string path, int maxChars)
|
||||
{
|
||||
var sb = new StringBuilder();
|
||||
using var doc = WordprocessingDocument.Open(path, false);
|
||||
var body = doc.MainDocumentPart?.Document.Body;
|
||||
if (body == null) return "(빈 문서)";
|
||||
|
||||
foreach (var para in body.Elements<DocumentFormat.OpenXml.Wordprocessing.Paragraph>())
|
||||
{
|
||||
var text = para.InnerText;
|
||||
if (!string.IsNullOrWhiteSpace(text))
|
||||
{
|
||||
sb.AppendLine(text);
|
||||
if (sb.Length >= maxChars) break;
|
||||
}
|
||||
}
|
||||
|
||||
return Truncate(sb.ToString(), maxChars);
|
||||
}
|
||||
|
||||
// ─── XLSX ───────────────────────────────────────────────────────────────
|
||||
|
||||
private static string ReadXlsx(string path, string sheetParam, int maxChars)
|
||||
{
|
||||
var sb = new StringBuilder();
|
||||
using var doc = SpreadsheetDocument.Open(path, false);
|
||||
var workbook = doc.WorkbookPart;
|
||||
if (workbook == null) return "(빈 스프레드시트)";
|
||||
|
||||
var sheets = workbook.Workbook.Sheets?.Elements<Sheet>().ToList() ?? [];
|
||||
if (sheets.Count == 0) return "(시트 없음)";
|
||||
|
||||
sb.AppendLine($"Excel: {sheets.Count}개 시트 ({string.Join(", ", sheets.Select(s => s.Name?.Value))})");
|
||||
sb.AppendLine();
|
||||
|
||||
Sheet? targetSheet = null;
|
||||
if (!string.IsNullOrEmpty(sheetParam))
|
||||
{
|
||||
if (int.TryParse(sheetParam, out var idx) && idx >= 1 && idx <= sheets.Count)
|
||||
targetSheet = sheets[idx - 1];
|
||||
else
|
||||
targetSheet = sheets.FirstOrDefault(s =>
|
||||
string.Equals(s.Name?.Value, sheetParam, StringComparison.OrdinalIgnoreCase));
|
||||
}
|
||||
targetSheet ??= sheets[0];
|
||||
|
||||
var sheetId = targetSheet.Id?.Value;
|
||||
if (sheetId == null) return "(시트 ID 없음)";
|
||||
|
||||
var wsPart = (WorksheetPart)workbook.GetPartById(sheetId);
|
||||
var sharedStrings = workbook.SharedStringTablePart?.SharedStringTable
|
||||
.Elements<SharedStringItem>().ToList() ?? [];
|
||||
|
||||
var rows = wsPart.Worksheet.Descendants<Row>().ToList();
|
||||
sb.AppendLine($"[{targetSheet.Name?.Value}] ({rows.Count} rows)");
|
||||
|
||||
foreach (var row in rows)
|
||||
{
|
||||
var cells = row.Elements<Cell>().ToList();
|
||||
var values = new List<string>();
|
||||
foreach (var cell in cells)
|
||||
values.Add(GetCellValue(cell, sharedStrings));
|
||||
sb.AppendLine(string.Join("\t", values));
|
||||
if (sb.Length >= maxChars) break;
|
||||
}
|
||||
|
||||
return Truncate(sb.ToString(), maxChars);
|
||||
}
|
||||
|
||||
private static string GetCellValue(Cell cell, List<SharedStringItem> sharedStrings)
|
||||
{
|
||||
var value = cell.CellValue?.Text ?? "";
|
||||
if (cell.DataType?.Value == CellValues.SharedString)
|
||||
{
|
||||
if (int.TryParse(value, out var idx) && idx >= 0 && idx < sharedStrings.Count)
|
||||
return sharedStrings[idx].InnerText;
|
||||
}
|
||||
return value;
|
||||
}
|
||||
|
||||
// ─── Text ───────────────────────────────────────────────────────────────
|
||||
|
||||
private static async Task<string> ReadTextFile(string path, int maxChars, CancellationToken ct)
|
||||
{
|
||||
var text = await File.ReadAllTextAsync(path, Encoding.UTF8, ct);
|
||||
return Truncate(text, maxChars);
|
||||
}
|
||||
|
||||
// ─── Helpers ────────────────────────────────────────────────────────────
|
||||
|
||||
private static string Truncate(string text, int maxChars)
|
||||
{
|
||||
if (text.Length <= maxChars) return text;
|
||||
return text[..maxChars] + "\n\n... (내용 잘림 — pages 또는 section 파라미터로 특정 부분을 읽을 수 있습니다)";
|
||||
}
|
||||
|
||||
private static string FormatSize(long bytes) => bytes switch
|
||||
{
|
||||
< 1024 => $"{bytes} B",
|
||||
< 1024 * 1024 => $"{bytes / 1024.0:F1} KB",
|
||||
_ => $"{bytes / (1024.0 * 1024.0):F1} MB",
|
||||
};
|
||||
|
||||
/// <summary>JsonElement에서 int를 안전하게 추출합니다. string/integer 양쪽 호환.</summary>
|
||||
private static int GetIntValue(JsonElement el, int defaultValue)
|
||||
{
|
||||
if (el.ValueKind == JsonValueKind.Number) return el.GetInt32();
|
||||
if (el.ValueKind == JsonValueKind.String && int.TryParse(el.GetString(), out var v)) return v;
|
||||
return defaultValue;
|
||||
}
|
||||
}
|
||||
|
||||
125
src/AxCopilot/Services/ClipboardHistoryService.ImageCache.cs
Normal file
125
src/AxCopilot/Services/ClipboardHistoryService.ImageCache.cs
Normal file
@@ -0,0 +1,125 @@
|
||||
using System.IO;
|
||||
using System.Runtime.InteropServices;
|
||||
using System.Security.Cryptography;
|
||||
using System.Text;
|
||||
using System.Text.Json;
|
||||
using System.Text.Json.Serialization;
|
||||
using System.Text.RegularExpressions;
|
||||
using System.Windows;
|
||||
using System.Windows.Interop;
|
||||
using System.Windows.Media.Imaging;
|
||||
using AxCopilot.Models;
|
||||
|
||||
namespace AxCopilot.Services;
|
||||
|
||||
public partial class ClipboardHistoryService
|
||||
{
|
||||
// ─── 이미지 캐시 + P/Invoke ─────────────────────────────────────────────
|
||||
|
||||
/// <summary>원본 이미지를 캐시 폴더에 PNG로 저장합니다.</summary>
|
||||
private static string? SaveOriginalImage(BitmapSource src)
|
||||
{
|
||||
try
|
||||
{
|
||||
Directory.CreateDirectory(ImageCachePath);
|
||||
var fileName = $"clip_{DateTime.Now:yyyyMMdd_HHmmss_fff}.png";
|
||||
var filePath = Path.Combine(ImageCachePath, fileName);
|
||||
|
||||
var encoder = new PngBitmapEncoder();
|
||||
encoder.Frames.Add(BitmapFrame.Create(src));
|
||||
using var fs = new FileStream(filePath, FileMode.Create);
|
||||
encoder.Save(fs);
|
||||
return filePath;
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
LogService.Warn($"원본 이미지 저장 실패: {ex.Message}");
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>원본 이미지를 파일에서 로드합니다.</summary>
|
||||
public static BitmapSource? LoadOriginalImage(string? path)
|
||||
{
|
||||
if (string.IsNullOrEmpty(path) || !File.Exists(path)) return null;
|
||||
try
|
||||
{
|
||||
var bi = new BitmapImage();
|
||||
bi.BeginInit();
|
||||
bi.CacheOption = BitmapCacheOption.OnLoad;
|
||||
bi.UriSource = new Uri(path, UriKind.Absolute);
|
||||
bi.EndInit();
|
||||
bi.Freeze();
|
||||
return bi;
|
||||
}
|
||||
catch (Exception) { return null; }
|
||||
}
|
||||
|
||||
/// <summary>이미지 캐시 정리 (30일 초과 + 500MB 초과 시 오래된 파일부터 삭제).</summary>
|
||||
public static void CleanupImageCache()
|
||||
{
|
||||
try
|
||||
{
|
||||
if (!Directory.Exists(ImageCachePath)) return;
|
||||
var files = new DirectoryInfo(ImageCachePath)
|
||||
.GetFiles("clip_*.png")
|
||||
.OrderBy(f => f.LastWriteTime)
|
||||
.ToList();
|
||||
|
||||
// 30일 초과 파일 삭제
|
||||
var cutoff = DateTime.Now.AddDays(-MaxCacheAgeDays);
|
||||
foreach (var f in files.Where(f => f.LastWriteTime < cutoff).ToList())
|
||||
{
|
||||
try { f.Delete(); files.Remove(f); } catch (Exception) { }
|
||||
}
|
||||
|
||||
// 500MB 초과 시 오래된 파일부터 삭제
|
||||
var totalSize = files.Sum(f => f.Length);
|
||||
while (totalSize > MaxCacheSizeBytes && files.Count > 0)
|
||||
{
|
||||
var oldest = files[0];
|
||||
totalSize -= oldest.Length;
|
||||
try { oldest.Delete(); } catch (Exception) { }
|
||||
files.RemoveAt(0);
|
||||
}
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
LogService.Warn($"이미지 캐시 정리 실패: {ex.Message}");
|
||||
}
|
||||
}
|
||||
|
||||
private static BitmapSource CreateThumbnail(BitmapSource src, int maxWidth)
|
||||
{
|
||||
if (src.PixelWidth <= maxWidth) return src;
|
||||
var scale = (double)maxWidth / src.PixelWidth;
|
||||
return new TransformedBitmap(src, new System.Windows.Media.ScaleTransform(scale, scale));
|
||||
}
|
||||
|
||||
private static string? ImageToBase64(BitmapSource? img)
|
||||
{
|
||||
if (img == null) return null;
|
||||
try
|
||||
{
|
||||
var encoder = new PngBitmapEncoder();
|
||||
encoder.Frames.Add(BitmapFrame.Create(img));
|
||||
using var ms = new MemoryStream();
|
||||
encoder.Save(ms);
|
||||
return Convert.ToBase64String(ms.ToArray());
|
||||
}
|
||||
catch (Exception) { return null; }
|
||||
}
|
||||
|
||||
private static BitmapSource? Base64ToImage(string? base64)
|
||||
{
|
||||
if (string.IsNullOrEmpty(base64)) return null;
|
||||
try
|
||||
{
|
||||
var bytes = Convert.FromBase64String(base64);
|
||||
using var ms = new MemoryStream(bytes);
|
||||
var decoder = BitmapDecoder.Create(ms, BitmapCreateOptions.None, BitmapCacheOption.OnLoad);
|
||||
return decoder.Frames[0];
|
||||
}
|
||||
catch (Exception) { return null; }
|
||||
}
|
||||
}
|
||||
@@ -16,7 +16,7 @@ namespace AxCopilot.Services;
|
||||
/// 클립보드 변경을 감지하여 히스토리를 관리합니다.
|
||||
/// WM_CLIPBOARDUPDATE 메시지를 수신하기 위해 숨겨진 메시지 창을 생성합니다.
|
||||
/// </summary>
|
||||
public class ClipboardHistoryService : IDisposable
|
||||
public partial class ClipboardHistoryService : IDisposable
|
||||
{
|
||||
private const int WM_CLIPBOARDUPDATE = 0x031D;
|
||||
|
||||
@@ -415,113 +415,6 @@ public class ClipboardHistoryService : IDisposable
|
||||
});
|
||||
}
|
||||
|
||||
/// <summary>원본 이미지를 캐시 폴더에 PNG로 저장합니다.</summary>
|
||||
private static string? SaveOriginalImage(BitmapSource src)
|
||||
{
|
||||
try
|
||||
{
|
||||
Directory.CreateDirectory(ImageCachePath);
|
||||
var fileName = $"clip_{DateTime.Now:yyyyMMdd_HHmmss_fff}.png";
|
||||
var filePath = Path.Combine(ImageCachePath, fileName);
|
||||
|
||||
var encoder = new PngBitmapEncoder();
|
||||
encoder.Frames.Add(BitmapFrame.Create(src));
|
||||
using var fs = new FileStream(filePath, FileMode.Create);
|
||||
encoder.Save(fs);
|
||||
return filePath;
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
LogService.Warn($"원본 이미지 저장 실패: {ex.Message}");
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>원본 이미지를 파일에서 로드합니다.</summary>
|
||||
public static BitmapSource? LoadOriginalImage(string? path)
|
||||
{
|
||||
if (string.IsNullOrEmpty(path) || !File.Exists(path)) return null;
|
||||
try
|
||||
{
|
||||
var bi = new BitmapImage();
|
||||
bi.BeginInit();
|
||||
bi.CacheOption = BitmapCacheOption.OnLoad;
|
||||
bi.UriSource = new Uri(path, UriKind.Absolute);
|
||||
bi.EndInit();
|
||||
bi.Freeze();
|
||||
return bi;
|
||||
}
|
||||
catch (Exception) { return null; }
|
||||
}
|
||||
|
||||
/// <summary>이미지 캐시 정리 (30일 초과 + 500MB 초과 시 오래된 파일부터 삭제).</summary>
|
||||
public static void CleanupImageCache()
|
||||
{
|
||||
try
|
||||
{
|
||||
if (!Directory.Exists(ImageCachePath)) return;
|
||||
var files = new DirectoryInfo(ImageCachePath)
|
||||
.GetFiles("clip_*.png")
|
||||
.OrderBy(f => f.LastWriteTime)
|
||||
.ToList();
|
||||
|
||||
// 30일 초과 파일 삭제
|
||||
var cutoff = DateTime.Now.AddDays(-MaxCacheAgeDays);
|
||||
foreach (var f in files.Where(f => f.LastWriteTime < cutoff).ToList())
|
||||
{
|
||||
try { f.Delete(); files.Remove(f); } catch (Exception) { }
|
||||
}
|
||||
|
||||
// 500MB 초과 시 오래된 파일부터 삭제
|
||||
var totalSize = files.Sum(f => f.Length);
|
||||
while (totalSize > MaxCacheSizeBytes && files.Count > 0)
|
||||
{
|
||||
var oldest = files[0];
|
||||
totalSize -= oldest.Length;
|
||||
try { oldest.Delete(); } catch (Exception) { }
|
||||
files.RemoveAt(0);
|
||||
}
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
LogService.Warn($"이미지 캐시 정리 실패: {ex.Message}");
|
||||
}
|
||||
}
|
||||
|
||||
private static BitmapSource CreateThumbnail(BitmapSource src, int maxWidth)
|
||||
{
|
||||
if (src.PixelWidth <= maxWidth) return src;
|
||||
var scale = (double)maxWidth / src.PixelWidth;
|
||||
return new TransformedBitmap(src, new System.Windows.Media.ScaleTransform(scale, scale));
|
||||
}
|
||||
|
||||
private static string? ImageToBase64(BitmapSource? img)
|
||||
{
|
||||
if (img == null) return null;
|
||||
try
|
||||
{
|
||||
var encoder = new PngBitmapEncoder();
|
||||
encoder.Frames.Add(BitmapFrame.Create(img));
|
||||
using var ms = new MemoryStream();
|
||||
encoder.Save(ms);
|
||||
return Convert.ToBase64String(ms.ToArray());
|
||||
}
|
||||
catch (Exception) { return null; }
|
||||
}
|
||||
|
||||
private static BitmapSource? Base64ToImage(string? base64)
|
||||
{
|
||||
if (string.IsNullOrEmpty(base64)) return null;
|
||||
try
|
||||
{
|
||||
var bytes = Convert.FromBase64String(base64);
|
||||
using var ms = new MemoryStream(bytes);
|
||||
var decoder = BitmapDecoder.Create(ms, BitmapCreateOptions.None, BitmapCacheOption.OnLoad);
|
||||
return decoder.Frames[0];
|
||||
}
|
||||
catch (Exception) { return null; }
|
||||
}
|
||||
|
||||
// ─── P/Invoke ──────────────────────────────────────────────────────────
|
||||
|
||||
[DllImport("user32.dll", SetLastError = true)]
|
||||
|
||||
204
src/AxCopilot/Services/CodeIndexService.Search.cs
Normal file
204
src/AxCopilot/Services/CodeIndexService.Search.cs
Normal file
@@ -0,0 +1,204 @@
|
||||
using System.IO;
|
||||
using System.Text;
|
||||
using System.Text.RegularExpressions;
|
||||
using Microsoft.Data.Sqlite;
|
||||
|
||||
namespace AxCopilot.Services;
|
||||
|
||||
public partial class CodeIndexService
|
||||
{
|
||||
// ── 검색 + TF-IDF + Dispose ──────────────────────────────────────────
|
||||
|
||||
// ── 검색 ────────────────────────────────────────────────────────────
|
||||
|
||||
/// <summary>시맨틱 검색: 질문과 가장 관련 있는 코드 청크를 반환합니다.</summary>
|
||||
public List<SearchResult> Search(string query, int maxResults = 5)
|
||||
{
|
||||
if (!_indexed || _db == null || _totalDocs == 0)
|
||||
return new();
|
||||
|
||||
var queryTokens = Tokenize(query);
|
||||
if (queryTokens.Count == 0) return new();
|
||||
|
||||
// 쿼리 토큰의 DF 조회
|
||||
var dfMap = new Dictionary<string, int>();
|
||||
foreach (var token in queryTokens.Keys)
|
||||
{
|
||||
using var cmd = _db.CreateCommand();
|
||||
cmd.CommandText = "SELECT df FROM doc_freq WHERE token = @t";
|
||||
cmd.Parameters.AddWithValue("@t", token);
|
||||
var result = cmd.ExecuteScalar();
|
||||
if (result != null) dfMap[token] = Convert.ToInt32(result);
|
||||
}
|
||||
|
||||
// 후보 청크 검색: 쿼리 토큰이 하나라도 포함된 청크만
|
||||
var candidateChunks = new HashSet<int>();
|
||||
foreach (var token in queryTokens.Keys)
|
||||
{
|
||||
using var cmd = _db.CreateCommand();
|
||||
cmd.CommandText = "SELECT DISTINCT chunk_id FROM tokens WHERE token = @t";
|
||||
cmd.Parameters.AddWithValue("@t", token);
|
||||
using var reader = cmd.ExecuteReader();
|
||||
while (reader.Read()) candidateChunks.Add(reader.GetInt32(0));
|
||||
}
|
||||
|
||||
if (candidateChunks.Count == 0) return new();
|
||||
|
||||
// 각 후보 청크의 TF-IDF 유사도 계산
|
||||
var scored = new List<(int ChunkId, double Score)>();
|
||||
|
||||
foreach (var chunkId in candidateChunks)
|
||||
{
|
||||
// 청크의 토큰 TF 로드
|
||||
var docTf = new Dictionary<string, int>(StringComparer.OrdinalIgnoreCase);
|
||||
using (var cmd = _db.CreateCommand())
|
||||
{
|
||||
cmd.CommandText = "SELECT token, tf FROM tokens WHERE chunk_id = @cid";
|
||||
cmd.Parameters.AddWithValue("@cid", chunkId);
|
||||
using var reader = cmd.ExecuteReader();
|
||||
while (reader.Read())
|
||||
docTf[reader.GetString(0)] = reader.GetInt32(1);
|
||||
}
|
||||
|
||||
var score = ComputeTfIdfSimilarity(queryTokens, docTf, dfMap);
|
||||
if (score > 0.01)
|
||||
scored.Add((chunkId, score));
|
||||
}
|
||||
|
||||
// 상위 결과 추출
|
||||
var topChunks = scored
|
||||
.OrderByDescending(s => s.Score)
|
||||
.Take(maxResults)
|
||||
.ToList();
|
||||
|
||||
var results = new List<SearchResult>();
|
||||
foreach (var (chunkId, score) in topChunks)
|
||||
{
|
||||
using var cmd = _db.CreateCommand();
|
||||
cmd.CommandText = """
|
||||
SELECT f.path, c.start_line, c.end_line, c.content
|
||||
FROM chunks c JOIN files f ON c.file_id = f.id
|
||||
WHERE c.id = @cid
|
||||
""";
|
||||
cmd.Parameters.AddWithValue("@cid", chunkId);
|
||||
using var reader = cmd.ExecuteReader();
|
||||
if (reader.Read())
|
||||
{
|
||||
results.Add(new SearchResult
|
||||
{
|
||||
FilePath = reader.GetString(0),
|
||||
StartLine = reader.GetInt32(1),
|
||||
EndLine = reader.GetInt32(2),
|
||||
Score = score,
|
||||
Preview = reader.GetString(3) is { Length: > 200 } s ? s[..200] + "..." : reader.GetString(3),
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
return results;
|
||||
}
|
||||
|
||||
/// <summary>기존 인덱스가 있으면 로드합니다 (앱 재시작 시).</summary>
|
||||
public void TryLoadExisting(string workFolder)
|
||||
{
|
||||
if (string.IsNullOrEmpty(workFolder) || !Directory.Exists(workFolder)) return;
|
||||
|
||||
var dbPath = GetDbPath(workFolder);
|
||||
if (!File.Exists(dbPath)) return;
|
||||
|
||||
EnsureDb(workFolder);
|
||||
_totalDocs = GetTotalChunkCount();
|
||||
_indexed = _totalDocs > 0;
|
||||
|
||||
if (_indexed)
|
||||
LogService.Info($"기존 코드 인덱스 로드: {_totalDocs}개 청크 [{workFolder}]");
|
||||
}
|
||||
|
||||
// ── TF-IDF 계산 ─────────────────────────────────────────────────────
|
||||
|
||||
private double ComputeTfIdfSimilarity(
|
||||
Dictionary<string, int> queryTf,
|
||||
Dictionary<string, int> docTf,
|
||||
Dictionary<string, int> dfMap)
|
||||
{
|
||||
double dotProduct = 0, queryNorm = 0, docNorm = 0;
|
||||
|
||||
foreach (var (token, qtf) in queryTf)
|
||||
{
|
||||
var df = dfMap.GetValueOrDefault(token, 0);
|
||||
var idf = Math.Log(1.0 + _totalDocs / (1.0 + df));
|
||||
var qWeight = qtf * idf;
|
||||
queryNorm += qWeight * qWeight;
|
||||
|
||||
if (docTf.TryGetValue(token, out var dtf))
|
||||
{
|
||||
var dWeight = dtf * idf;
|
||||
dotProduct += qWeight * dWeight;
|
||||
}
|
||||
}
|
||||
|
||||
foreach (var (token, dtf) in docTf)
|
||||
{
|
||||
var df = dfMap.GetValueOrDefault(token, 0);
|
||||
var idf = Math.Log(1.0 + _totalDocs / (1.0 + df));
|
||||
var dWeight = dtf * idf;
|
||||
docNorm += dWeight * dWeight;
|
||||
}
|
||||
|
||||
if (queryNorm == 0 || docNorm == 0) return 0;
|
||||
return dotProduct / (Math.Sqrt(queryNorm) * Math.Sqrt(docNorm));
|
||||
}
|
||||
|
||||
// ── 토큰화 ──────────────────────────────────────────────────────────
|
||||
|
||||
/// <summary>텍스트를 토큰으로 분할하고 빈도를 계산합니다. 스톱워드 제거 포함.</summary>
|
||||
private static Dictionary<string, int> Tokenize(string text)
|
||||
{
|
||||
var tf = new Dictionary<string, int>(StringComparer.OrdinalIgnoreCase);
|
||||
var words = Regex.Split(text, @"[^a-zA-Z0-9가-힣_]+")
|
||||
.SelectMany(SplitCamelCase)
|
||||
.Where(w => w.Length >= 2 && !StopWords.Contains(w));
|
||||
|
||||
foreach (var word in words)
|
||||
{
|
||||
var lower = word.ToLowerInvariant();
|
||||
tf.TryGetValue(lower, out var count);
|
||||
tf[lower] = count + 1;
|
||||
}
|
||||
|
||||
// 바이그램 추가 (구문 검색 품질 향상)
|
||||
var wordList = words.Select(w => w.ToLowerInvariant()).ToList();
|
||||
for (int i = 0; i < wordList.Count - 1; i++)
|
||||
{
|
||||
var bigram = $"{wordList[i]}_{wordList[i + 1]}";
|
||||
tf.TryGetValue(bigram, out var bc);
|
||||
tf[bigram] = bc + 1;
|
||||
}
|
||||
|
||||
return tf;
|
||||
}
|
||||
|
||||
private static IEnumerable<string> SplitCamelCase(string word)
|
||||
{
|
||||
if (string.IsNullOrEmpty(word)) yield break;
|
||||
var sb = new StringBuilder();
|
||||
foreach (var ch in word)
|
||||
{
|
||||
if (char.IsUpper(ch) && sb.Length > 0)
|
||||
{
|
||||
yield return sb.ToString();
|
||||
sb.Clear();
|
||||
}
|
||||
sb.Append(ch);
|
||||
}
|
||||
if (sb.Length > 0) yield return sb.ToString();
|
||||
}
|
||||
|
||||
// ── Dispose ─────────────────────────────────────────────────────────
|
||||
|
||||
public void Dispose()
|
||||
{
|
||||
_db?.Dispose();
|
||||
_db = null;
|
||||
}
|
||||
}
|
||||
@@ -11,7 +11,7 @@ namespace AxCopilot.Services;
|
||||
/// 증분 업데이트와 빠른 재시작을 지원합니다.
|
||||
/// (로컬 전용, 외부 서버 불필요)
|
||||
/// </summary>
|
||||
public class CodeIndexService : IDisposable
|
||||
public partial class CodeIndexService : IDisposable
|
||||
{
|
||||
private static App? CurrentApp => System.Windows.Application.Current as App;
|
||||
|
||||
@@ -371,199 +371,6 @@ public class CodeIndexService : IDisposable
|
||||
cmd.Parameters.AddWithValue("@v", value);
|
||||
cmd.ExecuteNonQuery();
|
||||
}
|
||||
|
||||
// ── 검색 ────────────────────────────────────────────────────────────
|
||||
|
||||
/// <summary>시맨틱 검색: 질문과 가장 관련 있는 코드 청크를 반환합니다.</summary>
|
||||
public List<SearchResult> Search(string query, int maxResults = 5)
|
||||
{
|
||||
if (!_indexed || _db == null || _totalDocs == 0)
|
||||
return new();
|
||||
|
||||
var queryTokens = Tokenize(query);
|
||||
if (queryTokens.Count == 0) return new();
|
||||
|
||||
// 쿼리 토큰의 DF 조회
|
||||
var dfMap = new Dictionary<string, int>();
|
||||
foreach (var token in queryTokens.Keys)
|
||||
{
|
||||
using var cmd = _db.CreateCommand();
|
||||
cmd.CommandText = "SELECT df FROM doc_freq WHERE token = @t";
|
||||
cmd.Parameters.AddWithValue("@t", token);
|
||||
var result = cmd.ExecuteScalar();
|
||||
if (result != null) dfMap[token] = Convert.ToInt32(result);
|
||||
}
|
||||
|
||||
// 후보 청크 검색: 쿼리 토큰이 하나라도 포함된 청크만
|
||||
var candidateChunks = new HashSet<int>();
|
||||
foreach (var token in queryTokens.Keys)
|
||||
{
|
||||
using var cmd = _db.CreateCommand();
|
||||
cmd.CommandText = "SELECT DISTINCT chunk_id FROM tokens WHERE token = @t";
|
||||
cmd.Parameters.AddWithValue("@t", token);
|
||||
using var reader = cmd.ExecuteReader();
|
||||
while (reader.Read()) candidateChunks.Add(reader.GetInt32(0));
|
||||
}
|
||||
|
||||
if (candidateChunks.Count == 0) return new();
|
||||
|
||||
// 각 후보 청크의 TF-IDF 유사도 계산
|
||||
var scored = new List<(int ChunkId, double Score)>();
|
||||
|
||||
foreach (var chunkId in candidateChunks)
|
||||
{
|
||||
// 청크의 토큰 TF 로드
|
||||
var docTf = new Dictionary<string, int>(StringComparer.OrdinalIgnoreCase);
|
||||
using (var cmd = _db.CreateCommand())
|
||||
{
|
||||
cmd.CommandText = "SELECT token, tf FROM tokens WHERE chunk_id = @cid";
|
||||
cmd.Parameters.AddWithValue("@cid", chunkId);
|
||||
using var reader = cmd.ExecuteReader();
|
||||
while (reader.Read())
|
||||
docTf[reader.GetString(0)] = reader.GetInt32(1);
|
||||
}
|
||||
|
||||
var score = ComputeTfIdfSimilarity(queryTokens, docTf, dfMap);
|
||||
if (score > 0.01)
|
||||
scored.Add((chunkId, score));
|
||||
}
|
||||
|
||||
// 상위 결과 추출
|
||||
var topChunks = scored
|
||||
.OrderByDescending(s => s.Score)
|
||||
.Take(maxResults)
|
||||
.ToList();
|
||||
|
||||
var results = new List<SearchResult>();
|
||||
foreach (var (chunkId, score) in topChunks)
|
||||
{
|
||||
using var cmd = _db.CreateCommand();
|
||||
cmd.CommandText = """
|
||||
SELECT f.path, c.start_line, c.end_line, c.content
|
||||
FROM chunks c JOIN files f ON c.file_id = f.id
|
||||
WHERE c.id = @cid
|
||||
""";
|
||||
cmd.Parameters.AddWithValue("@cid", chunkId);
|
||||
using var reader = cmd.ExecuteReader();
|
||||
if (reader.Read())
|
||||
{
|
||||
results.Add(new SearchResult
|
||||
{
|
||||
FilePath = reader.GetString(0),
|
||||
StartLine = reader.GetInt32(1),
|
||||
EndLine = reader.GetInt32(2),
|
||||
Score = score,
|
||||
Preview = reader.GetString(3) is { Length: > 200 } s ? s[..200] + "..." : reader.GetString(3),
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
return results;
|
||||
}
|
||||
|
||||
/// <summary>기존 인덱스가 있으면 로드합니다 (앱 재시작 시).</summary>
|
||||
public void TryLoadExisting(string workFolder)
|
||||
{
|
||||
if (string.IsNullOrEmpty(workFolder) || !Directory.Exists(workFolder)) return;
|
||||
|
||||
var dbPath = GetDbPath(workFolder);
|
||||
if (!File.Exists(dbPath)) return;
|
||||
|
||||
EnsureDb(workFolder);
|
||||
_totalDocs = GetTotalChunkCount();
|
||||
_indexed = _totalDocs > 0;
|
||||
|
||||
if (_indexed)
|
||||
LogService.Info($"기존 코드 인덱스 로드: {_totalDocs}개 청크 [{workFolder}]");
|
||||
}
|
||||
|
||||
// ── TF-IDF 계산 ─────────────────────────────────────────────────────
|
||||
|
||||
private double ComputeTfIdfSimilarity(
|
||||
Dictionary<string, int> queryTf,
|
||||
Dictionary<string, int> docTf,
|
||||
Dictionary<string, int> dfMap)
|
||||
{
|
||||
double dotProduct = 0, queryNorm = 0, docNorm = 0;
|
||||
|
||||
foreach (var (token, qtf) in queryTf)
|
||||
{
|
||||
var df = dfMap.GetValueOrDefault(token, 0);
|
||||
var idf = Math.Log(1.0 + _totalDocs / (1.0 + df));
|
||||
var qWeight = qtf * idf;
|
||||
queryNorm += qWeight * qWeight;
|
||||
|
||||
if (docTf.TryGetValue(token, out var dtf))
|
||||
{
|
||||
var dWeight = dtf * idf;
|
||||
dotProduct += qWeight * dWeight;
|
||||
}
|
||||
}
|
||||
|
||||
foreach (var (token, dtf) in docTf)
|
||||
{
|
||||
var df = dfMap.GetValueOrDefault(token, 0);
|
||||
var idf = Math.Log(1.0 + _totalDocs / (1.0 + df));
|
||||
var dWeight = dtf * idf;
|
||||
docNorm += dWeight * dWeight;
|
||||
}
|
||||
|
||||
if (queryNorm == 0 || docNorm == 0) return 0;
|
||||
return dotProduct / (Math.Sqrt(queryNorm) * Math.Sqrt(docNorm));
|
||||
}
|
||||
|
||||
// ── 토큰화 ──────────────────────────────────────────────────────────
|
||||
|
||||
/// <summary>텍스트를 토큰으로 분할하고 빈도를 계산합니다. 스톱워드 제거 포함.</summary>
|
||||
private static Dictionary<string, int> Tokenize(string text)
|
||||
{
|
||||
var tf = new Dictionary<string, int>(StringComparer.OrdinalIgnoreCase);
|
||||
var words = Regex.Split(text, @"[^a-zA-Z0-9가-힣_]+")
|
||||
.SelectMany(SplitCamelCase)
|
||||
.Where(w => w.Length >= 2 && !StopWords.Contains(w));
|
||||
|
||||
foreach (var word in words)
|
||||
{
|
||||
var lower = word.ToLowerInvariant();
|
||||
tf.TryGetValue(lower, out var count);
|
||||
tf[lower] = count + 1;
|
||||
}
|
||||
|
||||
// 바이그램 추가 (구문 검색 품질 향상)
|
||||
var wordList = words.Select(w => w.ToLowerInvariant()).ToList();
|
||||
for (int i = 0; i < wordList.Count - 1; i++)
|
||||
{
|
||||
var bigram = $"{wordList[i]}_{wordList[i + 1]}";
|
||||
tf.TryGetValue(bigram, out var bc);
|
||||
tf[bigram] = bc + 1;
|
||||
}
|
||||
|
||||
return tf;
|
||||
}
|
||||
|
||||
private static IEnumerable<string> SplitCamelCase(string word)
|
||||
{
|
||||
if (string.IsNullOrEmpty(word)) yield break;
|
||||
var sb = new StringBuilder();
|
||||
foreach (var ch in word)
|
||||
{
|
||||
if (char.IsUpper(ch) && sb.Length > 0)
|
||||
{
|
||||
yield return sb.ToString();
|
||||
sb.Clear();
|
||||
}
|
||||
sb.Append(ch);
|
||||
}
|
||||
if (sb.Length > 0) yield return sb.ToString();
|
||||
}
|
||||
|
||||
// ── Dispose ─────────────────────────────────────────────────────────
|
||||
|
||||
public void Dispose()
|
||||
{
|
||||
_db?.Dispose();
|
||||
_db = null;
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>인덱싱된 코드 청크.</summary>
|
||||
|
||||
166
src/AxCopilot/Services/IndexService.Helpers.cs
Normal file
166
src/AxCopilot/Services/IndexService.Helpers.cs
Normal file
@@ -0,0 +1,166 @@
|
||||
using System.IO;
|
||||
|
||||
namespace AxCopilot.Services;
|
||||
|
||||
public partial class IndexService
|
||||
{
|
||||
// ─── 경로 탐색 헬퍼 + 검색 캐시 ──────────────────────────────────────────
|
||||
|
||||
private static string? FindOfficeApp(string exeName)
|
||||
{
|
||||
var roots = new[]
|
||||
{
|
||||
@"C:\Program Files\Microsoft Office\root\Office16",
|
||||
@"C:\Program Files (x86)\Microsoft Office\root\Office16",
|
||||
@"C:\Program Files\Microsoft Office\Office16",
|
||||
@"C:\Program Files (x86)\Microsoft Office\Office16",
|
||||
};
|
||||
foreach (var root in roots)
|
||||
{
|
||||
var path = Path.Combine(root, exeName);
|
||||
if (File.Exists(path)) return path;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
private static string? FindTeams()
|
||||
{
|
||||
var localApp = Environment.GetFolderPath(Environment.SpecialFolder.LocalApplicationData);
|
||||
var path = Path.Combine(localApp, @"Microsoft\Teams\current\Teams.exe");
|
||||
if (File.Exists(path)) return path;
|
||||
// New Teams (MSIX)
|
||||
var msTeams = Path.Combine(localApp, @"Microsoft\WindowsApps\ms-teams.exe");
|
||||
return File.Exists(msTeams) ? msTeams : null;
|
||||
}
|
||||
|
||||
private static string? FindInPath(string fileName)
|
||||
{
|
||||
var pathEnv = Environment.GetEnvironmentVariable("PATH") ?? "";
|
||||
foreach (var dir in pathEnv.Split(';'))
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(dir)) continue;
|
||||
var full = Path.Combine(dir.Trim(), fileName);
|
||||
if (File.Exists(full)) return full;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
private static string? FindInLocalAppData(string relativePath)
|
||||
{
|
||||
var localApp = Environment.GetFolderPath(Environment.SpecialFolder.LocalApplicationData);
|
||||
var path = Path.Combine(localApp, relativePath);
|
||||
return File.Exists(path) ? path : null;
|
||||
}
|
||||
|
||||
/// <summary>ProgramFiles / ProgramFiles(x86) 두 곳을 탐색합니다.</summary>
|
||||
private static string? FindInProgramFiles(string relativePath)
|
||||
{
|
||||
var pf = Environment.GetFolderPath(Environment.SpecialFolder.ProgramFiles);
|
||||
var pf86 = Environment.GetFolderPath(Environment.SpecialFolder.ProgramFilesX86);
|
||||
var p1 = Path.Combine(pf, relativePath);
|
||||
if (File.Exists(p1)) return p1;
|
||||
var p2 = Path.Combine(pf86, relativePath);
|
||||
return File.Exists(p2) ? p2 : null;
|
||||
}
|
||||
|
||||
/// <summary>AppData\Roaming 경로를 탐색합니다.</summary>
|
||||
private static string? FindInRoaming(string relativePath)
|
||||
{
|
||||
var roaming = Environment.GetFolderPath(Environment.SpecialFolder.ApplicationData);
|
||||
var path = Path.Combine(roaming, relativePath);
|
||||
return File.Exists(path) ? path : null;
|
||||
}
|
||||
|
||||
// ─── 검색 가속 캐시 계산 ──────────────────────────────────────────────────
|
||||
|
||||
/// <summary>빌드 완료 후 전체 항목의 검색 캐시를 한 번에 계산합니다.</summary>
|
||||
private static void ComputeAllSearchCaches(List<IndexEntry> entries)
|
||||
{
|
||||
foreach (var e in entries)
|
||||
ComputeSearchCache(e);
|
||||
}
|
||||
|
||||
/// <summary>항목 1개의 NameLower / NameJamo / NameChosung 캐시를 계산합니다.</summary>
|
||||
private static void ComputeSearchCache(IndexEntry entry)
|
||||
{
|
||||
entry.NameLower = entry.Name.ToLowerInvariant();
|
||||
// 자모 분리 (FuzzyEngine static 메서드 — 동일 어셈블리 internal 접근)
|
||||
entry.NameJamo = AxCopilot.Core.FuzzyEngine.DecomposeToJamo(entry.NameLower);
|
||||
// 초성 문자열 (예: "엑셀" → "ㅇㅅ", "메모장" → "ㅁㅁㅈ")
|
||||
var sb = new System.Text.StringBuilder(entry.NameLower.Length);
|
||||
foreach (var c in entry.NameLower)
|
||||
{
|
||||
var cho = AxCopilot.Core.FuzzyEngine.GetChosung(c);
|
||||
if (cho != '\0') sb.Append(cho);
|
||||
}
|
||||
entry.NameChosung = sb.ToString();
|
||||
}
|
||||
|
||||
/// <summary>인덱싱 속도에 따른 throttle 간격(ms). N개 파일마다 yield.</summary>
|
||||
private static (int batchSize, int delayMs) GetThrottle(string speed) => speed switch
|
||||
{
|
||||
"fast" => (500, 0), // 최대 속도, CPU 양보 없음
|
||||
"slow" => (50, 15), // 50개마다 15ms 양보 → PC 부하 최소
|
||||
_ => (150, 5), // normal: 150개마다 5ms → 적정 균형
|
||||
};
|
||||
|
||||
private static async Task ScanDirectoryAsync(string dir, List<IndexEntry> entries,
|
||||
HashSet<string> allowedExts, string indexSpeed, CancellationToken ct)
|
||||
{
|
||||
var (batchSize, delayMs) = GetThrottle(indexSpeed);
|
||||
|
||||
await Task.Run(async () =>
|
||||
{
|
||||
try
|
||||
{
|
||||
int count = 0;
|
||||
|
||||
// 파일 인덱싱 (확장자 필터 적용)
|
||||
foreach (var file in Directory.EnumerateFiles(dir, "*.*", SearchOption.AllDirectories))
|
||||
{
|
||||
ct.ThrowIfCancellationRequested();
|
||||
var ext = Path.GetExtension(file).ToLowerInvariant();
|
||||
if (allowedExts.Count > 0 && !allowedExts.Contains(ext)) continue;
|
||||
var name = Path.GetFileNameWithoutExtension(file);
|
||||
if (string.IsNullOrEmpty(name)) continue;
|
||||
var type = ext switch
|
||||
{
|
||||
".exe" => IndexEntryType.App,
|
||||
".lnk" or ".url" => IndexEntryType.File,
|
||||
_ => IndexEntryType.File
|
||||
};
|
||||
entries.Add(new IndexEntry
|
||||
{
|
||||
Name = name,
|
||||
DisplayName = ext is ".exe" or ".lnk" or ".url" ? name : name + ext,
|
||||
Path = file,
|
||||
Type = type
|
||||
});
|
||||
|
||||
// 속도 조절: batchSize개마다 CPU 양보
|
||||
if (delayMs > 0 && ++count % batchSize == 0)
|
||||
await Task.Delay(delayMs, ct);
|
||||
}
|
||||
|
||||
// 폴더 인덱싱 (1단계 하위 폴더)
|
||||
foreach (var subDir in Directory.EnumerateDirectories(dir, "*", SearchOption.TopDirectoryOnly))
|
||||
{
|
||||
ct.ThrowIfCancellationRequested();
|
||||
var name = Path.GetFileName(subDir);
|
||||
if (name.StartsWith(".")) continue;
|
||||
entries.Add(new IndexEntry
|
||||
{
|
||||
Name = name,
|
||||
DisplayName = name,
|
||||
Path = subDir,
|
||||
Type = IndexEntryType.Folder
|
||||
});
|
||||
}
|
||||
}
|
||||
catch (UnauthorizedAccessException ex)
|
||||
{
|
||||
LogService.Warn($"폴더 접근 불가 (건너뜀): {dir} - {ex.Message}");
|
||||
}
|
||||
}, ct);
|
||||
}
|
||||
}
|
||||
@@ -8,7 +8,7 @@ namespace AxCopilot.Services;
|
||||
/// 파일/앱 인덱싱 서비스. Fuzzy 검색의 데이터 소스를 관리합니다.
|
||||
/// FileSystemWatcher로 변경 시 자동 재빌드(3초 디바운스)합니다.
|
||||
/// </summary>
|
||||
public class IndexService : IDisposable
|
||||
public partial class IndexService : IDisposable
|
||||
{
|
||||
private readonly SettingsService _settings;
|
||||
private List<IndexEntry> _index = new();
|
||||
@@ -387,163 +387,6 @@ public class IndexService : IDisposable
|
||||
}
|
||||
}
|
||||
|
||||
private static string? FindOfficeApp(string exeName)
|
||||
{
|
||||
var roots = new[]
|
||||
{
|
||||
@"C:\Program Files\Microsoft Office\root\Office16",
|
||||
@"C:\Program Files (x86)\Microsoft Office\root\Office16",
|
||||
@"C:\Program Files\Microsoft Office\Office16",
|
||||
@"C:\Program Files (x86)\Microsoft Office\Office16",
|
||||
};
|
||||
foreach (var root in roots)
|
||||
{
|
||||
var path = Path.Combine(root, exeName);
|
||||
if (File.Exists(path)) return path;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
private static string? FindTeams()
|
||||
{
|
||||
var localApp = Environment.GetFolderPath(Environment.SpecialFolder.LocalApplicationData);
|
||||
var path = Path.Combine(localApp, @"Microsoft\Teams\current\Teams.exe");
|
||||
if (File.Exists(path)) return path;
|
||||
// New Teams (MSIX)
|
||||
var msTeams = Path.Combine(localApp, @"Microsoft\WindowsApps\ms-teams.exe");
|
||||
return File.Exists(msTeams) ? msTeams : null;
|
||||
}
|
||||
|
||||
private static string? FindInPath(string fileName)
|
||||
{
|
||||
var pathEnv = Environment.GetEnvironmentVariable("PATH") ?? "";
|
||||
foreach (var dir in pathEnv.Split(';'))
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(dir)) continue;
|
||||
var full = Path.Combine(dir.Trim(), fileName);
|
||||
if (File.Exists(full)) return full;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
private static string? FindInLocalAppData(string relativePath)
|
||||
{
|
||||
var localApp = Environment.GetFolderPath(Environment.SpecialFolder.LocalApplicationData);
|
||||
var path = Path.Combine(localApp, relativePath);
|
||||
return File.Exists(path) ? path : null;
|
||||
}
|
||||
|
||||
/// <summary>ProgramFiles / ProgramFiles(x86) 두 곳을 탐색합니다.</summary>
|
||||
private static string? FindInProgramFiles(string relativePath)
|
||||
{
|
||||
var pf = Environment.GetFolderPath(Environment.SpecialFolder.ProgramFiles);
|
||||
var pf86 = Environment.GetFolderPath(Environment.SpecialFolder.ProgramFilesX86);
|
||||
var p1 = Path.Combine(pf, relativePath);
|
||||
if (File.Exists(p1)) return p1;
|
||||
var p2 = Path.Combine(pf86, relativePath);
|
||||
return File.Exists(p2) ? p2 : null;
|
||||
}
|
||||
|
||||
/// <summary>AppData\Roaming 경로를 탐색합니다.</summary>
|
||||
private static string? FindInRoaming(string relativePath)
|
||||
{
|
||||
var roaming = Environment.GetFolderPath(Environment.SpecialFolder.ApplicationData);
|
||||
var path = Path.Combine(roaming, relativePath);
|
||||
return File.Exists(path) ? path : null;
|
||||
}
|
||||
|
||||
// ─── 검색 가속 캐시 계산 ──────────────────────────────────────────────────
|
||||
|
||||
/// <summary>빌드 완료 후 전체 항목의 검색 캐시를 한 번에 계산합니다.</summary>
|
||||
private static void ComputeAllSearchCaches(List<IndexEntry> entries)
|
||||
{
|
||||
foreach (var e in entries)
|
||||
ComputeSearchCache(e);
|
||||
}
|
||||
|
||||
/// <summary>항목 1개의 NameLower / NameJamo / NameChosung 캐시를 계산합니다.</summary>
|
||||
private static void ComputeSearchCache(IndexEntry entry)
|
||||
{
|
||||
entry.NameLower = entry.Name.ToLowerInvariant();
|
||||
// 자모 분리 (FuzzyEngine static 메서드 — 동일 어셈블리 internal 접근)
|
||||
entry.NameJamo = AxCopilot.Core.FuzzyEngine.DecomposeToJamo(entry.NameLower);
|
||||
// 초성 문자열 (예: "엑셀" → "ㅇㅅ", "메모장" → "ㅁㅁㅈ")
|
||||
var sb = new System.Text.StringBuilder(entry.NameLower.Length);
|
||||
foreach (var c in entry.NameLower)
|
||||
{
|
||||
var cho = AxCopilot.Core.FuzzyEngine.GetChosung(c);
|
||||
if (cho != '\0') sb.Append(cho);
|
||||
}
|
||||
entry.NameChosung = sb.ToString();
|
||||
}
|
||||
|
||||
/// <summary>인덱싱 속도에 따른 throttle 간격(ms). N개 파일마다 yield.</summary>
|
||||
private static (int batchSize, int delayMs) GetThrottle(string speed) => speed switch
|
||||
{
|
||||
"fast" => (500, 0), // 최대 속도, CPU 양보 없음
|
||||
"slow" => (50, 15), // 50개마다 15ms 양보 → PC 부하 최소
|
||||
_ => (150, 5), // normal: 150개마다 5ms → 적정 균형
|
||||
};
|
||||
|
||||
private static async Task ScanDirectoryAsync(string dir, List<IndexEntry> entries,
|
||||
HashSet<string> allowedExts, string indexSpeed, CancellationToken ct)
|
||||
{
|
||||
var (batchSize, delayMs) = GetThrottle(indexSpeed);
|
||||
|
||||
await Task.Run(async () =>
|
||||
{
|
||||
try
|
||||
{
|
||||
int count = 0;
|
||||
|
||||
// 파일 인덱싱 (확장자 필터 적용)
|
||||
foreach (var file in Directory.EnumerateFiles(dir, "*.*", SearchOption.AllDirectories))
|
||||
{
|
||||
ct.ThrowIfCancellationRequested();
|
||||
var ext = Path.GetExtension(file).ToLowerInvariant();
|
||||
if (allowedExts.Count > 0 && !allowedExts.Contains(ext)) continue;
|
||||
var name = Path.GetFileNameWithoutExtension(file);
|
||||
if (string.IsNullOrEmpty(name)) continue;
|
||||
var type = ext switch
|
||||
{
|
||||
".exe" => IndexEntryType.App,
|
||||
".lnk" or ".url" => IndexEntryType.File,
|
||||
_ => IndexEntryType.File
|
||||
};
|
||||
entries.Add(new IndexEntry
|
||||
{
|
||||
Name = name,
|
||||
DisplayName = ext is ".exe" or ".lnk" or ".url" ? name : name + ext,
|
||||
Path = file,
|
||||
Type = type
|
||||
});
|
||||
|
||||
// 속도 조절: batchSize개마다 CPU 양보
|
||||
if (delayMs > 0 && ++count % batchSize == 0)
|
||||
await Task.Delay(delayMs, ct);
|
||||
}
|
||||
|
||||
// 폴더 인덱싱 (1단계 하위 폴더)
|
||||
foreach (var subDir in Directory.EnumerateDirectories(dir, "*", SearchOption.TopDirectoryOnly))
|
||||
{
|
||||
ct.ThrowIfCancellationRequested();
|
||||
var name = Path.GetFileName(subDir);
|
||||
if (name.StartsWith(".")) continue;
|
||||
entries.Add(new IndexEntry
|
||||
{
|
||||
Name = name,
|
||||
DisplayName = name,
|
||||
Path = subDir,
|
||||
Type = IndexEntryType.Folder
|
||||
});
|
||||
}
|
||||
}
|
||||
catch (UnauthorizedAccessException ex)
|
||||
{
|
||||
LogService.Warn($"폴더 접근 불가 (건너뜀): {dir} - {ex.Message}");
|
||||
}
|
||||
}, ct);
|
||||
}
|
||||
}
|
||||
|
||||
public class IndexEntry
|
||||
|
||||
@@ -451,143 +451,4 @@ public partial class LauncherWindow
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ─── 단축키 도움말 ────────────────────────────────────────────────────────
|
||||
|
||||
/// <summary>단축키 도움말 팝업</summary>
|
||||
private void ShowShortcutHelp()
|
||||
{
|
||||
var lines = new[]
|
||||
{
|
||||
"[ 전역 ]",
|
||||
"Alt+Space AX Commander 열기/닫기",
|
||||
"",
|
||||
"[ 탐색 ]",
|
||||
"↑ / ↓ 결과 이동",
|
||||
"Enter 선택 실행",
|
||||
"Tab 자동완성",
|
||||
"→ 액션 모드",
|
||||
"Escape 닫기 / 뒤로",
|
||||
"",
|
||||
"[ 기능 ]",
|
||||
"F1 도움말",
|
||||
"F2 파일 이름 바꾸기",
|
||||
"F5 인덱스 새로 고침",
|
||||
"Delete 항목 제거",
|
||||
"Ctrl+, 설정",
|
||||
"Ctrl+L 입력 초기화",
|
||||
"Ctrl+C 이름 복사",
|
||||
"Ctrl+H 클립보드 히스토리",
|
||||
"Ctrl+R 최근 실행",
|
||||
"Ctrl+B 즐겨찾기",
|
||||
"Ctrl+K 이 도움말",
|
||||
"Ctrl+1~9 N번째 실행",
|
||||
"Ctrl+Shift+C 경로 복사",
|
||||
"Ctrl+Shift+E 탐색기에서 열기",
|
||||
"Ctrl+Enter 관리자 실행",
|
||||
"Alt+Enter 속성 보기",
|
||||
"Shift+Enter 대형 텍스트",
|
||||
};
|
||||
|
||||
CustomMessageBox.Show(
|
||||
string.Join("\n", lines),
|
||||
"AX Commander — 단축키 도움말",
|
||||
MessageBoxButton.OK,
|
||||
MessageBoxImage.Information);
|
||||
}
|
||||
|
||||
// ─── 토스트 알림 ──────────────────────────────────────────────────────────
|
||||
|
||||
/// <summary>오버레이 토스트 표시 (페이드인 → 2초 대기 → 페이드아웃)</summary>
|
||||
private void ShowToast(string message, string icon = "\uE73E")
|
||||
{
|
||||
ToastText.Text = message;
|
||||
ToastIcon.Text = icon;
|
||||
ToastOverlay.Visibility = Visibility.Visible;
|
||||
ToastOverlay.Opacity = 0;
|
||||
|
||||
// 페이드인
|
||||
var fadeIn = (System.Windows.Media.Animation.Storyboard)FindResource("ToastFadeIn");
|
||||
fadeIn.Begin(this);
|
||||
|
||||
_indexStatusTimer?.Stop();
|
||||
_indexStatusTimer = new System.Windows.Threading.DispatcherTimer
|
||||
{
|
||||
Interval = TimeSpan.FromSeconds(2)
|
||||
};
|
||||
_indexStatusTimer.Tick += (_, _) =>
|
||||
{
|
||||
_indexStatusTimer.Stop();
|
||||
// 페이드아웃 후 Collapsed
|
||||
var fadeOut = (System.Windows.Media.Animation.Storyboard)FindResource("ToastFadeOut");
|
||||
EventHandler? onCompleted = null;
|
||||
onCompleted = (__, ___) =>
|
||||
{
|
||||
fadeOut.Completed -= onCompleted;
|
||||
ToastOverlay.Visibility = Visibility.Collapsed;
|
||||
};
|
||||
fadeOut.Completed += onCompleted;
|
||||
fadeOut.Begin(this);
|
||||
};
|
||||
_indexStatusTimer.Start();
|
||||
}
|
||||
|
||||
// ─── 특수 액션 처리 ───────────────────────────────────────────────────────
|
||||
|
||||
/// <summary>
|
||||
/// 액션 모드에서 특수 처리가 필요한 동작(삭제/이름변경)을 처리합니다.
|
||||
/// 처리되면 true 반환 → ExecuteSelectedAsync 호출 생략.
|
||||
/// </summary>
|
||||
private bool TryHandleSpecialAction()
|
||||
{
|
||||
if (_vm.SelectedItem?.Data is not AxCopilot.ViewModels.FileActionData actionData)
|
||||
return false;
|
||||
|
||||
switch (actionData.Action)
|
||||
{
|
||||
case AxCopilot.ViewModels.FileAction.DeleteToRecycleBin:
|
||||
{
|
||||
var path = actionData.Path;
|
||||
var name = System.IO.Path.GetFileName(path);
|
||||
var r = CustomMessageBox.Show(
|
||||
$"'{name}'\n\n이 항목을 휴지통으로 보내겠습니까?",
|
||||
"AX Copilot — 삭제 확인",
|
||||
MessageBoxButton.OKCancel,
|
||||
MessageBoxImage.Warning);
|
||||
|
||||
if (r == MessageBoxResult.OK)
|
||||
{
|
||||
try
|
||||
{
|
||||
SendToRecycleBin(path);
|
||||
_vm.ExitActionMode();
|
||||
ShowToast("휴지통으로 이동됨", "\uE74D");
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
CustomMessageBox.Show($"삭제 실패: {ex.Message}", "오류",
|
||||
MessageBoxButton.OK, MessageBoxImage.Error);
|
||||
}
|
||||
}
|
||||
else
|
||||
{
|
||||
_vm.ExitActionMode();
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
case AxCopilot.ViewModels.FileAction.Rename:
|
||||
{
|
||||
var path = actionData.Path;
|
||||
_vm.ExitActionMode();
|
||||
_vm.InputText = $"rename {path}";
|
||||
Dispatcher.BeginInvoke(() => { InputBox.CaretIndex = InputBox.Text.Length; },
|
||||
System.Windows.Threading.DispatcherPriority.Input);
|
||||
return true;
|
||||
}
|
||||
|
||||
default:
|
||||
return false;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
148
src/AxCopilot/Views/LauncherWindow.ShortcutHelp.cs
Normal file
148
src/AxCopilot/Views/LauncherWindow.ShortcutHelp.cs
Normal file
@@ -0,0 +1,148 @@
|
||||
using System.Windows;
|
||||
using System.Windows.Input;
|
||||
using AxCopilot.Services;
|
||||
using AxCopilot.ViewModels;
|
||||
|
||||
namespace AxCopilot.Views;
|
||||
|
||||
public partial class LauncherWindow
|
||||
{
|
||||
// ─── 단축키 도움말 + 토스트 + 특수 액션 ────────────────────────────────────
|
||||
|
||||
/// <summary>단축키 도움말 팝업</summary>
|
||||
private void ShowShortcutHelp()
|
||||
{
|
||||
var lines = new[]
|
||||
{
|
||||
"[ 전역 ]",
|
||||
"Alt+Space AX Commander 열기/닫기",
|
||||
"",
|
||||
"[ 탐색 ]",
|
||||
"↑ / ↓ 결과 이동",
|
||||
"Enter 선택 실행",
|
||||
"Tab 자동완성",
|
||||
"→ 액션 모드",
|
||||
"Escape 닫기 / 뒤로",
|
||||
"",
|
||||
"[ 기능 ]",
|
||||
"F1 도움말",
|
||||
"F2 파일 이름 바꾸기",
|
||||
"F5 인덱스 새로 고침",
|
||||
"Delete 항목 제거",
|
||||
"Ctrl+, 설정",
|
||||
"Ctrl+L 입력 초기화",
|
||||
"Ctrl+C 이름 복사",
|
||||
"Ctrl+H 클립보드 히스토리",
|
||||
"Ctrl+R 최근 실행",
|
||||
"Ctrl+B 즐겨찾기",
|
||||
"Ctrl+K 이 도움말",
|
||||
"Ctrl+1~9 N번째 실행",
|
||||
"Ctrl+Shift+C 경로 복사",
|
||||
"Ctrl+Shift+E 탐색기에서 열기",
|
||||
"Ctrl+Enter 관리자 실행",
|
||||
"Alt+Enter 속성 보기",
|
||||
"Shift+Enter 대형 텍스트",
|
||||
};
|
||||
|
||||
CustomMessageBox.Show(
|
||||
string.Join("\n", lines),
|
||||
"AX Commander — 단축키 도움말",
|
||||
MessageBoxButton.OK,
|
||||
MessageBoxImage.Information);
|
||||
}
|
||||
|
||||
// ─── 토스트 알림 ──────────────────────────────────────────────────────────
|
||||
|
||||
/// <summary>오버레이 토스트 표시 (페이드인 → 2초 대기 → 페이드아웃)</summary>
|
||||
private void ShowToast(string message, string icon = "\uE73E")
|
||||
{
|
||||
ToastText.Text = message;
|
||||
ToastIcon.Text = icon;
|
||||
ToastOverlay.Visibility = Visibility.Visible;
|
||||
ToastOverlay.Opacity = 0;
|
||||
|
||||
// 페이드인
|
||||
var fadeIn = (System.Windows.Media.Animation.Storyboard)FindResource("ToastFadeIn");
|
||||
fadeIn.Begin(this);
|
||||
|
||||
_indexStatusTimer?.Stop();
|
||||
_indexStatusTimer = new System.Windows.Threading.DispatcherTimer
|
||||
{
|
||||
Interval = TimeSpan.FromSeconds(2)
|
||||
};
|
||||
_indexStatusTimer.Tick += (_, _) =>
|
||||
{
|
||||
_indexStatusTimer.Stop();
|
||||
// 페이드아웃 후 Collapsed
|
||||
var fadeOut = (System.Windows.Media.Animation.Storyboard)FindResource("ToastFadeOut");
|
||||
EventHandler? onCompleted = null;
|
||||
onCompleted = (__, ___) =>
|
||||
{
|
||||
fadeOut.Completed -= onCompleted;
|
||||
ToastOverlay.Visibility = Visibility.Collapsed;
|
||||
};
|
||||
fadeOut.Completed += onCompleted;
|
||||
fadeOut.Begin(this);
|
||||
};
|
||||
_indexStatusTimer.Start();
|
||||
}
|
||||
|
||||
// ─── 특수 액션 처리 ───────────────────────────────────────────────────────
|
||||
|
||||
/// <summary>
|
||||
/// 액션 모드에서 특수 처리가 필요한 동작(삭제/이름변경)을 처리합니다.
|
||||
/// 처리되면 true 반환 → ExecuteSelectedAsync 호출 생략.
|
||||
/// </summary>
|
||||
private bool TryHandleSpecialAction()
|
||||
{
|
||||
if (_vm.SelectedItem?.Data is not AxCopilot.ViewModels.FileActionData actionData)
|
||||
return false;
|
||||
|
||||
switch (actionData.Action)
|
||||
{
|
||||
case AxCopilot.ViewModels.FileAction.DeleteToRecycleBin:
|
||||
{
|
||||
var path = actionData.Path;
|
||||
var name = System.IO.Path.GetFileName(path);
|
||||
var r = CustomMessageBox.Show(
|
||||
$"'{name}'\n\n이 항목을 휴지통으로 보내겠습니까?",
|
||||
"AX Copilot — 삭제 확인",
|
||||
MessageBoxButton.OKCancel,
|
||||
MessageBoxImage.Warning);
|
||||
|
||||
if (r == MessageBoxResult.OK)
|
||||
{
|
||||
try
|
||||
{
|
||||
SendToRecycleBin(path);
|
||||
_vm.ExitActionMode();
|
||||
ShowToast("휴지통으로 이동됨", "\uE74D");
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
CustomMessageBox.Show($"삭제 실패: {ex.Message}", "오류",
|
||||
MessageBoxButton.OK, MessageBoxImage.Error);
|
||||
}
|
||||
}
|
||||
else
|
||||
{
|
||||
_vm.ExitActionMode();
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
case AxCopilot.ViewModels.FileAction.Rename:
|
||||
{
|
||||
var path = actionData.Path;
|
||||
_vm.ExitActionMode();
|
||||
_vm.InputText = $"rename {path}";
|
||||
Dispatcher.BeginInvoke(() => { InputBox.CaretIndex = InputBox.Text.Length; },
|
||||
System.Windows.Threading.DispatcherPriority.Input);
|
||||
return true;
|
||||
}
|
||||
|
||||
default:
|
||||
return false;
|
||||
}
|
||||
}
|
||||
}
|
||||
381
src/AxCopilot/Views/SettingsWindow.SkillListPanel.cs
Normal file
381
src/AxCopilot/Views/SettingsWindow.SkillListPanel.cs
Normal file
@@ -0,0 +1,381 @@
|
||||
using System.Linq;
|
||||
using System.Windows;
|
||||
using System.Windows.Controls;
|
||||
using System.Windows.Input;
|
||||
using System.Windows.Media;
|
||||
using AxCopilot.Services;
|
||||
using AxCopilot.ViewModels;
|
||||
|
||||
namespace AxCopilot.Views;
|
||||
|
||||
public partial class SettingsWindow
|
||||
{
|
||||
// ─── 스킬 목록 패널 ─────────────────────────────────────────────────────
|
||||
|
||||
private void BuildSkillListSection(ref int insertIdx)
|
||||
{
|
||||
if (AgentEtcContent == null) return;
|
||||
|
||||
var skills = Services.Agent.SkillService.Skills;
|
||||
if (skills.Count == 0) return;
|
||||
|
||||
var accentColor = ThemeResourceHelper.HexColor("#4B5EFC");
|
||||
var subtleText = ThemeResourceHelper.HexColor("#6666AA");
|
||||
|
||||
// 스킬 콘텐츠를 모아서 접기/열기 섹션에 넣기
|
||||
var skillItems = new List<UIElement>();
|
||||
|
||||
// 설명
|
||||
skillItems.Add(new TextBlock
|
||||
{
|
||||
Text = "/ 명령으로 호출할 수 있는 스킬 목록입니다. 앱 내장 + 사용자 추가 스킬이 포함됩니다.\n" +
|
||||
"(스킬은 사용자가 직접 /명령어를 입력해야 실행됩니다. LLM이 자동 호출하지 않습니다.)",
|
||||
FontSize = 11,
|
||||
Foreground = new SolidColorBrush(subtleText),
|
||||
Margin = new Thickness(2, 0, 0, 10),
|
||||
TextWrapping = TextWrapping.Wrap,
|
||||
});
|
||||
|
||||
// 내장 스킬 / 고급 스킬 분류
|
||||
var builtIn = skills.Where(s => string.IsNullOrEmpty(s.Requires)).ToList();
|
||||
var advanced = skills.Where(s => !string.IsNullOrEmpty(s.Requires)).ToList();
|
||||
|
||||
// 내장 스킬 카드
|
||||
if (builtIn.Count > 0)
|
||||
{
|
||||
var card = CreateSkillGroupCard("내장 스킬", "\uE768", "#34D399", builtIn);
|
||||
skillItems.Add(card);
|
||||
}
|
||||
|
||||
// 고급 스킬 (런타임 의존) 카드
|
||||
if (advanced.Count > 0)
|
||||
{
|
||||
var card = CreateSkillGroupCard("고급 스킬 (런타임 필요)", "\uE9D9", "#A78BFA", advanced);
|
||||
skillItems.Add(card);
|
||||
}
|
||||
|
||||
// ── 스킬 가져오기/내보내기 버튼 ──
|
||||
var btnPanel = new StackPanel
|
||||
{
|
||||
Orientation = Orientation.Horizontal,
|
||||
Margin = new Thickness(2, 4, 0, 10),
|
||||
};
|
||||
|
||||
// 가져오기 버튼
|
||||
var importBtn = new Border
|
||||
{
|
||||
Background = ThemeResourceHelper.HexBrush("#4B5EFC"),
|
||||
CornerRadius = new CornerRadius(8),
|
||||
Padding = new Thickness(14, 7, 14, 7),
|
||||
Margin = new Thickness(0, 0, 8, 0),
|
||||
Cursor = Cursors.Hand,
|
||||
};
|
||||
var importContent = new StackPanel { Orientation = Orientation.Horizontal };
|
||||
importContent.Children.Add(new TextBlock
|
||||
{
|
||||
Text = "\uE8B5",
|
||||
FontFamily = ThemeResourceHelper.SegoeMdl2,
|
||||
FontSize = 12,
|
||||
Foreground = Brushes.White,
|
||||
VerticalAlignment = VerticalAlignment.Center,
|
||||
Margin = new Thickness(0, 0, 6, 0),
|
||||
});
|
||||
importContent.Children.Add(new TextBlock
|
||||
{
|
||||
Text = "스킬 가져오기 (.zip)",
|
||||
FontSize = 12,
|
||||
FontWeight = FontWeights.SemiBold,
|
||||
Foreground = Brushes.White,
|
||||
VerticalAlignment = VerticalAlignment.Center,
|
||||
});
|
||||
importBtn.Child = importContent;
|
||||
importBtn.MouseLeftButtonUp += SkillImport_Click;
|
||||
importBtn.MouseEnter += (s, _) => ((Border)s).Opacity = 0.85;
|
||||
importBtn.MouseLeave += (s, _) => ((Border)s).Opacity = 1.0;
|
||||
btnPanel.Children.Add(importBtn);
|
||||
|
||||
// 내보내기 버튼
|
||||
var exportBtn = new Border
|
||||
{
|
||||
Background = ThemeResourceHelper.HexBrush("#F0F1F5"),
|
||||
CornerRadius = new CornerRadius(8),
|
||||
Padding = new Thickness(14, 7, 14, 7),
|
||||
Cursor = Cursors.Hand,
|
||||
};
|
||||
var exportContent = new StackPanel { Orientation = Orientation.Horizontal };
|
||||
exportContent.Children.Add(new TextBlock
|
||||
{
|
||||
Text = "\uEDE1",
|
||||
FontFamily = ThemeResourceHelper.SegoeMdl2,
|
||||
FontSize = 12,
|
||||
Foreground = ThemeResourceHelper.HexBrush("#555"),
|
||||
VerticalAlignment = VerticalAlignment.Center,
|
||||
Margin = new Thickness(0, 0, 6, 0),
|
||||
});
|
||||
exportContent.Children.Add(new TextBlock
|
||||
{
|
||||
Text = "스킬 내보내기",
|
||||
FontSize = 12,
|
||||
FontWeight = FontWeights.SemiBold,
|
||||
Foreground = ThemeResourceHelper.HexBrush("#444"),
|
||||
VerticalAlignment = VerticalAlignment.Center,
|
||||
});
|
||||
exportBtn.Child = exportContent;
|
||||
exportBtn.MouseLeftButtonUp += SkillExport_Click;
|
||||
exportBtn.MouseEnter += (s, _) => ((Border)s).Opacity = 0.85;
|
||||
exportBtn.MouseLeave += (s, _) => ((Border)s).Opacity = 1.0;
|
||||
btnPanel.Children.Add(exportBtn);
|
||||
|
||||
skillItems.Add(btnPanel);
|
||||
|
||||
// ── 갤러리/통계 링크 버튼 ──
|
||||
var linkPanel = new StackPanel
|
||||
{
|
||||
Orientation = Orientation.Horizontal,
|
||||
Margin = new Thickness(2, 0, 0, 12),
|
||||
};
|
||||
|
||||
var galleryBtn = new Border
|
||||
{
|
||||
CornerRadius = new CornerRadius(8),
|
||||
Padding = new Thickness(14, 7, 14, 7),
|
||||
Margin = new Thickness(0, 0, 8, 0),
|
||||
Background = new SolidColorBrush(Color.FromArgb(0x18, 0x4B, 0x5E, 0xFC)),
|
||||
Cursor = Cursors.Hand,
|
||||
};
|
||||
var galleryContent = new StackPanel { Orientation = Orientation.Horizontal };
|
||||
galleryContent.Children.Add(new TextBlock
|
||||
{
|
||||
Text = "\uE768",
|
||||
FontFamily = ThemeResourceHelper.SegoeMdl2,
|
||||
FontSize = 12,
|
||||
Foreground = ThemeResourceHelper.HexBrush("#4B5EFC"),
|
||||
VerticalAlignment = VerticalAlignment.Center,
|
||||
Margin = new Thickness(0, 0, 6, 0),
|
||||
});
|
||||
galleryContent.Children.Add(new TextBlock
|
||||
{
|
||||
Text = "스킬 갤러리 열기",
|
||||
FontSize = 12,
|
||||
FontWeight = FontWeights.SemiBold,
|
||||
Foreground = ThemeResourceHelper.HexBrush("#4B5EFC"),
|
||||
});
|
||||
galleryBtn.Child = galleryContent;
|
||||
galleryBtn.MouseLeftButtonUp += (_, _) =>
|
||||
{
|
||||
var win = new SkillGalleryWindow();
|
||||
win.Owner = Window.GetWindow(this);
|
||||
win.ShowDialog();
|
||||
};
|
||||
galleryBtn.MouseEnter += (s, _) => ((Border)s).Opacity = 0.8;
|
||||
galleryBtn.MouseLeave += (s, _) => ((Border)s).Opacity = 1.0;
|
||||
linkPanel.Children.Add(galleryBtn);
|
||||
|
||||
var statsBtn = new Border
|
||||
{
|
||||
CornerRadius = new CornerRadius(8),
|
||||
Padding = new Thickness(14, 7, 14, 7),
|
||||
Background = new SolidColorBrush(Color.FromArgb(0x18, 0xA7, 0x8B, 0xFA)),
|
||||
Cursor = Cursors.Hand,
|
||||
};
|
||||
var statsContent = new StackPanel { Orientation = Orientation.Horizontal };
|
||||
statsContent.Children.Add(new TextBlock
|
||||
{
|
||||
Text = "\uE9D9",
|
||||
FontFamily = ThemeResourceHelper.SegoeMdl2,
|
||||
FontSize = 12,
|
||||
Foreground = ThemeResourceHelper.HexBrush("#A78BFA"),
|
||||
VerticalAlignment = VerticalAlignment.Center,
|
||||
Margin = new Thickness(0, 0, 6, 0),
|
||||
});
|
||||
statsContent.Children.Add(new TextBlock
|
||||
{
|
||||
Text = "실행 통계 보기",
|
||||
FontSize = 12,
|
||||
FontWeight = FontWeights.SemiBold,
|
||||
Foreground = ThemeResourceHelper.HexBrush("#A78BFA"),
|
||||
});
|
||||
statsBtn.Child = statsContent;
|
||||
statsBtn.MouseLeftButtonUp += (_, _) =>
|
||||
{
|
||||
var win = new AgentStatsDashboardWindow();
|
||||
win.Owner = Window.GetWindow(this);
|
||||
win.ShowDialog();
|
||||
};
|
||||
statsBtn.MouseEnter += (s, _) => ((Border)s).Opacity = 0.8;
|
||||
statsBtn.MouseLeave += (s, _) => ((Border)s).Opacity = 1.0;
|
||||
linkPanel.Children.Add(statsBtn);
|
||||
|
||||
skillItems.Add(linkPanel);
|
||||
|
||||
// ── 스킬 섹션 직접 추가 ──
|
||||
// 스킬 헤더
|
||||
var skillHeader = new TextBlock
|
||||
{
|
||||
Text = $"슬래시 스킬 ({skills.Count})",
|
||||
FontSize = 13,
|
||||
FontWeight = FontWeights.SemiBold,
|
||||
Foreground = TryFindResource("PrimaryText") as Brush ?? Brushes.White,
|
||||
Margin = new Thickness(2, 16, 0, 8),
|
||||
};
|
||||
AgentEtcContent.Children.Insert(insertIdx++, skillHeader);
|
||||
foreach (var item in skillItems)
|
||||
AgentEtcContent.Children.Insert(insertIdx++, item);
|
||||
}
|
||||
|
||||
private Border CreateSkillGroupCard(string title, string icon, string colorHex,
|
||||
List<Services.Agent.SkillDefinition> skills)
|
||||
{
|
||||
var color = ThemeResourceHelper.HexColor(colorHex);
|
||||
var card = new Border
|
||||
{
|
||||
Background = TryFindResource("ItemBackground") as Brush ?? Brushes.White,
|
||||
CornerRadius = new CornerRadius(10),
|
||||
Padding = new Thickness(14, 10, 14, 10),
|
||||
Margin = new Thickness(0, 0, 0, 6),
|
||||
};
|
||||
|
||||
var panel = new StackPanel();
|
||||
|
||||
// 스킬 아이템 패널 (접기/열기 대상)
|
||||
var skillItemsPanel = new StackPanel { Visibility = Visibility.Collapsed };
|
||||
|
||||
// 접기/열기 화살표
|
||||
var arrow = new TextBlock
|
||||
{
|
||||
Text = "\uE70D",
|
||||
FontFamily = ThemeResourceHelper.SegoeMdl2,
|
||||
FontSize = 9,
|
||||
Foreground = TryFindResource("SecondaryText") as Brush ?? Brushes.Gray,
|
||||
VerticalAlignment = VerticalAlignment.Center,
|
||||
Margin = new Thickness(0, 0, 6, 0),
|
||||
RenderTransformOrigin = new Point(0.5, 0.5),
|
||||
RenderTransform = new RotateTransform(0),
|
||||
};
|
||||
|
||||
// 카테고리 헤더 (클릭 가능)
|
||||
var catHeader = new Border
|
||||
{
|
||||
Background = Brushes.Transparent,
|
||||
Cursor = Cursors.Hand,
|
||||
};
|
||||
var catHeaderPanel = new StackPanel { Orientation = Orientation.Horizontal };
|
||||
catHeaderPanel.Children.Add(arrow);
|
||||
catHeaderPanel.Children.Add(new TextBlock
|
||||
{
|
||||
Text = icon,
|
||||
FontFamily = ThemeResourceHelper.SegoeMdl2,
|
||||
FontSize = 14,
|
||||
Foreground = new SolidColorBrush(color),
|
||||
VerticalAlignment = VerticalAlignment.Center,
|
||||
Margin = new Thickness(0, 0, 8, 0),
|
||||
});
|
||||
catHeaderPanel.Children.Add(new TextBlock
|
||||
{
|
||||
Text = $"{title} ({skills.Count})",
|
||||
FontSize = 12.5,
|
||||
FontWeight = FontWeights.SemiBold,
|
||||
Foreground = TryFindResource("PrimaryText") as Brush
|
||||
?? ThemeResourceHelper.HexBrush("#1A1B2E"),
|
||||
VerticalAlignment = VerticalAlignment.Center,
|
||||
});
|
||||
catHeader.Child = catHeaderPanel;
|
||||
|
||||
// 클릭 시 접기/열기 토글
|
||||
catHeader.MouseLeftButtonDown += (s, e) =>
|
||||
{
|
||||
e.Handled = true;
|
||||
bool isVisible = skillItemsPanel.Visibility == Visibility.Visible;
|
||||
skillItemsPanel.Visibility = isVisible ? Visibility.Collapsed : Visibility.Visible;
|
||||
arrow.RenderTransform = new RotateTransform(isVisible ? 0 : 90);
|
||||
};
|
||||
|
||||
panel.Children.Add(catHeader);
|
||||
|
||||
// 구분선
|
||||
skillItemsPanel.Children.Add(new Border
|
||||
{
|
||||
Height = 1,
|
||||
Background = TryFindResource("BorderColor") as Brush
|
||||
?? ThemeResourceHelper.HexBrush("#F0F0F4"),
|
||||
Margin = new Thickness(0, 2, 0, 4),
|
||||
});
|
||||
|
||||
// 스킬 아이템
|
||||
foreach (var skill in skills)
|
||||
{
|
||||
var row = new StackPanel { Margin = new Thickness(0, 4, 0, 4) };
|
||||
|
||||
// 위 줄: 스킬 명칭 + 가용성 뱃지
|
||||
var namePanel = new StackPanel { Orientation = Orientation.Horizontal };
|
||||
namePanel.Children.Add(new TextBlock
|
||||
{
|
||||
Text = $"/{skill.Name}",
|
||||
FontSize = 12,
|
||||
FontFamily = ThemeResourceHelper.ConsolasCode,
|
||||
Foreground = skill.IsAvailable
|
||||
? (TryFindResource("AccentColor") as Brush ?? ThemeResourceHelper.HexBrush("#4B5EFC"))
|
||||
: Brushes.Gray,
|
||||
VerticalAlignment = VerticalAlignment.Center,
|
||||
Margin = new Thickness(4, 0, 8, 0),
|
||||
Opacity = skill.IsAvailable ? 1.0 : 0.5,
|
||||
});
|
||||
|
||||
if (!skill.IsAvailable && !string.IsNullOrEmpty(skill.Requires))
|
||||
{
|
||||
namePanel.Children.Add(new Border
|
||||
{
|
||||
Background = new SolidColorBrush(Color.FromArgb(0x20, 0xF8, 0x71, 0x71)),
|
||||
CornerRadius = new CornerRadius(4),
|
||||
Padding = new Thickness(5, 1, 5, 1),
|
||||
VerticalAlignment = VerticalAlignment.Center,
|
||||
Child = new TextBlock
|
||||
{
|
||||
Text = skill.UnavailableHint,
|
||||
FontSize = 9,
|
||||
Foreground = new SolidColorBrush(Color.FromRgb(0xF8, 0x71, 0x71)),
|
||||
FontWeight = FontWeights.SemiBold,
|
||||
},
|
||||
});
|
||||
}
|
||||
else if (skill.IsAvailable && !string.IsNullOrEmpty(skill.Requires))
|
||||
{
|
||||
namePanel.Children.Add(new Border
|
||||
{
|
||||
Background = new SolidColorBrush(Color.FromArgb(0x20, 0x34, 0xD3, 0x99)),
|
||||
CornerRadius = new CornerRadius(4),
|
||||
Padding = new Thickness(5, 1, 5, 1),
|
||||
VerticalAlignment = VerticalAlignment.Center,
|
||||
Child = new TextBlock
|
||||
{
|
||||
Text = "✓ 사용 가능",
|
||||
FontSize = 9,
|
||||
Foreground = new SolidColorBrush(Color.FromRgb(0x34, 0xD3, 0x99)),
|
||||
FontWeight = FontWeights.SemiBold,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
row.Children.Add(namePanel);
|
||||
|
||||
// 아래 줄: 설명 (뱃지와 구분되도록 위 여백 추가)
|
||||
row.Children.Add(new TextBlock
|
||||
{
|
||||
Text = $"{skill.Label} — {skill.Description}",
|
||||
FontSize = 11.5,
|
||||
Foreground = TryFindResource("SecondaryText") as Brush
|
||||
?? ThemeResourceHelper.HexBrush("#6666AA"),
|
||||
TextWrapping = TextWrapping.Wrap,
|
||||
Margin = new Thickness(4, 3, 0, 0),
|
||||
Opacity = skill.IsAvailable ? 1.0 : 0.5,
|
||||
});
|
||||
|
||||
skillItemsPanel.Children.Add(row);
|
||||
}
|
||||
|
||||
panel.Children.Add(skillItemsPanel);
|
||||
card.Child = panel;
|
||||
return card;
|
||||
}
|
||||
|
||||
}
|
||||
@@ -236,370 +236,4 @@ public partial class SettingsWindow
|
||||
BuildSkillListSection(ref insertIdx);
|
||||
}
|
||||
|
||||
private void BuildSkillListSection(ref int insertIdx)
|
||||
{
|
||||
if (AgentEtcContent == null) return;
|
||||
|
||||
var skills = Services.Agent.SkillService.Skills;
|
||||
if (skills.Count == 0) return;
|
||||
|
||||
var accentColor = ThemeResourceHelper.HexColor("#4B5EFC");
|
||||
var subtleText = ThemeResourceHelper.HexColor("#6666AA");
|
||||
|
||||
// 스킬 콘텐츠를 모아서 접기/열기 섹션에 넣기
|
||||
var skillItems = new List<UIElement>();
|
||||
|
||||
// 설명
|
||||
skillItems.Add(new TextBlock
|
||||
{
|
||||
Text = "/ 명령으로 호출할 수 있는 스킬 목록입니다. 앱 내장 + 사용자 추가 스킬이 포함됩니다.\n" +
|
||||
"(스킬은 사용자가 직접 /명령어를 입력해야 실행됩니다. LLM이 자동 호출하지 않습니다.)",
|
||||
FontSize = 11,
|
||||
Foreground = new SolidColorBrush(subtleText),
|
||||
Margin = new Thickness(2, 0, 0, 10),
|
||||
TextWrapping = TextWrapping.Wrap,
|
||||
});
|
||||
|
||||
// 내장 스킬 / 고급 스킬 분류
|
||||
var builtIn = skills.Where(s => string.IsNullOrEmpty(s.Requires)).ToList();
|
||||
var advanced = skills.Where(s => !string.IsNullOrEmpty(s.Requires)).ToList();
|
||||
|
||||
// 내장 스킬 카드
|
||||
if (builtIn.Count > 0)
|
||||
{
|
||||
var card = CreateSkillGroupCard("내장 스킬", "\uE768", "#34D399", builtIn);
|
||||
skillItems.Add(card);
|
||||
}
|
||||
|
||||
// 고급 스킬 (런타임 의존) 카드
|
||||
if (advanced.Count > 0)
|
||||
{
|
||||
var card = CreateSkillGroupCard("고급 스킬 (런타임 필요)", "\uE9D9", "#A78BFA", advanced);
|
||||
skillItems.Add(card);
|
||||
}
|
||||
|
||||
// ── 스킬 가져오기/내보내기 버튼 ──
|
||||
var btnPanel = new StackPanel
|
||||
{
|
||||
Orientation = Orientation.Horizontal,
|
||||
Margin = new Thickness(2, 4, 0, 10),
|
||||
};
|
||||
|
||||
// 가져오기 버튼
|
||||
var importBtn = new Border
|
||||
{
|
||||
Background = ThemeResourceHelper.HexBrush("#4B5EFC"),
|
||||
CornerRadius = new CornerRadius(8),
|
||||
Padding = new Thickness(14, 7, 14, 7),
|
||||
Margin = new Thickness(0, 0, 8, 0),
|
||||
Cursor = Cursors.Hand,
|
||||
};
|
||||
var importContent = new StackPanel { Orientation = Orientation.Horizontal };
|
||||
importContent.Children.Add(new TextBlock
|
||||
{
|
||||
Text = "\uE8B5",
|
||||
FontFamily = ThemeResourceHelper.SegoeMdl2,
|
||||
FontSize = 12,
|
||||
Foreground = Brushes.White,
|
||||
VerticalAlignment = VerticalAlignment.Center,
|
||||
Margin = new Thickness(0, 0, 6, 0),
|
||||
});
|
||||
importContent.Children.Add(new TextBlock
|
||||
{
|
||||
Text = "스킬 가져오기 (.zip)",
|
||||
FontSize = 12,
|
||||
FontWeight = FontWeights.SemiBold,
|
||||
Foreground = Brushes.White,
|
||||
VerticalAlignment = VerticalAlignment.Center,
|
||||
});
|
||||
importBtn.Child = importContent;
|
||||
importBtn.MouseLeftButtonUp += SkillImport_Click;
|
||||
importBtn.MouseEnter += (s, _) => ((Border)s).Opacity = 0.85;
|
||||
importBtn.MouseLeave += (s, _) => ((Border)s).Opacity = 1.0;
|
||||
btnPanel.Children.Add(importBtn);
|
||||
|
||||
// 내보내기 버튼
|
||||
var exportBtn = new Border
|
||||
{
|
||||
Background = ThemeResourceHelper.HexBrush("#F0F1F5"),
|
||||
CornerRadius = new CornerRadius(8),
|
||||
Padding = new Thickness(14, 7, 14, 7),
|
||||
Cursor = Cursors.Hand,
|
||||
};
|
||||
var exportContent = new StackPanel { Orientation = Orientation.Horizontal };
|
||||
exportContent.Children.Add(new TextBlock
|
||||
{
|
||||
Text = "\uEDE1",
|
||||
FontFamily = ThemeResourceHelper.SegoeMdl2,
|
||||
FontSize = 12,
|
||||
Foreground = ThemeResourceHelper.HexBrush("#555"),
|
||||
VerticalAlignment = VerticalAlignment.Center,
|
||||
Margin = new Thickness(0, 0, 6, 0),
|
||||
});
|
||||
exportContent.Children.Add(new TextBlock
|
||||
{
|
||||
Text = "스킬 내보내기",
|
||||
FontSize = 12,
|
||||
FontWeight = FontWeights.SemiBold,
|
||||
Foreground = ThemeResourceHelper.HexBrush("#444"),
|
||||
VerticalAlignment = VerticalAlignment.Center,
|
||||
});
|
||||
exportBtn.Child = exportContent;
|
||||
exportBtn.MouseLeftButtonUp += SkillExport_Click;
|
||||
exportBtn.MouseEnter += (s, _) => ((Border)s).Opacity = 0.85;
|
||||
exportBtn.MouseLeave += (s, _) => ((Border)s).Opacity = 1.0;
|
||||
btnPanel.Children.Add(exportBtn);
|
||||
|
||||
skillItems.Add(btnPanel);
|
||||
|
||||
// ── 갤러리/통계 링크 버튼 ──
|
||||
var linkPanel = new StackPanel
|
||||
{
|
||||
Orientation = Orientation.Horizontal,
|
||||
Margin = new Thickness(2, 0, 0, 12),
|
||||
};
|
||||
|
||||
var galleryBtn = new Border
|
||||
{
|
||||
CornerRadius = new CornerRadius(8),
|
||||
Padding = new Thickness(14, 7, 14, 7),
|
||||
Margin = new Thickness(0, 0, 8, 0),
|
||||
Background = new SolidColorBrush(Color.FromArgb(0x18, 0x4B, 0x5E, 0xFC)),
|
||||
Cursor = Cursors.Hand,
|
||||
};
|
||||
var galleryContent = new StackPanel { Orientation = Orientation.Horizontal };
|
||||
galleryContent.Children.Add(new TextBlock
|
||||
{
|
||||
Text = "\uE768",
|
||||
FontFamily = ThemeResourceHelper.SegoeMdl2,
|
||||
FontSize = 12,
|
||||
Foreground = ThemeResourceHelper.HexBrush("#4B5EFC"),
|
||||
VerticalAlignment = VerticalAlignment.Center,
|
||||
Margin = new Thickness(0, 0, 6, 0),
|
||||
});
|
||||
galleryContent.Children.Add(new TextBlock
|
||||
{
|
||||
Text = "스킬 갤러리 열기",
|
||||
FontSize = 12,
|
||||
FontWeight = FontWeights.SemiBold,
|
||||
Foreground = ThemeResourceHelper.HexBrush("#4B5EFC"),
|
||||
});
|
||||
galleryBtn.Child = galleryContent;
|
||||
galleryBtn.MouseLeftButtonUp += (_, _) =>
|
||||
{
|
||||
var win = new SkillGalleryWindow();
|
||||
win.Owner = Window.GetWindow(this);
|
||||
win.ShowDialog();
|
||||
};
|
||||
galleryBtn.MouseEnter += (s, _) => ((Border)s).Opacity = 0.8;
|
||||
galleryBtn.MouseLeave += (s, _) => ((Border)s).Opacity = 1.0;
|
||||
linkPanel.Children.Add(galleryBtn);
|
||||
|
||||
var statsBtn = new Border
|
||||
{
|
||||
CornerRadius = new CornerRadius(8),
|
||||
Padding = new Thickness(14, 7, 14, 7),
|
||||
Background = new SolidColorBrush(Color.FromArgb(0x18, 0xA7, 0x8B, 0xFA)),
|
||||
Cursor = Cursors.Hand,
|
||||
};
|
||||
var statsContent = new StackPanel { Orientation = Orientation.Horizontal };
|
||||
statsContent.Children.Add(new TextBlock
|
||||
{
|
||||
Text = "\uE9D9",
|
||||
FontFamily = ThemeResourceHelper.SegoeMdl2,
|
||||
FontSize = 12,
|
||||
Foreground = ThemeResourceHelper.HexBrush("#A78BFA"),
|
||||
VerticalAlignment = VerticalAlignment.Center,
|
||||
Margin = new Thickness(0, 0, 6, 0),
|
||||
});
|
||||
statsContent.Children.Add(new TextBlock
|
||||
{
|
||||
Text = "실행 통계 보기",
|
||||
FontSize = 12,
|
||||
FontWeight = FontWeights.SemiBold,
|
||||
Foreground = ThemeResourceHelper.HexBrush("#A78BFA"),
|
||||
});
|
||||
statsBtn.Child = statsContent;
|
||||
statsBtn.MouseLeftButtonUp += (_, _) =>
|
||||
{
|
||||
var win = new AgentStatsDashboardWindow();
|
||||
win.Owner = Window.GetWindow(this);
|
||||
win.ShowDialog();
|
||||
};
|
||||
statsBtn.MouseEnter += (s, _) => ((Border)s).Opacity = 0.8;
|
||||
statsBtn.MouseLeave += (s, _) => ((Border)s).Opacity = 1.0;
|
||||
linkPanel.Children.Add(statsBtn);
|
||||
|
||||
skillItems.Add(linkPanel);
|
||||
|
||||
// ── 스킬 섹션 직접 추가 ──
|
||||
// 스킬 헤더
|
||||
var skillHeader = new TextBlock
|
||||
{
|
||||
Text = $"슬래시 스킬 ({skills.Count})",
|
||||
FontSize = 13,
|
||||
FontWeight = FontWeights.SemiBold,
|
||||
Foreground = TryFindResource("PrimaryText") as Brush ?? Brushes.White,
|
||||
Margin = new Thickness(2, 16, 0, 8),
|
||||
};
|
||||
AgentEtcContent.Children.Insert(insertIdx++, skillHeader);
|
||||
foreach (var item in skillItems)
|
||||
AgentEtcContent.Children.Insert(insertIdx++, item);
|
||||
}
|
||||
|
||||
private Border CreateSkillGroupCard(string title, string icon, string colorHex,
|
||||
List<Services.Agent.SkillDefinition> skills)
|
||||
{
|
||||
var color = ThemeResourceHelper.HexColor(colorHex);
|
||||
var card = new Border
|
||||
{
|
||||
Background = TryFindResource("ItemBackground") as Brush ?? Brushes.White,
|
||||
CornerRadius = new CornerRadius(10),
|
||||
Padding = new Thickness(14, 10, 14, 10),
|
||||
Margin = new Thickness(0, 0, 0, 6),
|
||||
};
|
||||
|
||||
var panel = new StackPanel();
|
||||
|
||||
// 스킬 아이템 패널 (접기/열기 대상)
|
||||
var skillItemsPanel = new StackPanel { Visibility = Visibility.Collapsed };
|
||||
|
||||
// 접기/열기 화살표
|
||||
var arrow = new TextBlock
|
||||
{
|
||||
Text = "\uE70D",
|
||||
FontFamily = ThemeResourceHelper.SegoeMdl2,
|
||||
FontSize = 9,
|
||||
Foreground = TryFindResource("SecondaryText") as Brush ?? Brushes.Gray,
|
||||
VerticalAlignment = VerticalAlignment.Center,
|
||||
Margin = new Thickness(0, 0, 6, 0),
|
||||
RenderTransformOrigin = new Point(0.5, 0.5),
|
||||
RenderTransform = new RotateTransform(0),
|
||||
};
|
||||
|
||||
// 카테고리 헤더 (클릭 가능)
|
||||
var catHeader = new Border
|
||||
{
|
||||
Background = Brushes.Transparent,
|
||||
Cursor = Cursors.Hand,
|
||||
};
|
||||
var catHeaderPanel = new StackPanel { Orientation = Orientation.Horizontal };
|
||||
catHeaderPanel.Children.Add(arrow);
|
||||
catHeaderPanel.Children.Add(new TextBlock
|
||||
{
|
||||
Text = icon,
|
||||
FontFamily = ThemeResourceHelper.SegoeMdl2,
|
||||
FontSize = 14,
|
||||
Foreground = new SolidColorBrush(color),
|
||||
VerticalAlignment = VerticalAlignment.Center,
|
||||
Margin = new Thickness(0, 0, 8, 0),
|
||||
});
|
||||
catHeaderPanel.Children.Add(new TextBlock
|
||||
{
|
||||
Text = $"{title} ({skills.Count})",
|
||||
FontSize = 12.5,
|
||||
FontWeight = FontWeights.SemiBold,
|
||||
Foreground = TryFindResource("PrimaryText") as Brush
|
||||
?? ThemeResourceHelper.HexBrush("#1A1B2E"),
|
||||
VerticalAlignment = VerticalAlignment.Center,
|
||||
});
|
||||
catHeader.Child = catHeaderPanel;
|
||||
|
||||
// 클릭 시 접기/열기 토글
|
||||
catHeader.MouseLeftButtonDown += (s, e) =>
|
||||
{
|
||||
e.Handled = true;
|
||||
bool isVisible = skillItemsPanel.Visibility == Visibility.Visible;
|
||||
skillItemsPanel.Visibility = isVisible ? Visibility.Collapsed : Visibility.Visible;
|
||||
arrow.RenderTransform = new RotateTransform(isVisible ? 0 : 90);
|
||||
};
|
||||
|
||||
panel.Children.Add(catHeader);
|
||||
|
||||
// 구분선
|
||||
skillItemsPanel.Children.Add(new Border
|
||||
{
|
||||
Height = 1,
|
||||
Background = TryFindResource("BorderColor") as Brush
|
||||
?? ThemeResourceHelper.HexBrush("#F0F0F4"),
|
||||
Margin = new Thickness(0, 2, 0, 4),
|
||||
});
|
||||
|
||||
// 스킬 아이템
|
||||
foreach (var skill in skills)
|
||||
{
|
||||
var row = new StackPanel { Margin = new Thickness(0, 4, 0, 4) };
|
||||
|
||||
// 위 줄: 스킬 명칭 + 가용성 뱃지
|
||||
var namePanel = new StackPanel { Orientation = Orientation.Horizontal };
|
||||
namePanel.Children.Add(new TextBlock
|
||||
{
|
||||
Text = $"/{skill.Name}",
|
||||
FontSize = 12,
|
||||
FontFamily = ThemeResourceHelper.ConsolasCode,
|
||||
Foreground = skill.IsAvailable
|
||||
? (TryFindResource("AccentColor") as Brush ?? ThemeResourceHelper.HexBrush("#4B5EFC"))
|
||||
: Brushes.Gray,
|
||||
VerticalAlignment = VerticalAlignment.Center,
|
||||
Margin = new Thickness(4, 0, 8, 0),
|
||||
Opacity = skill.IsAvailable ? 1.0 : 0.5,
|
||||
});
|
||||
|
||||
if (!skill.IsAvailable && !string.IsNullOrEmpty(skill.Requires))
|
||||
{
|
||||
namePanel.Children.Add(new Border
|
||||
{
|
||||
Background = new SolidColorBrush(Color.FromArgb(0x20, 0xF8, 0x71, 0x71)),
|
||||
CornerRadius = new CornerRadius(4),
|
||||
Padding = new Thickness(5, 1, 5, 1),
|
||||
VerticalAlignment = VerticalAlignment.Center,
|
||||
Child = new TextBlock
|
||||
{
|
||||
Text = skill.UnavailableHint,
|
||||
FontSize = 9,
|
||||
Foreground = new SolidColorBrush(Color.FromRgb(0xF8, 0x71, 0x71)),
|
||||
FontWeight = FontWeights.SemiBold,
|
||||
},
|
||||
});
|
||||
}
|
||||
else if (skill.IsAvailable && !string.IsNullOrEmpty(skill.Requires))
|
||||
{
|
||||
namePanel.Children.Add(new Border
|
||||
{
|
||||
Background = new SolidColorBrush(Color.FromArgb(0x20, 0x34, 0xD3, 0x99)),
|
||||
CornerRadius = new CornerRadius(4),
|
||||
Padding = new Thickness(5, 1, 5, 1),
|
||||
VerticalAlignment = VerticalAlignment.Center,
|
||||
Child = new TextBlock
|
||||
{
|
||||
Text = "✓ 사용 가능",
|
||||
FontSize = 9,
|
||||
Foreground = new SolidColorBrush(Color.FromRgb(0x34, 0xD3, 0x99)),
|
||||
FontWeight = FontWeights.SemiBold,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
row.Children.Add(namePanel);
|
||||
|
||||
// 아래 줄: 설명 (뱃지와 구분되도록 위 여백 추가)
|
||||
row.Children.Add(new TextBlock
|
||||
{
|
||||
Text = $"{skill.Label} — {skill.Description}",
|
||||
FontSize = 11.5,
|
||||
Foreground = TryFindResource("SecondaryText") as Brush
|
||||
?? ThemeResourceHelper.HexBrush("#6666AA"),
|
||||
TextWrapping = TextWrapping.Wrap,
|
||||
Margin = new Thickness(4, 3, 0, 0),
|
||||
Opacity = skill.IsAvailable ? 1.0 : 0.5,
|
||||
});
|
||||
|
||||
skillItemsPanel.Children.Add(row);
|
||||
}
|
||||
|
||||
panel.Children.Add(skillItemsPanel);
|
||||
card.Child = panel;
|
||||
return card;
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user