From bf37800311a66f118eec13e03aa1b9980a13c142 Mon Sep 17 00:00:00 2001 From: lacvet Date: Fri, 3 Apr 2026 19:45:26 +0900 Subject: [PATCH] =?UTF-8?q?[Phase=2042]=20ChatWindow.ResponseHandling?= =?UTF-8?q?=C2=B7LlmService=20=ED=8C=8C=EC=85=9C=20=EB=B6=84=ED=95=A0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ChatWindow.ResponseHandling (1,494줄 → 741줄, 50% 감소): - ChatWindow.MessageActions.cs (277줄): 버튼이벤트, 메시지검색, 에러복구 - ChatWindow.StatusAndUI.cs (498줄): 우클릭, 팁, AX.md, 글로우, 토스트, 하단바 LlmService (1,010줄 → 263줄, 74% 감소): - LlmService.Streaming.cs (516줄): 스트리밍 응답, 백엔드별 구현 - LlmService.Helpers.cs (252줄): 헬퍼, 토큰 파싱, Dispose - 빌드: 경고 0, 오류 0 Co-Authored-By: Claude Sonnet 4.6 --- docs/NEXT_ROADMAP.md | 29 +- src/AxCopilot/Services/LlmService.Helpers.cs | 252 ++++++ .../Services/LlmService.Streaming.cs | 516 ++++++++++++ src/AxCopilot/Services/LlmService.cs | 747 ----------------- .../Views/ChatWindow.MessageActions.cs | 277 +++++++ .../Views/ChatWindow.ResponseHandling.cs | 755 +----------------- src/AxCopilot/Views/ChatWindow.StatusAndUI.cs | 498 ++++++++++++ 7 files changed, 1572 insertions(+), 1502 deletions(-) create mode 100644 src/AxCopilot/Services/LlmService.Helpers.cs create mode 100644 src/AxCopilot/Services/LlmService.Streaming.cs create mode 100644 src/AxCopilot/Views/ChatWindow.MessageActions.cs create mode 100644 src/AxCopilot/Views/ChatWindow.StatusAndUI.cs diff --git a/docs/NEXT_ROADMAP.md b/docs/NEXT_ROADMAP.md index f463fd7..ec8ec57 100644 --- a/docs/NEXT_ROADMAP.md +++ b/docs/NEXT_ROADMAP.md @@ -4632,5 +4632,32 @@ ThemeResourceHelper에 5개 정적 필드 추가: --- -최종 업데이트: 2026-04-03 (Phase 22~41 구현 완료 — CC 동등성 37/37 + 코드 품질 리팩터링 9차) +## Phase 42 — ChatWindow.ResponseHandling·LlmService 파셜 분할 (v2.3) ✅ 완료 + +> **목표**: ChatWindow.ResponseHandling (1,494줄)·LlmService (1,010줄) 추가 분할. + +### ChatWindow.ResponseHandling 분할 + +| 파일 | 줄 수 | 내용 | +|------|-------|------| +| `ChatWindow.ResponseHandling.cs` | 741 | 응답 재생성, 스트리밍 완료 마크다운, 중지, 대화 분기, 팔레트 | +| `ChatWindow.MessageActions.cs` | 277 | 버튼 이벤트, 메시지 내 검색(Ctrl+F), 에러 복구 | +| `ChatWindow.StatusAndUI.cs` | 498 | 우클릭 메뉴, 팁, AX.md, 무지개 글로우, 토스트, 하단바, 헬퍼 | + +- **원본 대비**: 1,494줄 → 741줄 (**50.3% 감소**) + +### LlmService 분할 + +| 파일 | 줄 수 | 내용 | +|------|-------|------| +| `LlmService.cs` (메인) | 263 | 필드, 생성자, 라우팅, 시스템 프롬프트, 비스트리밍 | +| `LlmService.Streaming.cs` | 516 | StreamAsync, TestConnectionAsync, 백엔드별 구현 | +| `LlmService.Helpers.cs` | 252 | 메시지 빌드, HTTP 재시도, 토큰 파싱, Dispose | + +- **메인 파일**: 1,010줄 → 263줄 (**74.0% 감소**) +- **빌드**: 경고 0, 오류 0 + +--- + +최종 업데이트: 2026-04-03 (Phase 22~42 구현 완료 — CC 동등성 37/37 + 코드 품질 리팩터링 10차) diff --git a/src/AxCopilot/Services/LlmService.Helpers.cs b/src/AxCopilot/Services/LlmService.Helpers.cs new file mode 100644 index 0000000..763a2ae --- /dev/null +++ b/src/AxCopilot/Services/LlmService.Helpers.cs @@ -0,0 +1,252 @@ +using System.IO; +using System.Net.Http; +using System.Text; +using System.Text.Json; +using AxCopilot.Models; + +namespace AxCopilot.Services; + +public partial class LlmService +{ + // ─── 공용 헬퍼 ───────────────────────────────────────────────────────── + + private List BuildMessageList(List messages, bool openAiVision = false) + { + var result = new List(); + if (!string.IsNullOrEmpty(_systemPrompt)) + result.Add(new { role = "system", content = _systemPrompt }); + + foreach (var m in messages) + { + if (m.Role == "system") continue; + if (m.Images?.Count > 0) + { + if (openAiVision) + { + // OpenAI Vision: content 배열 (text + image_url) + var contentParts = new List(); + contentParts.Add(new { type = "text", text = m.Content }); + foreach (var img in m.Images) + contentParts.Add(new { type = "image_url", image_url = new { url = $"data:{img.MimeType};base64,{img.Base64}" } }); + result.Add(new { role = m.Role, content = contentParts }); + } + else + { + // Ollama Vision: images 필드에 base64 배열 + result.Add(new { role = m.Role, content = m.Content, images = m.Images.Select(i => i.Base64).ToArray() }); + } + } + else + { + result.Add(new { role = m.Role, content = m.Content }); + } + } + return result; + } + + /// 비스트리밍 POST + 재시도 (일시적 오류 시 최대 2회) + private async Task PostJsonWithRetryAsync(string url, object body, CancellationToken ct) + { + var json = JsonSerializer.Serialize(body); + Exception? lastEx = null; + + for (int attempt = 0; attempt <= MaxRetries; attempt++) + { + try + { + using var content = new StringContent(json, Encoding.UTF8, "application/json"); + using var resp = await _http.PostAsync(url, content, ct); + + if (resp.IsSuccessStatusCode) + return await resp.Content.ReadAsStringAsync(ct); + + // 429 Rate Limit → 재시도 + if ((int)resp.StatusCode == 429 && attempt < MaxRetries) + { + await Task.Delay(1000 * (attempt + 1), ct); + continue; + } + + // 그 외 에러 → 분류 후 예외 + var errBody = await resp.Content.ReadAsStringAsync(ct); + throw new HttpRequestException(ClassifyHttpError(resp, errBody)); + } + catch (HttpRequestException) { throw; } + catch (TaskCanceledException) when (!ct.IsCancellationRequested && attempt < MaxRetries) + { + lastEx = new TimeoutException("요청 시간 초과"); + await Task.Delay(1000 * (attempt + 1), ct); + } + } + throw lastEx ?? new HttpRequestException("요청 실패"); + } + + /// 스트리밍 전용 — HTTP 요청 전송 + 에러 분류 + private async Task SendWithErrorClassificationAsync( + HttpRequestMessage req, CancellationToken ct) + { + var resp = await _http.SendAsync(req, HttpCompletionOption.ResponseHeadersRead, ct); + if (!resp.IsSuccessStatusCode) + { + var errBody = await resp.Content.ReadAsStringAsync(ct); + var errorMsg = ClassifyHttpError(resp, errBody); + resp.Dispose(); + throw new HttpRequestException(errorMsg); + } + return resp; + } + + /// 스트리밍 ReadLine에 청크 타임아웃 적용 + private static async Task ReadLineWithTimeoutAsync(StreamReader reader, CancellationToken ct) + { + using var cts = CancellationTokenSource.CreateLinkedTokenSource(ct); + cts.CancelAfter(ChunkTimeout); + try + { + return await reader.ReadLineAsync(cts.Token); + } + catch (OperationCanceledException) when (!ct.IsCancellationRequested) + { + LogService.Warn("스트리밍 청크 타임아웃 (30초 무응답)"); + return null; // 타임아웃 시 스트림 종료 + } + } + + /// JSON 파싱 안전 래퍼 — 파싱 실패 시 상세 에러 메시지 반환 + private static string SafeParseJson(string json, Func extractor, string context) + { + try + { + using var doc = JsonDocument.Parse(json); + + // API 에러 응답 감지 + if (doc.RootElement.TryGetProperty("error", out var error)) + { + var msg = error.TryGetProperty("message", out var m) ? m.GetString() : error.ToString(); + throw new HttpRequestException($"[{context}] API 에러: {msg}"); + } + + return extractor(doc.RootElement); + } + catch (JsonException ex) + { + var preview = json.Length > 200 ? json[..200] + "…" : json; + throw new InvalidOperationException( + $"[{context}] 응답 형식 오류 — 예상하지 못한 JSON 형식입니다.\n파싱 오류: {ex.Message}\n응답 미리보기: {preview}"); + } + catch (KeyNotFoundException) + { + var preview = json.Length > 200 ? json[..200] + "…" : json; + throw new InvalidOperationException( + $"[{context}] 응답에 필요한 필드가 없습니다.\n응답 미리보기: {preview}"); + } + } + + /// HTTP 에러 코드별 사용자 친화적 메시지 + private static string ClassifyHttpError(HttpResponseMessage resp, string? body = null) + { + var code = (int)resp.StatusCode; + var detail = ""; + + // JSON error.message 추출 시도 + if (!string.IsNullOrEmpty(body)) + { + try + { + using var doc = JsonDocument.Parse(body); + if (doc.RootElement.TryGetProperty("error", out var err)) + { + if (err.ValueKind == JsonValueKind.Object && err.TryGetProperty("message", out var m)) + detail = m.GetString() ?? ""; + else if (err.ValueKind == JsonValueKind.String) + detail = err.GetString() ?? ""; + } + } + catch (Exception) { } + } + + var msg = code switch + { + 400 => "잘못된 요청 — 모델 이름이나 요청 형식을 확인하세요", + 401 => "인증 실패 — API 키가 유효하지 않습니다", + 403 => "접근 거부 — API 키 권한을 확인하세요", + 404 => "모델을 찾을 수 없습니다 — 모델 이름을 확인하세요", + 429 => "요청 한도 초과 — 잠시 후 다시 시도하세요", + 500 => "서버 내부 오류 — LLM 서버 상태를 확인하세요", + 502 or 503 => "서버 일시 장애 — 잠시 후 다시 시도하세요", + _ => $"HTTP {code} 오류" + }; + + return string.IsNullOrEmpty(detail) ? msg : $"{msg}\n상세: {detail}"; + } + + private static StringContent JsonContent(object body) + { + var json = JsonSerializer.Serialize(body); + return new StringContent(json, Encoding.UTF8, "application/json"); + } + + // ─── 토큰 사용량 파싱 헬퍼 ────────────────────────────────────────── + + private void TryParseOllamaUsage(JsonElement root) + { + try + { + var prompt = root.TryGetProperty("prompt_eval_count", out var p) ? p.GetInt32() : 0; + var completion = root.TryGetProperty("eval_count", out var e) ? e.GetInt32() : 0; + if (prompt > 0 || completion > 0) + LastTokenUsage = new TokenUsage(prompt, completion); + } + catch (Exception) { } + } + + private void TryParseOpenAiUsage(JsonElement root) + { + try + { + if (!root.TryGetProperty("usage", out var usage)) return; + var prompt = usage.TryGetProperty("prompt_tokens", out var p) ? p.GetInt32() : 0; + var completion = usage.TryGetProperty("completion_tokens", out var c) ? c.GetInt32() : 0; + if (prompt > 0 || completion > 0) + LastTokenUsage = new TokenUsage(prompt, completion); + } + catch (Exception) { } + } + + private void TryParseGeminiUsage(JsonElement root) + { + try + { + if (!root.TryGetProperty("usageMetadata", out var usage)) return; + var prompt = usage.TryGetProperty("promptTokenCount", out var p) ? p.GetInt32() : 0; + var completion = usage.TryGetProperty("candidatesTokenCount", out var c) ? c.GetInt32() : 0; + if (prompt > 0 || completion > 0) + LastTokenUsage = new TokenUsage(prompt, completion); + } + catch (Exception) { } + } + + private void TryParseClaudeUsage(JsonElement root) + { + try + { + if (!root.TryGetProperty("usage", out var usage)) return; + TryParseClaudeUsageFromElement(usage); + } + catch (Exception) { } + } + + private void TryParseClaudeUsageFromElement(JsonElement usage) + { + try + { + var input = usage.TryGetProperty("input_tokens", out var i) ? i.GetInt32() : 0; + var output = usage.TryGetProperty("output_tokens", out var o) ? o.GetInt32() : 0; + if (input > 0 || output > 0) + LastTokenUsage = new TokenUsage(input, output); + } + catch (Exception) { } + } + + public void Dispose() => _http.Dispose(); +} diff --git a/src/AxCopilot/Services/LlmService.Streaming.cs b/src/AxCopilot/Services/LlmService.Streaming.cs new file mode 100644 index 0000000..e892b35 --- /dev/null +++ b/src/AxCopilot/Services/LlmService.Streaming.cs @@ -0,0 +1,516 @@ +using System.IO; +using System.Net.Http; +using System.Runtime.CompilerServices; +using System.Text; +using System.Text.Json; +using AxCopilot.Models; + +namespace AxCopilot.Services; + +public partial class LlmService +{ + // ─── 스트리밍 응답 ──────────────────────────────────────────────────── + + public async IAsyncEnumerable StreamAsync( + List messages, + [EnumeratorCancellation] CancellationToken ct = default) + { + var activeService = ResolveService(); + var stream = activeService.ToLowerInvariant() switch + { + "gemini" => StreamGeminiAsync(messages, ct), + "claude" => StreamClaudeAsync(messages, ct), + "vllm" => StreamOpenAiCompatibleAsync(messages, ct), + _ => StreamOllamaAsync(messages, ct), + }; + await foreach (var chunk in stream.WithCancellation(ct)) + yield return chunk; + } + + // ─── 연결 테스트 ────────────────────────────────────────────────────── + + public async Task<(bool ok, string message)> TestConnectionAsync() + { + try + { + var llm = _settings.Settings.Llm; + switch (llm.Service.ToLowerInvariant()) + { + case "ollama": + var resp = await _http.GetAsync(llm.Endpoint.TrimEnd('/') + "/api/tags"); + return resp.IsSuccessStatusCode + ? (true, "Ollama 연결 성공") + : (false, ClassifyHttpError(resp)); + + case "vllm": + var vResp = await _http.GetAsync(llm.Endpoint.TrimEnd('/') + "/v1/models"); + return vResp.IsSuccessStatusCode + ? (true, "vLLM 연결 성공") + : (false, ClassifyHttpError(vResp)); + + case "gemini": + var gKey = llm.ApiKey; + if (string.IsNullOrEmpty(gKey)) return (false, "API 키가 설정되지 않았습니다"); + var gResp = await _http.GetAsync( + $"https://generativelanguage.googleapis.com/v1beta/models?key={gKey}"); + return gResp.IsSuccessStatusCode + ? (true, "Gemini API 연결 성공") + : (false, ClassifyHttpError(gResp)); + + case "claude": + { + var cKey = llm.ApiKey; + if (string.IsNullOrEmpty(cKey)) return (false, "API 키가 설정되지 않았습니다"); + using var cReq = new HttpRequestMessage(HttpMethod.Get, "https://api.anthropic.com/v1/models"); + cReq.Headers.Add("x-api-key", cKey); + cReq.Headers.Add("anthropic-version", "2023-06-01"); + var cResp = await _http.SendAsync(cReq); + return cResp.IsSuccessStatusCode + ? (true, "Claude API 연결 성공") + : (false, ClassifyHttpError(cResp)); + } + + default: + return (false, "알 수 없는 서비스"); + } + } + catch (TaskCanceledException) + { + return (false, "연결 시간 초과 — 서버가 응답하지 않습니다"); + } + catch (HttpRequestException ex) + { + return (false, $"연결 실패 — {ex.Message}"); + } + catch (Exception ex) + { + return (false, ex.Message); + } + } + + // ═══════════════════════════════════════════════════════════════════════ + // Ollama + // ═══════════════════════════════════════════════════════════════════════ + + private async Task SendOllamaAsync(List messages, CancellationToken ct) + { + var llm = _settings.Settings.Llm; + var (endpoint, _) = ResolveServerInfo(); + var ep = string.IsNullOrEmpty(endpoint) ? llm.Endpoint : endpoint; + var body = BuildOllamaBody(messages, stream: false); + var resp = await PostJsonWithRetryAsync(ep.TrimEnd('/') + "/api/chat", body, ct); + return SafeParseJson(resp, root => + { + TryParseOllamaUsage(root); + return root.GetProperty("message").GetProperty("content").GetString() ?? ""; + }, "Ollama 응답"); + } + + private async IAsyncEnumerable StreamOllamaAsync( + List messages, + [EnumeratorCancellation] CancellationToken ct) + { + var llm = _settings.Settings.Llm; + var (endpoint, _) = ResolveServerInfo(); + var ep = string.IsNullOrEmpty(endpoint) ? llm.Endpoint : endpoint; + var body = BuildOllamaBody(messages, stream: true); + var url = ep.TrimEnd('/') + "/api/chat"; + + using var req = new HttpRequestMessage(HttpMethod.Post, url) { Content = JsonContent(body) }; + using var resp = await SendWithErrorClassificationAsync(req, ct); + + using var stream = await resp.Content.ReadAsStreamAsync(ct); + using var reader = new StreamReader(stream); + + while (!reader.EndOfStream && !ct.IsCancellationRequested) + { + var line = await ReadLineWithTimeoutAsync(reader, ct); + if (line == null) break; + if (string.IsNullOrEmpty(line)) continue; + + string? text = null; + try + { + using var doc = JsonDocument.Parse(line); + if (doc.RootElement.TryGetProperty("message", out var msg) && + msg.TryGetProperty("content", out var c)) + text = c.GetString(); + // Ollama: done=true 시 토큰 사용량 포함 + if (doc.RootElement.TryGetProperty("done", out var done) && done.GetBoolean()) + TryParseOllamaUsage(doc.RootElement); + } + catch (JsonException ex) + { + LogService.Warn($"Ollama 스트리밍 JSON 파싱 오류: {ex.Message}"); + } + if (!string.IsNullOrEmpty(text)) yield return text; + } + } + + private object BuildOllamaBody(List messages, bool stream) + { + var llm = _settings.Settings.Llm; + var msgs = BuildMessageList(messages); + return new + { + model = ResolveModelName(), + messages = msgs, + stream = stream, + options = new { temperature = llm.Temperature } + }; + } + + // ═══════════════════════════════════════════════════════════════════════ + // OpenAI-Compatible (vLLM) + // ═══════════════════════════════════════════════════════════════════════ + + private async Task SendOpenAiCompatibleAsync(List messages, CancellationToken ct) + { + var llm = _settings.Settings.Llm; + var (endpoint, _) = ResolveServerInfo(); + var ep = string.IsNullOrEmpty(endpoint) ? llm.Endpoint : endpoint; + var body = BuildOpenAiBody(messages, stream: false); + var url = ep.TrimEnd('/') + "/v1/chat/completions"; + var json = JsonSerializer.Serialize(body); + + using var req = new HttpRequestMessage(HttpMethod.Post, url) + { + Content = new StringContent(json, Encoding.UTF8, "application/json") + }; + await ApplyAuthHeaderAsync(req, ct); + + using var resp = await SendWithErrorClassificationAsync(req, ct); + var respBody = await resp.Content.ReadAsStringAsync(ct); + return SafeParseJson(respBody, root => + { + TryParseOpenAiUsage(root); + var choices = root.GetProperty("choices"); + if (choices.GetArrayLength() == 0) return "(빈 응답)"; + return choices[0].GetProperty("message").GetProperty("content").GetString() ?? ""; + }, "vLLM 응답"); + } + + private async IAsyncEnumerable StreamOpenAiCompatibleAsync( + List messages, + [EnumeratorCancellation] CancellationToken ct) + { + var llm = _settings.Settings.Llm; + var (endpoint, _) = ResolveServerInfo(); + var ep = string.IsNullOrEmpty(endpoint) ? llm.Endpoint : endpoint; + var body = BuildOpenAiBody(messages, stream: true); + var url = ep.TrimEnd('/') + "/v1/chat/completions"; + + using var req = new HttpRequestMessage(HttpMethod.Post, url) { Content = JsonContent(body) }; + await ApplyAuthHeaderAsync(req, ct); + using var resp = await SendWithErrorClassificationAsync(req, ct); + + using var stream = await resp.Content.ReadAsStreamAsync(ct); + using var reader = new StreamReader(stream); + + while (!reader.EndOfStream && !ct.IsCancellationRequested) + { + var line = await ReadLineWithTimeoutAsync(reader, ct); + if (line == null) break; + if (string.IsNullOrEmpty(line) || !line.StartsWith("data: ")) continue; + var data = line["data: ".Length..]; + if (data == "[DONE]") break; + + string? text = null; + try + { + using var doc = JsonDocument.Parse(data); + TryParseOpenAiUsage(doc.RootElement); + var choices = doc.RootElement.GetProperty("choices"); + if (choices.GetArrayLength() > 0) + { + var delta = choices[0].GetProperty("delta"); + if (delta.TryGetProperty("content", out var c)) + text = c.GetString(); + } + } + catch (JsonException ex) + { + LogService.Warn($"vLLM 스트리밍 JSON 파싱 오류: {ex.Message}"); + } + if (!string.IsNullOrEmpty(text)) yield return text; + } + } + + private object BuildOpenAiBody(List messages, bool stream) + { + var llm = _settings.Settings.Llm; + var msgs = BuildMessageList(messages, openAiVision: true); + return new + { + model = ResolveModelName(), + messages = msgs, + stream = stream, + temperature = llm.Temperature, + max_tokens = llm.MaxContextTokens + }; + } + + // ═══════════════════════════════════════════════════════════════════════ + // Gemini + // ═══════════════════════════════════════════════════════════════════════ + + private async Task SendGeminiAsync(List messages, CancellationToken ct) + { + var llm = _settings.Settings.Llm; + var apiKey = ResolveApiKeyForService("gemini"); + if (string.IsNullOrEmpty(apiKey)) + throw new InvalidOperationException("Gemini API 키가 설정되지 않았습니다. 설정 > AX Agent에서 API 키를 입력하세요."); + + var model = ResolveModel(); + var body = BuildGeminiBody(messages); + var url = $"https://generativelanguage.googleapis.com/v1beta/models/{model}:generateContent?key={apiKey}"; + var resp = await PostJsonWithRetryAsync(url, body, ct); + return SafeParseJson(resp, root => + { + TryParseGeminiUsage(root); + var candidates = root.GetProperty("candidates"); + if (candidates.GetArrayLength() == 0) return "(빈 응답)"; + var parts = candidates[0].GetProperty("content").GetProperty("parts"); + if (parts.GetArrayLength() == 0) return "(빈 응답)"; + return parts[0].GetProperty("text").GetString() ?? ""; + }, "Gemini 응답"); + } + + private async IAsyncEnumerable StreamGeminiAsync( + List messages, + [EnumeratorCancellation] CancellationToken ct) + { + var llm = _settings.Settings.Llm; + var apiKey = ResolveApiKeyForService("gemini"); + if (string.IsNullOrEmpty(apiKey)) + throw new InvalidOperationException("Gemini API 키가 설정되지 않았습니다."); + + var model = ResolveModel(); + var body = BuildGeminiBody(messages); + var url = $"https://generativelanguage.googleapis.com/v1beta/models/{model}:streamGenerateContent?alt=sse&key={apiKey}"; + + using var req = new HttpRequestMessage(HttpMethod.Post, url) { Content = JsonContent(body) }; + using var resp = await SendWithErrorClassificationAsync(req, ct); + + using var stream = await resp.Content.ReadAsStreamAsync(ct); + using var reader = new StreamReader(stream); + + while (!reader.EndOfStream && !ct.IsCancellationRequested) + { + var line = await ReadLineWithTimeoutAsync(reader, ct); + if (line == null) break; + if (string.IsNullOrEmpty(line) || !line.StartsWith("data: ")) continue; + var data = line["data: ".Length..]; + string? parsed = null; + try + { + using var doc = JsonDocument.Parse(data); + TryParseGeminiUsage(doc.RootElement); + var candidates = doc.RootElement.GetProperty("candidates"); + if (candidates.GetArrayLength() == 0) continue; + var sb = new StringBuilder(); + var parts = candidates[0].GetProperty("content").GetProperty("parts"); + foreach (var part in parts.EnumerateArray()) + { + if (part.TryGetProperty("text", out var t)) + { + var text = t.GetString(); + if (!string.IsNullOrEmpty(text)) sb.Append(text); + } + } + if (sb.Length > 0) parsed = sb.ToString(); + } + catch (JsonException ex) + { + LogService.Warn($"Gemini 스트리밍 JSON 파싱 오류: {ex.Message}"); + } + if (parsed != null) yield return parsed; + } + } + + private object BuildGeminiBody(List messages) + { + var llm = _settings.Settings.Llm; + var contents = new List(); + + object? systemInstruction = null; + if (!string.IsNullOrEmpty(_systemPrompt)) + { + systemInstruction = new { parts = new[] { new { text = _systemPrompt } } }; + } + + foreach (var m in messages) + { + if (m.Role == "system") continue; + var parts = new List { new { text = m.Content } }; + if (m.Images?.Count > 0) + { + foreach (var img in m.Images) + parts.Add(new { inlineData = new { mimeType = img.MimeType, data = img.Base64 } }); + } + contents.Add(new + { + role = m.Role == "assistant" ? "model" : "user", + parts + }); + } + + if (systemInstruction != null) + return new + { + systemInstruction, + contents, + generationConfig = new { temperature = llm.Temperature, maxOutputTokens = llm.MaxContextTokens } + }; + + return new + { + contents, + generationConfig = new { temperature = llm.Temperature, maxOutputTokens = llm.MaxContextTokens } + }; + } + + // ═══════════════════════════════════════════════════════════════════════ + // Claude (Anthropic Messages API) + // ═══════════════════════════════════════════════════════════════════════ + + private async Task SendClaudeAsync(List messages, CancellationToken ct) + { + var llm = _settings.Settings.Llm; + var apiKey = llm.ApiKey; + if (string.IsNullOrEmpty(apiKey)) + throw new InvalidOperationException("Claude API 키가 설정되지 않았습니다. 설정 > AX Agent에서 API 키를 입력하세요."); + + var body = BuildClaudeBody(messages, stream: false); + var json = JsonSerializer.Serialize(body); + using var req = new HttpRequestMessage(HttpMethod.Post, "https://api.anthropic.com/v1/messages"); + req.Content = new StringContent(json, Encoding.UTF8, "application/json"); + req.Headers.Add("x-api-key", apiKey); + req.Headers.Add("anthropic-version", "2023-06-01"); + + using var resp = await _http.SendAsync(req, ct); + if (!resp.IsSuccessStatusCode) + { + var errBody = await resp.Content.ReadAsStringAsync(ct); + throw new HttpRequestException(ClassifyHttpError(resp, errBody)); + } + + var respJson = await resp.Content.ReadAsStringAsync(ct); + return SafeParseJson(respJson, root => + { + TryParseClaudeUsage(root); + var content = root.GetProperty("content"); + if (content.GetArrayLength() == 0) return "(빈 응답)"; + return content[0].GetProperty("text").GetString() ?? ""; + }, "Claude 응답"); + } + + private async IAsyncEnumerable StreamClaudeAsync( + List messages, + [EnumeratorCancellation] CancellationToken ct) + { + var llm = _settings.Settings.Llm; + var apiKey = llm.ApiKey; + if (string.IsNullOrEmpty(apiKey)) + throw new InvalidOperationException("Claude API 키가 설정되지 않았습니다."); + + var body = BuildClaudeBody(messages, stream: true); + var json = JsonSerializer.Serialize(body); + using var req = new HttpRequestMessage(HttpMethod.Post, "https://api.anthropic.com/v1/messages"); + req.Content = new StringContent(json, Encoding.UTF8, "application/json"); + req.Headers.Add("x-api-key", apiKey); + req.Headers.Add("anthropic-version", "2023-06-01"); + + using var resp = await _http.SendAsync(req, HttpCompletionOption.ResponseHeadersRead, ct); + if (!resp.IsSuccessStatusCode) + { + var errBody = await resp.Content.ReadAsStringAsync(ct); + throw new HttpRequestException(ClassifyHttpError(resp, errBody)); + } + + using var stream = await resp.Content.ReadAsStreamAsync(ct); + using var reader = new StreamReader(stream); + + while (!reader.EndOfStream && !ct.IsCancellationRequested) + { + var line = await ReadLineWithTimeoutAsync(reader, ct); + if (line == null) break; + if (string.IsNullOrEmpty(line) || !line.StartsWith("data: ")) continue; + var data = line["data: ".Length..]; + + string? text = null; + try + { + using var doc = JsonDocument.Parse(data); + var type = doc.RootElement.GetProperty("type").GetString(); + if (type == "content_block_delta") + { + var delta = doc.RootElement.GetProperty("delta"); + if (delta.TryGetProperty("text", out var t)) + text = t.GetString(); + } + else if (type is "message_start" or "message_delta") + { + // message_start: usage in .message.usage, message_delta: usage in .usage + if (doc.RootElement.TryGetProperty("message", out var msg) && + msg.TryGetProperty("usage", out var u1)) + TryParseClaudeUsageFromElement(u1); + else if (doc.RootElement.TryGetProperty("usage", out var u2)) + TryParseClaudeUsageFromElement(u2); + } + } + catch (JsonException ex) + { + LogService.Warn($"Claude 스트리밍 JSON 파싱 오류: {ex.Message}"); + } + if (!string.IsNullOrEmpty(text)) yield return text; + } + } + + private object BuildClaudeBody(List messages, bool stream) + { + var llm = _settings.Settings.Llm; + var msgs = new List(); + + foreach (var m in messages) + { + if (m.Role == "system") continue; + if (m.Images?.Count > 0) + { + // Claude Vision: content를 배열로 변환 (이미지 + 텍스트) + var contentParts = new List(); + foreach (var img in m.Images) + contentParts.Add(new { type = "image", source = new { type = "base64", media_type = img.MimeType, data = img.Base64 } }); + contentParts.Add(new { type = "text", text = m.Content }); + msgs.Add(new { role = m.Role, content = contentParts }); + } + else + { + msgs.Add(new { role = m.Role, content = m.Content }); + } + } + + var activeModel = ResolveModel(); + if (!string.IsNullOrEmpty(_systemPrompt)) + { + return new + { + model = activeModel, + max_tokens = llm.MaxContextTokens, + temperature = llm.Temperature, + system = _systemPrompt, + messages = msgs, + stream + }; + } + + return new + { + model = activeModel, + max_tokens = llm.MaxContextTokens, + temperature = llm.Temperature, + messages = msgs, + stream + }; + } +} diff --git a/src/AxCopilot/Services/LlmService.cs b/src/AxCopilot/Services/LlmService.cs index 5fa5f50..f16b484 100644 --- a/src/AxCopilot/Services/LlmService.cs +++ b/src/AxCopilot/Services/LlmService.cs @@ -1,6 +1,5 @@ using System.IO; using System.Net.Http; -using System.Runtime.CompilerServices; using System.Text; using System.Text.Json; using AxCopilot.Models; @@ -261,750 +260,4 @@ public partial class LlmService : IDisposable }; } - // ─── 스트리밍 응답 ──────────────────────────────────────────────────── - - public async IAsyncEnumerable StreamAsync( - List messages, - [EnumeratorCancellation] CancellationToken ct = default) - { - var activeService = ResolveService(); - var stream = activeService.ToLowerInvariant() switch - { - "gemini" => StreamGeminiAsync(messages, ct), - "claude" => StreamClaudeAsync(messages, ct), - "vllm" => StreamOpenAiCompatibleAsync(messages, ct), - _ => StreamOllamaAsync(messages, ct), - }; - await foreach (var chunk in stream.WithCancellation(ct)) - yield return chunk; - } - - // ─── 연결 테스트 ────────────────────────────────────────────────────── - - public async Task<(bool ok, string message)> TestConnectionAsync() - { - try - { - var llm = _settings.Settings.Llm; - switch (llm.Service.ToLowerInvariant()) - { - case "ollama": - var resp = await _http.GetAsync(llm.Endpoint.TrimEnd('/') + "/api/tags"); - return resp.IsSuccessStatusCode - ? (true, "Ollama 연결 성공") - : (false, ClassifyHttpError(resp)); - - case "vllm": - var vResp = await _http.GetAsync(llm.Endpoint.TrimEnd('/') + "/v1/models"); - return vResp.IsSuccessStatusCode - ? (true, "vLLM 연결 성공") - : (false, ClassifyHttpError(vResp)); - - case "gemini": - var gKey = llm.ApiKey; - if (string.IsNullOrEmpty(gKey)) return (false, "API 키가 설정되지 않았습니다"); - var gResp = await _http.GetAsync( - $"https://generativelanguage.googleapis.com/v1beta/models?key={gKey}"); - return gResp.IsSuccessStatusCode - ? (true, "Gemini API 연결 성공") - : (false, ClassifyHttpError(gResp)); - - case "claude": - { - var cKey = llm.ApiKey; - if (string.IsNullOrEmpty(cKey)) return (false, "API 키가 설정되지 않았습니다"); - using var cReq = new HttpRequestMessage(HttpMethod.Get, "https://api.anthropic.com/v1/models"); - cReq.Headers.Add("x-api-key", cKey); - cReq.Headers.Add("anthropic-version", "2023-06-01"); - var cResp = await _http.SendAsync(cReq); - return cResp.IsSuccessStatusCode - ? (true, "Claude API 연결 성공") - : (false, ClassifyHttpError(cResp)); - } - - default: - return (false, "알 수 없는 서비스"); - } - } - catch (TaskCanceledException) - { - return (false, "연결 시간 초과 — 서버가 응답하지 않습니다"); - } - catch (HttpRequestException ex) - { - return (false, $"연결 실패 — {ex.Message}"); - } - catch (Exception ex) - { - return (false, ex.Message); - } - } - - // ═══════════════════════════════════════════════════════════════════════ - // Ollama - // ═══════════════════════════════════════════════════════════════════════ - - private async Task SendOllamaAsync(List messages, CancellationToken ct) - { - var llm = _settings.Settings.Llm; - var (endpoint, _) = ResolveServerInfo(); - var ep = string.IsNullOrEmpty(endpoint) ? llm.Endpoint : endpoint; - var body = BuildOllamaBody(messages, stream: false); - var resp = await PostJsonWithRetryAsync(ep.TrimEnd('/') + "/api/chat", body, ct); - return SafeParseJson(resp, root => - { - TryParseOllamaUsage(root); - return root.GetProperty("message").GetProperty("content").GetString() ?? ""; - }, "Ollama 응답"); - } - - private async IAsyncEnumerable StreamOllamaAsync( - List messages, - [EnumeratorCancellation] CancellationToken ct) - { - var llm = _settings.Settings.Llm; - var (endpoint, _) = ResolveServerInfo(); - var ep = string.IsNullOrEmpty(endpoint) ? llm.Endpoint : endpoint; - var body = BuildOllamaBody(messages, stream: true); - var url = ep.TrimEnd('/') + "/api/chat"; - - using var req = new HttpRequestMessage(HttpMethod.Post, url) { Content = JsonContent(body) }; - using var resp = await SendWithErrorClassificationAsync(req, ct); - - using var stream = await resp.Content.ReadAsStreamAsync(ct); - using var reader = new StreamReader(stream); - - while (!reader.EndOfStream && !ct.IsCancellationRequested) - { - var line = await ReadLineWithTimeoutAsync(reader, ct); - if (line == null) break; - if (string.IsNullOrEmpty(line)) continue; - - string? text = null; - try - { - using var doc = JsonDocument.Parse(line); - if (doc.RootElement.TryGetProperty("message", out var msg) && - msg.TryGetProperty("content", out var c)) - text = c.GetString(); - // Ollama: done=true 시 토큰 사용량 포함 - if (doc.RootElement.TryGetProperty("done", out var done) && done.GetBoolean()) - TryParseOllamaUsage(doc.RootElement); - } - catch (JsonException ex) - { - LogService.Warn($"Ollama 스트리밍 JSON 파싱 오류: {ex.Message}"); - } - if (!string.IsNullOrEmpty(text)) yield return text; - } - } - - private object BuildOllamaBody(List messages, bool stream) - { - var llm = _settings.Settings.Llm; - var msgs = BuildMessageList(messages); - return new - { - model = ResolveModelName(), - messages = msgs, - stream = stream, - options = new { temperature = llm.Temperature } - }; - } - - // ═══════════════════════════════════════════════════════════════════════ - // OpenAI-Compatible (vLLM) - // ═══════════════════════════════════════════════════════════════════════ - - private async Task SendOpenAiCompatibleAsync(List messages, CancellationToken ct) - { - var llm = _settings.Settings.Llm; - var (endpoint, _) = ResolveServerInfo(); - var ep = string.IsNullOrEmpty(endpoint) ? llm.Endpoint : endpoint; - var body = BuildOpenAiBody(messages, stream: false); - var url = ep.TrimEnd('/') + "/v1/chat/completions"; - var json = JsonSerializer.Serialize(body); - - using var req = new HttpRequestMessage(HttpMethod.Post, url) - { - Content = new StringContent(json, Encoding.UTF8, "application/json") - }; - await ApplyAuthHeaderAsync(req, ct); - - using var resp = await SendWithErrorClassificationAsync(req, ct); - var respBody = await resp.Content.ReadAsStringAsync(ct); - return SafeParseJson(respBody, root => - { - TryParseOpenAiUsage(root); - var choices = root.GetProperty("choices"); - if (choices.GetArrayLength() == 0) return "(빈 응답)"; - return choices[0].GetProperty("message").GetProperty("content").GetString() ?? ""; - }, "vLLM 응답"); - } - - private async IAsyncEnumerable StreamOpenAiCompatibleAsync( - List messages, - [EnumeratorCancellation] CancellationToken ct) - { - var llm = _settings.Settings.Llm; - var (endpoint, _) = ResolveServerInfo(); - var ep = string.IsNullOrEmpty(endpoint) ? llm.Endpoint : endpoint; - var body = BuildOpenAiBody(messages, stream: true); - var url = ep.TrimEnd('/') + "/v1/chat/completions"; - - using var req = new HttpRequestMessage(HttpMethod.Post, url) { Content = JsonContent(body) }; - await ApplyAuthHeaderAsync(req, ct); - using var resp = await SendWithErrorClassificationAsync(req, ct); - - using var stream = await resp.Content.ReadAsStreamAsync(ct); - using var reader = new StreamReader(stream); - - while (!reader.EndOfStream && !ct.IsCancellationRequested) - { - var line = await ReadLineWithTimeoutAsync(reader, ct); - if (line == null) break; - if (string.IsNullOrEmpty(line) || !line.StartsWith("data: ")) continue; - var data = line["data: ".Length..]; - if (data == "[DONE]") break; - - string? text = null; - try - { - using var doc = JsonDocument.Parse(data); - TryParseOpenAiUsage(doc.RootElement); - var choices = doc.RootElement.GetProperty("choices"); - if (choices.GetArrayLength() > 0) - { - var delta = choices[0].GetProperty("delta"); - if (delta.TryGetProperty("content", out var c)) - text = c.GetString(); - } - } - catch (JsonException ex) - { - LogService.Warn($"vLLM 스트리밍 JSON 파싱 오류: {ex.Message}"); - } - if (!string.IsNullOrEmpty(text)) yield return text; - } - } - - private object BuildOpenAiBody(List messages, bool stream) - { - var llm = _settings.Settings.Llm; - var msgs = BuildMessageList(messages, openAiVision: true); - return new - { - model = ResolveModelName(), - messages = msgs, - stream = stream, - temperature = llm.Temperature, - max_tokens = llm.MaxContextTokens - }; - } - - // ═══════════════════════════════════════════════════════════════════════ - // Gemini - // ═══════════════════════════════════════════════════════════════════════ - - private async Task SendGeminiAsync(List messages, CancellationToken ct) - { - var llm = _settings.Settings.Llm; - var apiKey = ResolveApiKeyForService("gemini"); - if (string.IsNullOrEmpty(apiKey)) - throw new InvalidOperationException("Gemini API 키가 설정되지 않았습니다. 설정 > AX Agent에서 API 키를 입력하세요."); - - var model = ResolveModel(); - var body = BuildGeminiBody(messages); - var url = $"https://generativelanguage.googleapis.com/v1beta/models/{model}:generateContent?key={apiKey}"; - var resp = await PostJsonWithRetryAsync(url, body, ct); - return SafeParseJson(resp, root => - { - TryParseGeminiUsage(root); - var candidates = root.GetProperty("candidates"); - if (candidates.GetArrayLength() == 0) return "(빈 응답)"; - var parts = candidates[0].GetProperty("content").GetProperty("parts"); - if (parts.GetArrayLength() == 0) return "(빈 응답)"; - return parts[0].GetProperty("text").GetString() ?? ""; - }, "Gemini 응답"); - } - - private async IAsyncEnumerable StreamGeminiAsync( - List messages, - [EnumeratorCancellation] CancellationToken ct) - { - var llm = _settings.Settings.Llm; - var apiKey = ResolveApiKeyForService("gemini"); - if (string.IsNullOrEmpty(apiKey)) - throw new InvalidOperationException("Gemini API 키가 설정되지 않았습니다."); - - var model = ResolveModel(); - var body = BuildGeminiBody(messages); - var url = $"https://generativelanguage.googleapis.com/v1beta/models/{model}:streamGenerateContent?alt=sse&key={apiKey}"; - - using var req = new HttpRequestMessage(HttpMethod.Post, url) { Content = JsonContent(body) }; - using var resp = await SendWithErrorClassificationAsync(req, ct); - - using var stream = await resp.Content.ReadAsStreamAsync(ct); - using var reader = new StreamReader(stream); - - while (!reader.EndOfStream && !ct.IsCancellationRequested) - { - var line = await ReadLineWithTimeoutAsync(reader, ct); - if (line == null) break; - if (string.IsNullOrEmpty(line) || !line.StartsWith("data: ")) continue; - var data = line["data: ".Length..]; - string? parsed = null; - try - { - using var doc = JsonDocument.Parse(data); - TryParseGeminiUsage(doc.RootElement); - var candidates = doc.RootElement.GetProperty("candidates"); - if (candidates.GetArrayLength() == 0) continue; - var sb = new StringBuilder(); - var parts = candidates[0].GetProperty("content").GetProperty("parts"); - foreach (var part in parts.EnumerateArray()) - { - if (part.TryGetProperty("text", out var t)) - { - var text = t.GetString(); - if (!string.IsNullOrEmpty(text)) sb.Append(text); - } - } - if (sb.Length > 0) parsed = sb.ToString(); - } - catch (JsonException ex) - { - LogService.Warn($"Gemini 스트리밍 JSON 파싱 오류: {ex.Message}"); - } - if (parsed != null) yield return parsed; - } - } - - private object BuildGeminiBody(List messages) - { - var llm = _settings.Settings.Llm; - var contents = new List(); - - object? systemInstruction = null; - if (!string.IsNullOrEmpty(_systemPrompt)) - { - systemInstruction = new { parts = new[] { new { text = _systemPrompt } } }; - } - - foreach (var m in messages) - { - if (m.Role == "system") continue; - var parts = new List { new { text = m.Content } }; - if (m.Images?.Count > 0) - { - foreach (var img in m.Images) - parts.Add(new { inlineData = new { mimeType = img.MimeType, data = img.Base64 } }); - } - contents.Add(new - { - role = m.Role == "assistant" ? "model" : "user", - parts - }); - } - - if (systemInstruction != null) - return new - { - systemInstruction, - contents, - generationConfig = new { temperature = llm.Temperature, maxOutputTokens = llm.MaxContextTokens } - }; - - return new - { - contents, - generationConfig = new { temperature = llm.Temperature, maxOutputTokens = llm.MaxContextTokens } - }; - } - - // ═══════════════════════════════════════════════════════════════════════ - // Claude (Anthropic Messages API) - // ═══════════════════════════════════════════════════════════════════════ - - private async Task SendClaudeAsync(List messages, CancellationToken ct) - { - var llm = _settings.Settings.Llm; - var apiKey = llm.ApiKey; - if (string.IsNullOrEmpty(apiKey)) - throw new InvalidOperationException("Claude API 키가 설정되지 않았습니다. 설정 > AX Agent에서 API 키를 입력하세요."); - - var body = BuildClaudeBody(messages, stream: false); - var json = JsonSerializer.Serialize(body); - using var req = new HttpRequestMessage(HttpMethod.Post, "https://api.anthropic.com/v1/messages"); - req.Content = new StringContent(json, Encoding.UTF8, "application/json"); - req.Headers.Add("x-api-key", apiKey); - req.Headers.Add("anthropic-version", "2023-06-01"); - - using var resp = await _http.SendAsync(req, ct); - if (!resp.IsSuccessStatusCode) - { - var errBody = await resp.Content.ReadAsStringAsync(ct); - throw new HttpRequestException(ClassifyHttpError(resp, errBody)); - } - - var respJson = await resp.Content.ReadAsStringAsync(ct); - return SafeParseJson(respJson, root => - { - TryParseClaudeUsage(root); - var content = root.GetProperty("content"); - if (content.GetArrayLength() == 0) return "(빈 응답)"; - return content[0].GetProperty("text").GetString() ?? ""; - }, "Claude 응답"); - } - - private async IAsyncEnumerable StreamClaudeAsync( - List messages, - [EnumeratorCancellation] CancellationToken ct) - { - var llm = _settings.Settings.Llm; - var apiKey = llm.ApiKey; - if (string.IsNullOrEmpty(apiKey)) - throw new InvalidOperationException("Claude API 키가 설정되지 않았습니다."); - - var body = BuildClaudeBody(messages, stream: true); - var json = JsonSerializer.Serialize(body); - using var req = new HttpRequestMessage(HttpMethod.Post, "https://api.anthropic.com/v1/messages"); - req.Content = new StringContent(json, Encoding.UTF8, "application/json"); - req.Headers.Add("x-api-key", apiKey); - req.Headers.Add("anthropic-version", "2023-06-01"); - - using var resp = await _http.SendAsync(req, HttpCompletionOption.ResponseHeadersRead, ct); - if (!resp.IsSuccessStatusCode) - { - var errBody = await resp.Content.ReadAsStringAsync(ct); - throw new HttpRequestException(ClassifyHttpError(resp, errBody)); - } - - using var stream = await resp.Content.ReadAsStreamAsync(ct); - using var reader = new StreamReader(stream); - - while (!reader.EndOfStream && !ct.IsCancellationRequested) - { - var line = await ReadLineWithTimeoutAsync(reader, ct); - if (line == null) break; - if (string.IsNullOrEmpty(line) || !line.StartsWith("data: ")) continue; - var data = line["data: ".Length..]; - - string? text = null; - try - { - using var doc = JsonDocument.Parse(data); - var type = doc.RootElement.GetProperty("type").GetString(); - if (type == "content_block_delta") - { - var delta = doc.RootElement.GetProperty("delta"); - if (delta.TryGetProperty("text", out var t)) - text = t.GetString(); - } - else if (type is "message_start" or "message_delta") - { - // message_start: usage in .message.usage, message_delta: usage in .usage - if (doc.RootElement.TryGetProperty("message", out var msg) && - msg.TryGetProperty("usage", out var u1)) - TryParseClaudeUsageFromElement(u1); - else if (doc.RootElement.TryGetProperty("usage", out var u2)) - TryParseClaudeUsageFromElement(u2); - } - } - catch (JsonException ex) - { - LogService.Warn($"Claude 스트리밍 JSON 파싱 오류: {ex.Message}"); - } - if (!string.IsNullOrEmpty(text)) yield return text; - } - } - - private object BuildClaudeBody(List messages, bool stream) - { - var llm = _settings.Settings.Llm; - var msgs = new List(); - - foreach (var m in messages) - { - if (m.Role == "system") continue; - if (m.Images?.Count > 0) - { - // Claude Vision: content를 배열로 변환 (이미지 + 텍스트) - var contentParts = new List(); - foreach (var img in m.Images) - contentParts.Add(new { type = "image", source = new { type = "base64", media_type = img.MimeType, data = img.Base64 } }); - contentParts.Add(new { type = "text", text = m.Content }); - msgs.Add(new { role = m.Role, content = contentParts }); - } - else - { - msgs.Add(new { role = m.Role, content = m.Content }); - } - } - - var activeModel = ResolveModel(); - if (!string.IsNullOrEmpty(_systemPrompt)) - { - return new - { - model = activeModel, - max_tokens = llm.MaxContextTokens, - temperature = llm.Temperature, - system = _systemPrompt, - messages = msgs, - stream - }; - } - - return new - { - model = activeModel, - max_tokens = llm.MaxContextTokens, - temperature = llm.Temperature, - messages = msgs, - stream - }; - } - - // ─── 공용 헬퍼 ───────────────────────────────────────────────────────── - - private List BuildMessageList(List messages, bool openAiVision = false) - { - var result = new List(); - if (!string.IsNullOrEmpty(_systemPrompt)) - result.Add(new { role = "system", content = _systemPrompt }); - - foreach (var m in messages) - { - if (m.Role == "system") continue; - if (m.Images?.Count > 0) - { - if (openAiVision) - { - // OpenAI Vision: content 배열 (text + image_url) - var contentParts = new List(); - contentParts.Add(new { type = "text", text = m.Content }); - foreach (var img in m.Images) - contentParts.Add(new { type = "image_url", image_url = new { url = $"data:{img.MimeType};base64,{img.Base64}" } }); - result.Add(new { role = m.Role, content = contentParts }); - } - else - { - // Ollama Vision: images 필드에 base64 배열 - result.Add(new { role = m.Role, content = m.Content, images = m.Images.Select(i => i.Base64).ToArray() }); - } - } - else - { - result.Add(new { role = m.Role, content = m.Content }); - } - } - return result; - } - - /// 비스트리밍 POST + 재시도 (일시적 오류 시 최대 2회) - private async Task PostJsonWithRetryAsync(string url, object body, CancellationToken ct) - { - var json = JsonSerializer.Serialize(body); - Exception? lastEx = null; - - for (int attempt = 0; attempt <= MaxRetries; attempt++) - { - try - { - using var content = new StringContent(json, Encoding.UTF8, "application/json"); - using var resp = await _http.PostAsync(url, content, ct); - - if (resp.IsSuccessStatusCode) - return await resp.Content.ReadAsStringAsync(ct); - - // 429 Rate Limit → 재시도 - if ((int)resp.StatusCode == 429 && attempt < MaxRetries) - { - await Task.Delay(1000 * (attempt + 1), ct); - continue; - } - - // 그 외 에러 → 분류 후 예외 - var errBody = await resp.Content.ReadAsStringAsync(ct); - throw new HttpRequestException(ClassifyHttpError(resp, errBody)); - } - catch (HttpRequestException) { throw; } - catch (TaskCanceledException) when (!ct.IsCancellationRequested && attempt < MaxRetries) - { - lastEx = new TimeoutException("요청 시간 초과"); - await Task.Delay(1000 * (attempt + 1), ct); - } - } - throw lastEx ?? new HttpRequestException("요청 실패"); - } - - /// 스트리밍 전용 — HTTP 요청 전송 + 에러 분류 - private async Task SendWithErrorClassificationAsync( - HttpRequestMessage req, CancellationToken ct) - { - var resp = await _http.SendAsync(req, HttpCompletionOption.ResponseHeadersRead, ct); - if (!resp.IsSuccessStatusCode) - { - var errBody = await resp.Content.ReadAsStringAsync(ct); - var errorMsg = ClassifyHttpError(resp, errBody); - resp.Dispose(); - throw new HttpRequestException(errorMsg); - } - return resp; - } - - /// 스트리밍 ReadLine에 청크 타임아웃 적용 - private static async Task ReadLineWithTimeoutAsync(StreamReader reader, CancellationToken ct) - { - using var cts = CancellationTokenSource.CreateLinkedTokenSource(ct); - cts.CancelAfter(ChunkTimeout); - try - { - return await reader.ReadLineAsync(cts.Token); - } - catch (OperationCanceledException) when (!ct.IsCancellationRequested) - { - LogService.Warn("스트리밍 청크 타임아웃 (30초 무응답)"); - return null; // 타임아웃 시 스트림 종료 - } - } - - /// JSON 파싱 안전 래퍼 — 파싱 실패 시 상세 에러 메시지 반환 - private static string SafeParseJson(string json, Func extractor, string context) - { - try - { - using var doc = JsonDocument.Parse(json); - - // API 에러 응답 감지 - if (doc.RootElement.TryGetProperty("error", out var error)) - { - var msg = error.TryGetProperty("message", out var m) ? m.GetString() : error.ToString(); - throw new HttpRequestException($"[{context}] API 에러: {msg}"); - } - - return extractor(doc.RootElement); - } - catch (JsonException ex) - { - var preview = json.Length > 200 ? json[..200] + "…" : json; - throw new InvalidOperationException( - $"[{context}] 응답 형식 오류 — 예상하지 못한 JSON 형식입니다.\n파싱 오류: {ex.Message}\n응답 미리보기: {preview}"); - } - catch (KeyNotFoundException) - { - var preview = json.Length > 200 ? json[..200] + "…" : json; - throw new InvalidOperationException( - $"[{context}] 응답에 필요한 필드가 없습니다.\n응답 미리보기: {preview}"); - } - } - - /// HTTP 에러 코드별 사용자 친화적 메시지 - private static string ClassifyHttpError(HttpResponseMessage resp, string? body = null) - { - var code = (int)resp.StatusCode; - var detail = ""; - - // JSON error.message 추출 시도 - if (!string.IsNullOrEmpty(body)) - { - try - { - using var doc = JsonDocument.Parse(body); - if (doc.RootElement.TryGetProperty("error", out var err)) - { - if (err.ValueKind == JsonValueKind.Object && err.TryGetProperty("message", out var m)) - detail = m.GetString() ?? ""; - else if (err.ValueKind == JsonValueKind.String) - detail = err.GetString() ?? ""; - } - } - catch (Exception) { } - } - - var msg = code switch - { - 400 => "잘못된 요청 — 모델 이름이나 요청 형식을 확인하세요", - 401 => "인증 실패 — API 키가 유효하지 않습니다", - 403 => "접근 거부 — API 키 권한을 확인하세요", - 404 => "모델을 찾을 수 없습니다 — 모델 이름을 확인하세요", - 429 => "요청 한도 초과 — 잠시 후 다시 시도하세요", - 500 => "서버 내부 오류 — LLM 서버 상태를 확인하세요", - 502 or 503 => "서버 일시 장애 — 잠시 후 다시 시도하세요", - _ => $"HTTP {code} 오류" - }; - - return string.IsNullOrEmpty(detail) ? msg : $"{msg}\n상세: {detail}"; - } - - private static StringContent JsonContent(object body) - { - var json = JsonSerializer.Serialize(body); - return new StringContent(json, Encoding.UTF8, "application/json"); - } - - // ─── 토큰 사용량 파싱 헬퍼 ────────────────────────────────────────── - - private void TryParseOllamaUsage(JsonElement root) - { - try - { - var prompt = root.TryGetProperty("prompt_eval_count", out var p) ? p.GetInt32() : 0; - var completion = root.TryGetProperty("eval_count", out var e) ? e.GetInt32() : 0; - if (prompt > 0 || completion > 0) - LastTokenUsage = new TokenUsage(prompt, completion); - } - catch (Exception) { } - } - - private void TryParseOpenAiUsage(JsonElement root) - { - try - { - if (!root.TryGetProperty("usage", out var usage)) return; - var prompt = usage.TryGetProperty("prompt_tokens", out var p) ? p.GetInt32() : 0; - var completion = usage.TryGetProperty("completion_tokens", out var c) ? c.GetInt32() : 0; - if (prompt > 0 || completion > 0) - LastTokenUsage = new TokenUsage(prompt, completion); - } - catch (Exception) { } - } - - private void TryParseGeminiUsage(JsonElement root) - { - try - { - if (!root.TryGetProperty("usageMetadata", out var usage)) return; - var prompt = usage.TryGetProperty("promptTokenCount", out var p) ? p.GetInt32() : 0; - var completion = usage.TryGetProperty("candidatesTokenCount", out var c) ? c.GetInt32() : 0; - if (prompt > 0 || completion > 0) - LastTokenUsage = new TokenUsage(prompt, completion); - } - catch (Exception) { } - } - - private void TryParseClaudeUsage(JsonElement root) - { - try - { - if (!root.TryGetProperty("usage", out var usage)) return; - TryParseClaudeUsageFromElement(usage); - } - catch (Exception) { } - } - - private void TryParseClaudeUsageFromElement(JsonElement usage) - { - try - { - var input = usage.TryGetProperty("input_tokens", out var i) ? i.GetInt32() : 0; - var output = usage.TryGetProperty("output_tokens", out var o) ? o.GetInt32() : 0; - if (input > 0 || output > 0) - LastTokenUsage = new TokenUsage(input, output); - } - catch (Exception) { } - } - - public void Dispose() => _http.Dispose(); } diff --git a/src/AxCopilot/Views/ChatWindow.MessageActions.cs b/src/AxCopilot/Views/ChatWindow.MessageActions.cs new file mode 100644 index 0000000..c3bdd92 --- /dev/null +++ b/src/AxCopilot/Views/ChatWindow.MessageActions.cs @@ -0,0 +1,277 @@ +using System.Windows; +using System.Windows.Controls; +using System.Windows.Input; +using System.Windows.Media; +using AxCopilot.Models; + +namespace AxCopilot.Views; + +public partial class ChatWindow +{ + // ─── 버튼 이벤트 ────────────────────────────────────────────────────── + + private void ChatWindow_KeyDown(object sender, KeyEventArgs e) + { + var mod = Keyboard.Modifiers; + + // Ctrl 단축키 + if (mod == ModifierKeys.Control) + { + switch (e.Key) + { + case Key.N: BtnNewChat_Click(this, new RoutedEventArgs()); e.Handled = true; break; + case Key.W: Close(); e.Handled = true; break; + case Key.E: ExportConversation(); e.Handled = true; break; + case Key.L: InputBox.Text = ""; InputBox.Focus(); e.Handled = true; break; + case Key.B: BtnToggleSidebar_Click(this, new RoutedEventArgs()); e.Handled = true; break; + case Key.M: BtnModelSelector_Click(this, new RoutedEventArgs()); e.Handled = true; break; + case Key.OemComma: BtnSettings_Click(this, new RoutedEventArgs()); e.Handled = true; break; + case Key.F: ToggleMessageSearch(); e.Handled = true; break; + case Key.D1: TabChat.IsChecked = true; e.Handled = true; break; + case Key.D2: TabCowork.IsChecked = true; e.Handled = true; break; + case Key.D3: if (TabCode.IsEnabled) TabCode.IsChecked = true; e.Handled = true; break; + } + } + + // Ctrl+Shift 단축키 + if (mod == (ModifierKeys.Control | ModifierKeys.Shift)) + { + switch (e.Key) + { + case Key.C: + // 마지막 AI 응답 복사 + ChatConversation? conv; + lock (_convLock) conv = _currentConversation; + if (conv != null) + { + var lastAi = conv.Messages.LastOrDefault(m => m.Role == "assistant"); + if (lastAi != null) + try { Clipboard.SetText(lastAi.Content); } catch (Exception) { /* 클립보드 접근 실패 */ } + } + e.Handled = true; + break; + case Key.R: + // 마지막 응답 재생성 + _ = RegenerateLastAsync(); + e.Handled = true; + break; + case Key.D: + // 모든 대화 삭제 + BtnDeleteAll_Click(this, new RoutedEventArgs()); + e.Handled = true; + break; + case Key.P: + // 커맨드 팔레트 + OpenCommandPalette(); + e.Handled = true; + break; + } + } + + // Escape: 검색 바 닫기 또는 스트리밍 중지 + if (e.Key == Key.Escape) + { + if (MessageSearchBar.Visibility == Visibility.Visible) { CloseMessageSearch(); e.Handled = true; } + else if (_isStreaming) { StopGeneration(); e.Handled = true; } + } + + // 슬래시 명령 팝업 키 처리 + if (SlashPopup.IsOpen) + { + if (e.Key == Key.Escape) + { + SlashPopup.IsOpen = false; + _slashSelectedIndex = -1; + e.Handled = true; + } + else if (e.Key == Key.Up) + { + SlashPopup_ScrollByDelta(120); // 위로 1칸 + e.Handled = true; + } + else if (e.Key == Key.Down) + { + SlashPopup_ScrollByDelta(-120); // 아래로 1칸 + e.Handled = true; + } + else if (e.Key == Key.Enter && _slashSelectedIndex >= 0) + { + e.Handled = true; + ExecuteSlashSelectedItem(); + } + } + } + + private void BtnStop_Click(object sender, RoutedEventArgs e) => StopGeneration(); + + private void BtnPause_Click(object sender, System.Windows.Input.MouseButtonEventArgs e) + { + if (_agentLoop.IsPaused) + { + _agentLoop.Resume(); + PauseIcon.Text = "\uE769"; // 일시정지 아이콘 + BtnPause.ToolTip = "일시정지"; + } + else + { + _ = _agentLoop.PauseAsync(); + PauseIcon.Text = "\uE768"; // 재생 아이콘 + BtnPause.ToolTip = "재개"; + } + } + private void BtnExport_Click(object sender, RoutedEventArgs e) => ExportConversation(); + + // ─── 메시지 내 검색 (Ctrl+F) ───────────────────────────────────────── + + private List _searchMatchIndices = new(); + private int _searchCurrentIndex = -1; + + private void ToggleMessageSearch() + { + if (MessageSearchBar.Visibility == Visibility.Visible) + CloseMessageSearch(); + else + { + MessageSearchBar.Visibility = Visibility.Visible; + SearchTextBox.Focus(); + SearchTextBox.SelectAll(); + } + } + + private void CloseMessageSearch() + { + MessageSearchBar.Visibility = Visibility.Collapsed; + SearchTextBox.Text = ""; + SearchResultCount.Text = ""; + _searchMatchIndices.Clear(); + _searchCurrentIndex = -1; + // 하이라이트 제거 + ClearSearchHighlights(); + } + + private void SearchTextBox_TextChanged(object sender, TextChangedEventArgs e) + { + var query = SearchTextBox.Text.Trim(); + if (string.IsNullOrEmpty(query)) + { + SearchResultCount.Text = ""; + _searchMatchIndices.Clear(); + _searchCurrentIndex = -1; + ClearSearchHighlights(); + return; + } + + // 현재 대화의 메시지에서 검색 + ChatConversation? conv; + lock (_convLock) conv = _currentConversation; + if (conv == null) return; + + _searchMatchIndices.Clear(); + for (int i = 0; i < conv.Messages.Count; i++) + { + if (conv.Messages[i].Content.Contains(query, StringComparison.OrdinalIgnoreCase)) + _searchMatchIndices.Add(i); + } + + if (_searchMatchIndices.Count > 0) + { + _searchCurrentIndex = 0; + SearchResultCount.Text = $"1/{_searchMatchIndices.Count}"; + HighlightSearchResult(); + } + else + { + _searchCurrentIndex = -1; + SearchResultCount.Text = "결과 없음"; + } + } + + private void SearchPrev_Click(object sender, RoutedEventArgs e) + { + if (_searchMatchIndices.Count == 0) return; + _searchCurrentIndex = (_searchCurrentIndex - 1 + _searchMatchIndices.Count) % _searchMatchIndices.Count; + SearchResultCount.Text = $"{_searchCurrentIndex + 1}/{_searchMatchIndices.Count}"; + HighlightSearchResult(); + } + + private void SearchNext_Click(object sender, RoutedEventArgs e) + { + if (_searchMatchIndices.Count == 0) return; + _searchCurrentIndex = (_searchCurrentIndex + 1) % _searchMatchIndices.Count; + SearchResultCount.Text = $"{_searchCurrentIndex + 1}/{_searchMatchIndices.Count}"; + HighlightSearchResult(); + } + + private void SearchClose_Click(object sender, RoutedEventArgs e) => CloseMessageSearch(); + + private void HighlightSearchResult() + { + if (_searchCurrentIndex < 0 || _searchCurrentIndex >= _searchMatchIndices.Count) return; + var msgIndex = _searchMatchIndices[_searchCurrentIndex]; + + if (msgIndex < MessagePanel.Children.Count) + { + var element = MessagePanel.Children[msgIndex] as FrameworkElement; + element?.BringIntoView(); + } + else if (MessagePanel.Children.Count > 0) + { + // 범위 밖이면 마지막 자식으로 이동 + (MessagePanel.Children[^1] as FrameworkElement)?.BringIntoView(); + } + } + + private void ClearSearchHighlights() + { + // 현재는 BringIntoView 기반이므로 별도 하이라이트 제거 불필요 + } + + // ─── 에러 복구 재시도 버튼 ────────────────────────────────────────────── + + private void AddRetryButton() + { + Dispatcher.Invoke(() => + { + var retryBorder = new Border + { + Background = new SolidColorBrush(Color.FromArgb(0x18, 0xEF, 0x44, 0x44)), + CornerRadius = new CornerRadius(8), + Padding = new Thickness(12, 8, 12, 8), + Margin = new Thickness(40, 4, 80, 4), + HorizontalAlignment = HorizontalAlignment.Left, + Cursor = System.Windows.Input.Cursors.Hand, + }; + var retrySp = new StackPanel { Orientation = Orientation.Horizontal }; + retrySp.Children.Add(new TextBlock + { + Text = "\uE72C", FontFamily = ThemeResourceHelper.SegoeMdl2, + FontSize = 12, Foreground = new SolidColorBrush(Color.FromRgb(0xEF, 0x44, 0x44)), + VerticalAlignment = VerticalAlignment.Center, Margin = new Thickness(0, 0, 6, 0), + }); + retrySp.Children.Add(new TextBlock + { + Text = "재시도", FontSize = 12, FontWeight = FontWeights.SemiBold, + Foreground = new SolidColorBrush(Color.FromRgb(0xEF, 0x44, 0x44)), + VerticalAlignment = VerticalAlignment.Center, + }); + retryBorder.Child = retrySp; + retryBorder.MouseEnter += (s, _) => { if (s is Border b) b.Background = new SolidColorBrush(Color.FromArgb(0x30, 0xEF, 0x44, 0x44)); }; + retryBorder.MouseLeave += (s, _) => { if (s is Border b) b.Background = new SolidColorBrush(Color.FromArgb(0x18, 0xEF, 0x44, 0x44)); }; + retryBorder.MouseLeftButtonUp += (_, _) => + { + lock (_convLock) + { + if (_currentConversation != null) + { + var lastIdx = _currentConversation.Messages.Count - 1; + if (lastIdx >= 0 && _currentConversation.Messages[lastIdx].Role == "assistant") + _currentConversation.Messages.RemoveAt(lastIdx); + } + } + _ = RegenerateLastAsync(); + }; + MessagePanel.Children.Add(retryBorder); + ForceScrollToEnd(); + }); + } +} diff --git a/src/AxCopilot/Views/ChatWindow.ResponseHandling.cs b/src/AxCopilot/Views/ChatWindow.ResponseHandling.cs index 02f5367..f784e8b 100644 --- a/src/AxCopilot/Views/ChatWindow.ResponseHandling.cs +++ b/src/AxCopilot/Views/ChatWindow.ResponseHandling.cs @@ -1,4 +1,4 @@ -using System.Windows; +using System.Windows; using System.Windows.Controls; using System.Windows.Input; using System.Windows.Media; @@ -738,757 +738,4 @@ public partial class ChatWindow sb.AppendLine(""); return sb.ToString(); } - - // ─── 버튼 이벤트 ────────────────────────────────────────────────────── - - private void ChatWindow_KeyDown(object sender, KeyEventArgs e) - { - var mod = Keyboard.Modifiers; - - // Ctrl 단축키 - if (mod == ModifierKeys.Control) - { - switch (e.Key) - { - case Key.N: BtnNewChat_Click(this, new RoutedEventArgs()); e.Handled = true; break; - case Key.W: Close(); e.Handled = true; break; - case Key.E: ExportConversation(); e.Handled = true; break; - case Key.L: InputBox.Text = ""; InputBox.Focus(); e.Handled = true; break; - case Key.B: BtnToggleSidebar_Click(this, new RoutedEventArgs()); e.Handled = true; break; - case Key.M: BtnModelSelector_Click(this, new RoutedEventArgs()); e.Handled = true; break; - case Key.OemComma: BtnSettings_Click(this, new RoutedEventArgs()); e.Handled = true; break; - case Key.F: ToggleMessageSearch(); e.Handled = true; break; - case Key.D1: TabChat.IsChecked = true; e.Handled = true; break; - case Key.D2: TabCowork.IsChecked = true; e.Handled = true; break; - case Key.D3: if (TabCode.IsEnabled) TabCode.IsChecked = true; e.Handled = true; break; - } - } - - // Ctrl+Shift 단축키 - if (mod == (ModifierKeys.Control | ModifierKeys.Shift)) - { - switch (e.Key) - { - case Key.C: - // 마지막 AI 응답 복사 - ChatConversation? conv; - lock (_convLock) conv = _currentConversation; - if (conv != null) - { - var lastAi = conv.Messages.LastOrDefault(m => m.Role == "assistant"); - if (lastAi != null) - try { Clipboard.SetText(lastAi.Content); } catch (Exception) { /* 클립보드 접근 실패 */ } - } - e.Handled = true; - break; - case Key.R: - // 마지막 응답 재생성 - _ = RegenerateLastAsync(); - e.Handled = true; - break; - case Key.D: - // 모든 대화 삭제 - BtnDeleteAll_Click(this, new RoutedEventArgs()); - e.Handled = true; - break; - case Key.P: - // 커맨드 팔레트 - OpenCommandPalette(); - e.Handled = true; - break; - } - } - - // Escape: 검색 바 닫기 또는 스트리밍 중지 - if (e.Key == Key.Escape) - { - if (MessageSearchBar.Visibility == Visibility.Visible) { CloseMessageSearch(); e.Handled = true; } - else if (_isStreaming) { StopGeneration(); e.Handled = true; } - } - - // 슬래시 명령 팝업 키 처리 - if (SlashPopup.IsOpen) - { - if (e.Key == Key.Escape) - { - SlashPopup.IsOpen = false; - _slashSelectedIndex = -1; - e.Handled = true; - } - else if (e.Key == Key.Up) - { - SlashPopup_ScrollByDelta(120); // 위로 1칸 - e.Handled = true; - } - else if (e.Key == Key.Down) - { - SlashPopup_ScrollByDelta(-120); // 아래로 1칸 - e.Handled = true; - } - else if (e.Key == Key.Enter && _slashSelectedIndex >= 0) - { - e.Handled = true; - ExecuteSlashSelectedItem(); - } - } - } - - private void BtnStop_Click(object sender, RoutedEventArgs e) => StopGeneration(); - - private void BtnPause_Click(object sender, System.Windows.Input.MouseButtonEventArgs e) - { - if (_agentLoop.IsPaused) - { - _agentLoop.Resume(); - PauseIcon.Text = "\uE769"; // 일시정지 아이콘 - BtnPause.ToolTip = "일시정지"; - } - else - { - _ = _agentLoop.PauseAsync(); - PauseIcon.Text = "\uE768"; // 재생 아이콘 - BtnPause.ToolTip = "재개"; - } - } - private void BtnExport_Click(object sender, RoutedEventArgs e) => ExportConversation(); - - // ─── 메시지 내 검색 (Ctrl+F) ───────────────────────────────────────── - - private List _searchMatchIndices = new(); - private int _searchCurrentIndex = -1; - - private void ToggleMessageSearch() - { - if (MessageSearchBar.Visibility == Visibility.Visible) - CloseMessageSearch(); - else - { - MessageSearchBar.Visibility = Visibility.Visible; - SearchTextBox.Focus(); - SearchTextBox.SelectAll(); - } - } - - private void CloseMessageSearch() - { - MessageSearchBar.Visibility = Visibility.Collapsed; - SearchTextBox.Text = ""; - SearchResultCount.Text = ""; - _searchMatchIndices.Clear(); - _searchCurrentIndex = -1; - // 하이라이트 제거 - ClearSearchHighlights(); - } - - private void SearchTextBox_TextChanged(object sender, TextChangedEventArgs e) - { - var query = SearchTextBox.Text.Trim(); - if (string.IsNullOrEmpty(query)) - { - SearchResultCount.Text = ""; - _searchMatchIndices.Clear(); - _searchCurrentIndex = -1; - ClearSearchHighlights(); - return; - } - - // 현재 대화의 메시지에서 검색 - ChatConversation? conv; - lock (_convLock) conv = _currentConversation; - if (conv == null) return; - - _searchMatchIndices.Clear(); - for (int i = 0; i < conv.Messages.Count; i++) - { - if (conv.Messages[i].Content.Contains(query, StringComparison.OrdinalIgnoreCase)) - _searchMatchIndices.Add(i); - } - - if (_searchMatchIndices.Count > 0) - { - _searchCurrentIndex = 0; - SearchResultCount.Text = $"1/{_searchMatchIndices.Count}"; - HighlightSearchResult(); - } - else - { - _searchCurrentIndex = -1; - SearchResultCount.Text = "결과 없음"; - } - } - - private void SearchPrev_Click(object sender, RoutedEventArgs e) - { - if (_searchMatchIndices.Count == 0) return; - _searchCurrentIndex = (_searchCurrentIndex - 1 + _searchMatchIndices.Count) % _searchMatchIndices.Count; - SearchResultCount.Text = $"{_searchCurrentIndex + 1}/{_searchMatchIndices.Count}"; - HighlightSearchResult(); - } - - private void SearchNext_Click(object sender, RoutedEventArgs e) - { - if (_searchMatchIndices.Count == 0) return; - _searchCurrentIndex = (_searchCurrentIndex + 1) % _searchMatchIndices.Count; - SearchResultCount.Text = $"{_searchCurrentIndex + 1}/{_searchMatchIndices.Count}"; - HighlightSearchResult(); - } - - private void SearchClose_Click(object sender, RoutedEventArgs e) => CloseMessageSearch(); - - private void HighlightSearchResult() - { - if (_searchCurrentIndex < 0 || _searchCurrentIndex >= _searchMatchIndices.Count) return; - var msgIndex = _searchMatchIndices[_searchCurrentIndex]; - - if (msgIndex < MessagePanel.Children.Count) - { - var element = MessagePanel.Children[msgIndex] as FrameworkElement; - element?.BringIntoView(); - } - else if (MessagePanel.Children.Count > 0) - { - // 범위 밖이면 마지막 자식으로 이동 - (MessagePanel.Children[^1] as FrameworkElement)?.BringIntoView(); - } - } - - private void ClearSearchHighlights() - { - // 현재는 BringIntoView 기반이므로 별도 하이라이트 제거 불필요 - } - - // ─── 에러 복구 재시도 버튼 ────────────────────────────────────────────── - - private void AddRetryButton() - { - Dispatcher.Invoke(() => - { - var retryBorder = new Border - { - Background = new SolidColorBrush(Color.FromArgb(0x18, 0xEF, 0x44, 0x44)), - CornerRadius = new CornerRadius(8), - Padding = new Thickness(12, 8, 12, 8), - Margin = new Thickness(40, 4, 80, 4), - HorizontalAlignment = HorizontalAlignment.Left, - Cursor = System.Windows.Input.Cursors.Hand, - }; - var retrySp = new StackPanel { Orientation = Orientation.Horizontal }; - retrySp.Children.Add(new TextBlock - { - Text = "\uE72C", FontFamily = ThemeResourceHelper.SegoeMdl2, - FontSize = 12, Foreground = new SolidColorBrush(Color.FromRgb(0xEF, 0x44, 0x44)), - VerticalAlignment = VerticalAlignment.Center, Margin = new Thickness(0, 0, 6, 0), - }); - retrySp.Children.Add(new TextBlock - { - Text = "재시도", FontSize = 12, FontWeight = FontWeights.SemiBold, - Foreground = new SolidColorBrush(Color.FromRgb(0xEF, 0x44, 0x44)), - VerticalAlignment = VerticalAlignment.Center, - }); - retryBorder.Child = retrySp; - retryBorder.MouseEnter += (s, _) => { if (s is Border b) b.Background = new SolidColorBrush(Color.FromArgb(0x30, 0xEF, 0x44, 0x44)); }; - retryBorder.MouseLeave += (s, _) => { if (s is Border b) b.Background = new SolidColorBrush(Color.FromArgb(0x18, 0xEF, 0x44, 0x44)); }; - retryBorder.MouseLeftButtonUp += (_, _) => - { - lock (_convLock) - { - if (_currentConversation != null) - { - var lastIdx = _currentConversation.Messages.Count - 1; - if (lastIdx >= 0 && _currentConversation.Messages[lastIdx].Role == "assistant") - _currentConversation.Messages.RemoveAt(lastIdx); - } - } - _ = RegenerateLastAsync(); - }; - MessagePanel.Children.Add(retryBorder); - ForceScrollToEnd(); - }); - } - - // ─── 메시지 우클릭 컨텍스트 메뉴 ─────────────────────────────────────── - - private void ShowMessageContextMenu(string content, string role) - { - var menu = CreateThemedContextMenu(); - var primaryText = ThemeResourceHelper.Primary(this); - var secondaryText = ThemeResourceHelper.Secondary(this); - - void AddItem(string icon, string label, Action action) - { - var sp = new StackPanel { Orientation = Orientation.Horizontal }; - sp.Children.Add(new TextBlock - { - Text = icon, FontFamily = ThemeResourceHelper.SegoeMdl2, - FontSize = 12, Foreground = secondaryText, - VerticalAlignment = VerticalAlignment.Center, Margin = new Thickness(0, 0, 8, 0), - }); - sp.Children.Add(new TextBlock - { - Text = label, FontSize = 12, Foreground = primaryText, - VerticalAlignment = VerticalAlignment.Center, - }); - var mi = new MenuItem { Header = sp, Padding = new Thickness(8, 6, 16, 6) }; - mi.Click += (_, _) => action(); - menu.Items.Add(mi); - } - - // 복사 - AddItem("\uE8C8", "텍스트 복사", () => - { - try { Clipboard.SetText(content); ShowToast("복사되었습니다"); } catch (Exception) { /* 클립보드 접근 실패 */ } - }); - - // 마크다운 복사 - AddItem("\uE943", "마크다운 복사", () => - { - try { Clipboard.SetText(content); ShowToast("마크다운으로 복사됨"); } catch (Exception) { /* 클립보드 접근 실패 */ } - }); - - // 인용하여 답장 - AddItem("\uE97A", "인용하여 답장", () => - { - var quote = content.Length > 200 ? content[..200] + "..." : content; - var lines = quote.Split('\n'); - var quoted = string.Join("\n", lines.Select(l => $"> {l}")); - InputBox.Text = quoted + "\n\n"; - InputBox.Focus(); - InputBox.CaretIndex = InputBox.Text.Length; - }); - - menu.Items.Add(new Separator()); - - // 재생성 (AI 응답만) - if (role == "assistant") - { - AddItem("\uE72C", "응답 재생성", () => _ = RegenerateLastAsync()); - } - - // 대화 분기 (Fork) - AddItem("\uE8A5", "여기서 분기", () => - { - ChatConversation? conv; - lock (_convLock) conv = _currentConversation; - if (conv == null) return; - - var idx = conv.Messages.FindLastIndex(m => m.Role == role && m.Content == content); - if (idx < 0) return; - - ForkConversation(conv, idx); - }); - - menu.Items.Add(new Separator()); - - // 이후 메시지 모두 삭제 - var msgContent = content; - var msgRole = role; - AddItem("\uE74D", "이후 메시지 모두 삭제", () => - { - ChatConversation? conv; - lock (_convLock) conv = _currentConversation; - if (conv == null) return; - - var idx = conv.Messages.FindLastIndex(m => m.Role == msgRole && m.Content == msgContent); - if (idx < 0) return; - - var removeCount = conv.Messages.Count - idx; - if (MessageBox.Show($"이 메시지 포함 {removeCount}개 메시지를 삭제하시겠습니까?", - "메시지 삭제", MessageBoxButton.YesNo, MessageBoxImage.Warning) != MessageBoxResult.Yes) - return; - - conv.Messages.RemoveRange(idx, removeCount); - try { _storage.Save(conv); } catch (Exception ex) { Services.LogService.Debug($"대화 저장 실패: {ex.Message}"); } - RenderMessages(); - ShowToast($"{removeCount}개 메시지 삭제됨"); - }); - - menu.IsOpen = true; - } - - // ─── 팁 알림 ────────────────────────────────────────────────────── - - private static readonly string[] Tips = - [ - "💡 작업 폴더에 AX.md 파일을 만들면 매번 시스템 프롬프트에 자동 주입됩니다. 프로젝트 설계 원칙이나 코딩 규칙을 기록하세요.", - "💡 Ctrl+1/2/3으로 Chat/Cowork/Code 탭을 빠르게 전환할 수 있습니다.", - "💡 Ctrl+F로 현재 대화 내 메시지를 검색할 수 있습니다.", - "💡 메시지를 우클릭하면 복사, 인용 답장, 재생성, 삭제를 할 수 있습니다.", - "💡 코드 블록을 더블클릭하면 전체화면으로 볼 수 있고, 💾 버튼으로 파일 저장이 가능합니다.", - "💡 Cowork 에이전트가 만든 파일은 자동으로 날짜_시간 접미사가 붙어 덮어쓰기를 방지합니다.", - "💡 Code 탭에서 개발 언어를 선택하면 해당 언어 우선으로 코드를 생성합니다.", - "💡 파일 탐색기(하단 바 '파일' 버튼)에서 더블클릭으로 프리뷰, 우클릭으로 관리할 수 있습니다.", - "💡 에이전트가 계획을 제시하면 '수정 요청'으로 방향을 바꾸거나 '취소'로 중단할 수 있습니다.", - "💡 Code 탭은 빌드/테스트를 자동으로 실행합니다. 프로젝트 폴더를 먼저 선택하세요.", - "💡 무드 갤러리에서 10가지 디자인 템플릿 중 원하는 스타일을 미리보기로 선택할 수 있습니다.", - "💡 Git 연동: Code 탭에서 에이전트가 git status, diff, commit을 수행합니다. (push는 직접)", - "💡 설정 → AX Agent → 공통에서 개발자 모드를 켜면 에이전트 동작을 스텝별로 검증할 수 있습니다.", - "💡 트레이 아이콘 우클릭 → '사용 통계'에서 대화 빈도와 토큰 사용량을 확인할 수 있습니다.", - "💡 대화 제목을 클릭하면 이름을 변경할 수 있습니다.", - "💡 LLM 오류 발생 시 '재시도' 버튼이 자동으로 나타납니다.", - "💡 검색란에서 대화 제목뿐 아니라 첫 메시지 내용까지 검색됩니다.", - "💡 프리셋 선택 후에도 대화가 리셋되지 않습니다. 진행 중인 대화에서 프리셋을 변경할 수 있습니다.", - "💡 Shift+Enter로 퍼지 검색 결과의 파일이 있는 폴더를 열 수 있습니다.", - "💡 최근 폴더를 우클릭하면 '폴더 열기', '경로 복사', '목록에서 삭제'가 가능합니다.", - "💡 Cowork/Code 에이전트 작업 완료 시 시스템 트레이에 알림이 표시됩니다.", - "💡 마크다운 테이블, 인용(>), 취소선(~~), 링크([text](url))가 모두 렌더링됩니다.", - "💡 ⚠ 데이터 폴더를 워크스페이스로 지정할 때는 반드시 백업을 먼저 만드세요!", - "💡 드라이브 루트(C:\\, D:\\)는 작업공간으로 설정할 수 없습니다. 하위 폴더를 선택하세요.", - ]; - private int _tipIndex; - private DispatcherTimer? _tipDismissTimer; - - private void ShowRandomTip() - { - if (!Llm.ShowTips) return; - if (_activeTab != "Cowork" && _activeTab != "Code") return; - - var tip = Tips[_tipIndex % Tips.Length]; - _tipIndex++; - - // 토스트 스타일로 표시 (기존 토스트와 다른 위치/색상) - ShowTip(tip); - } - - private void ShowTip(string message) - { - _tipDismissTimer?.Stop(); - - ToastText.Text = message; - ToastIcon.Text = "\uE82F"; // 전구 아이콘 - ToastBorder.Visibility = Visibility.Visible; - ToastBorder.BeginAnimation(UIElement.OpacityProperty, - new System.Windows.Media.Animation.DoubleAnimation(0, 1, TimeSpan.FromMilliseconds(300))); - - var duration = Llm.TipDurationSeconds; - if (duration <= 0) return; // 0이면 수동 닫기 (자동 사라짐 없음) - - _tipDismissTimer = new DispatcherTimer { Interval = TimeSpan.FromSeconds(duration) }; - _tipDismissTimer.Tick += (_, _) => - { - _tipDismissTimer.Stop(); - var fadeOut = new System.Windows.Media.Animation.DoubleAnimation(1, 0, TimeSpan.FromMilliseconds(300)); - fadeOut.Completed += (_, _) => ToastBorder.Visibility = Visibility.Collapsed; - ToastBorder.BeginAnimation(UIElement.OpacityProperty, fadeOut); - }; - _tipDismissTimer.Start(); - } - - // ─── 프로젝트 문맥 파일 (AX.md) ────────────────────────────────── - - /// - /// 작업 폴더에 AX.md가 있으면 내용을 읽어 시스템 프롬프트에 주입합니다. - /// Claude Code와 동일한 파일명/형식을 사용합니다. - /// - private static string LoadProjectContext(string workFolder) - { - if (string.IsNullOrEmpty(workFolder)) return ""; - - // Phase 30-C: HierarchicalMemoryService — 4-layer 계층 메모리 통합 조회 - try - { - var hierMemory = new AxCopilot.Services.Agent.HierarchicalMemoryService(); - var merged = hierMemory.BuildMergedContext(workFolder, 8000); - if (!string.IsNullOrWhiteSpace(merged)) - { - // @include 지시어 해석 - if (merged.Contains("@")) - { - try - { - var resolver = new AxCopilot.Services.Agent.AxMdIncludeResolver(); - merged = resolver.ResolveAsync(merged, workFolder).GetAwaiter().GetResult(); - } - catch (Exception) { /* @include 실패 시 원본 유지 */ } - } - return $"\n## Project Context (Hierarchical Memory)\n{merged}\n"; - } - } - catch (Exception) { /* 계층 메모리 실패 시 레거시 폴백 */ } - - // 레거시 폴백: 단일 AX.md 탐색 (작업 폴더 → 상위 폴더 순) - var searchDir = workFolder; - for (int i = 0; i < 3; i++) - { - if (string.IsNullOrEmpty(searchDir)) break; - var filePath = System.IO.Path.Combine(searchDir, "AX.md"); - if (System.IO.File.Exists(filePath)) - { - try - { - var content = System.IO.File.ReadAllText(filePath); - if (content.Contains("@")) - { - try - { - var resolver = new AxCopilot.Services.Agent.AxMdIncludeResolver(); - var baseDir = System.IO.Path.GetDirectoryName(filePath) ?? workFolder; - content = resolver.ResolveAsync(content, baseDir).GetAwaiter().GetResult(); - } - catch (Exception) { /* 비핵심 작업 실패 — UI 차단 방지 */ } - } - if (content.Length > 8000) content = content[..8000] + "\n... (8000자 초과 생략)"; - return $"\n## Project Context (from AX.md)\n{content}\n"; - } - catch (Exception) { /* 비핵심 작업 실패 — UI 차단 방지 */ } - } - searchDir = System.IO.Directory.GetParent(searchDir)?.FullName; - } - return ""; - } - - /// Phase 27-B: 현재 파일 경로에 매칭되는 스킬을 시스템 프롬프트에 자동 주입. - private string BuildPathBasedSkillSection() - { - try - { - // 현재 대화에서 언급된 파일 경로 추출 (최근 메시지에서) - var recentFiles = GetRecentlyMentionedFiles(); - if (recentFiles.Count == 0) return ""; - - var allSkills = AxCopilot.Services.Agent.SkillService.Skills; - var activator = new AxCopilot.Services.Agent.PathBasedSkillActivator(); - - var matchedSkills = new List(); - foreach (var file in recentFiles) - { - var matches = activator.GetActiveSkillsForFile(allSkills, file); - foreach (var m in matches) - { - if (!matchedSkills.Any(s => s.Name == m.Name)) - matchedSkills.Add(m); - } - } - - return activator.BuildSkillContextInjection(matchedSkills); - } - catch (Exception) { return ""; } - } - - /// 최근 대화에서 파일 경로를 추출합니다. - private List GetRecentlyMentionedFiles() - { - var files = new List(); - if (_currentConversation?.Messages == null) return files; - - // 최근 5개 메시지에서 파일 경로 패턴 탐색 - var recent = _currentConversation.Messages.TakeLast(5); - foreach (var msg in recent) - { - if (string.IsNullOrEmpty(msg.Content)) continue; - // 간단한 파일 경로 패턴: 확장자가 있는 경로 - var pathMatches = System.Text.RegularExpressions.Regex.Matches( - msg.Content, @"[\w./\\-]+\.\w{1,10}"); - foreach (System.Text.RegularExpressions.Match m in pathMatches) - { - var path = m.Value; - if (path.Contains('.') && !path.StartsWith("http")) - files.Add(path); - } - } - return files.Distinct().Take(10).ToList(); - } - - // ─── 무지개 글로우 애니메이션 ───────────────────────────────────────── - - private DispatcherTimer? _rainbowTimer; - private DateTime _rainbowStartTime; - - /// 입력창 테두리에 무지개 그라데이션 회전 애니메이션을 재생합니다 (3초). - private void PlayRainbowGlow() - { - if (!Llm.EnableChatRainbowGlow) return; - - _rainbowTimer?.Stop(); - _rainbowStartTime = DateTime.UtcNow; - - // 페이드인 (빠르게) - InputGlowBorder.BeginAnimation(UIElement.OpacityProperty, - new System.Windows.Media.Animation.DoubleAnimation(0, 0.9, TimeSpan.FromMilliseconds(150))); - - // 그라데이션 회전 타이머 (~60fps) — 스트리밍 종료까지 지속 - _rainbowTimer = new DispatcherTimer { Interval = TimeSpan.FromMilliseconds(16) }; - _rainbowTimer.Tick += (_, _) => - { - var elapsed = (DateTime.UtcNow - _rainbowStartTime).TotalMilliseconds; - - // 그라데이션 오프셋 회전 - var shift = (elapsed / 1500.0) % 1.0; // 1.5초에 1바퀴 (느리게) - var brush = InputGlowBorder.BorderBrush as LinearGradientBrush; - if (brush == null) return; - - // 시작/끝점 회전 (원형 이동) - var angle = shift * Math.PI * 2; - brush.StartPoint = new Point(0.5 + 0.5 * Math.Cos(angle), 0.5 + 0.5 * Math.Sin(angle)); - brush.EndPoint = new Point(0.5 - 0.5 * Math.Cos(angle), 0.5 - 0.5 * Math.Sin(angle)); - }; - _rainbowTimer.Start(); - } - - /// 레인보우 글로우 효과를 페이드아웃하며 중지합니다. - private void StopRainbowGlow() - { - _rainbowTimer?.Stop(); - _rainbowTimer = null; - if (InputGlowBorder.Opacity > 0) - { - var fadeOut = new System.Windows.Media.Animation.DoubleAnimation( - InputGlowBorder.Opacity, 0, TimeSpan.FromMilliseconds(600)); - fadeOut.Completed += (_, _) => InputGlowBorder.Opacity = 0; - InputGlowBorder.BeginAnimation(UIElement.OpacityProperty, fadeOut); - } - } - - // ─── 토스트 알림 ────────────────────────────────────────────────────── - - private DispatcherTimer? _toastHideTimer; - - private void ShowToast(string message, string icon = "\uE73E", int durationMs = 2000) - { - _toastHideTimer?.Stop(); - - ToastText.Text = message; - ToastIcon.Text = icon; - ToastBorder.Visibility = Visibility.Visible; - - // 페이드인 - ToastBorder.BeginAnimation(UIElement.OpacityProperty, - new System.Windows.Media.Animation.DoubleAnimation(0, 1, TimeSpan.FromMilliseconds(200))); - - // 자동 숨기기 - _toastHideTimer = new DispatcherTimer { Interval = TimeSpan.FromMilliseconds(durationMs) }; - _toastHideTimer.Tick += (_, _) => - { - _toastHideTimer.Stop(); - var fadeOut = new System.Windows.Media.Animation.DoubleAnimation(1, 0, TimeSpan.FromMilliseconds(300)); - fadeOut.Completed += (_, _) => ToastBorder.Visibility = Visibility.Collapsed; - ToastBorder.BeginAnimation(UIElement.OpacityProperty, fadeOut); - }; - _toastHideTimer.Start(); - } - - // ─── 하단 상태바 ────────────────────────────────────────────────────── - - private System.Windows.Media.Animation.Storyboard? _statusSpinStoryboard; - - private void UpdateStatusBar(AgentEvent evt) - { - var toolLabel = evt.ToolName switch - { - "file_read" or "document_read" => "파일 읽기", - "file_write" => "파일 쓰기", - "file_edit" => "파일 수정", - "html_create" => "HTML 생성", - "xlsx_create" => "Excel 생성", - "docx_create" => "Word 생성", - "csv_create" => "CSV 생성", - "md_create" => "Markdown 생성", - "folder_map" => "폴더 탐색", - "glob" => "파일 검색", - "grep" => "내용 검색", - "process" => "명령 실행", - _ => evt.ToolName, - }; - - switch (evt.Type) - { - case AgentEventType.Thinking: - SetStatus("생각 중...", spinning: true); - break; - case AgentEventType.Planning: - SetStatus($"계획 수립 중 — {evt.StepTotal}단계", spinning: true); - break; - case AgentEventType.ToolCall: - SetStatus($"{toolLabel} 실행 중...", spinning: true); - break; - case AgentEventType.ToolResult: - SetStatus(evt.Success ? $"{toolLabel} 완료" : $"{toolLabel} 실패", spinning: false); - break; - case AgentEventType.StepStart: - SetStatus($"[{evt.StepCurrent}/{evt.StepTotal}] {TruncateForStatus(evt.Summary)}", spinning: true); - break; - case AgentEventType.StepDone: - SetStatus($"[{evt.StepCurrent}/{evt.StepTotal}] 단계 완료", spinning: true); - break; - case AgentEventType.SkillCall: - SetStatus($"스킬 실행 중: {TruncateForStatus(evt.Summary)}", spinning: true); - break; - case AgentEventType.Complete: - SetStatus("작업 완료", spinning: false); - StopStatusAnimation(); - break; - case AgentEventType.Error: - SetStatus("오류 발생", spinning: false); - StopStatusAnimation(); - break; - case AgentEventType.Paused: - SetStatus("⏸ 일시정지", spinning: false); - break; - case AgentEventType.Resumed: - SetStatus("▶ 재개됨", spinning: true); - break; - } - } - - private void SetStatus(string text, bool spinning) - { - if (StatusLabel != null) StatusLabel.Text = text; - if (spinning) StartStatusAnimation(); - } - - private void StartStatusAnimation() - { - if (_statusSpinStoryboard != null) return; - - var anim = new System.Windows.Media.Animation.DoubleAnimation - { - From = 0, To = 360, - Duration = TimeSpan.FromSeconds(2), - RepeatBehavior = System.Windows.Media.Animation.RepeatBehavior.Forever, - }; - - _statusSpinStoryboard = new System.Windows.Media.Animation.Storyboard(); - System.Windows.Media.Animation.Storyboard.SetTarget(anim, StatusDiamond); - System.Windows.Media.Animation.Storyboard.SetTargetProperty(anim, - new PropertyPath("(UIElement.RenderTransform).(RotateTransform.Angle)")); - _statusSpinStoryboard.Children.Add(anim); - _statusSpinStoryboard.Begin(); - } - - private void StopStatusAnimation() - { - _statusSpinStoryboard?.Stop(); - _statusSpinStoryboard = null; - } - - private void SetStatusIdle() - { - StopStatusAnimation(); - if (StatusLabel != null) StatusLabel.Text = "대기 중"; - if (StatusElapsed != null) StatusElapsed.Text = ""; - if (StatusTokens != null) StatusTokens.Text = ""; - } - - private void UpdateStatusTokens(int inputTokens, int outputTokens) - { - if (StatusTokens == null) return; - var llm = Llm; - var (inCost, outCost) = Services.TokenEstimator.EstimateCost( - inputTokens, outputTokens, llm.Service, llm.Model); - var totalCost = inCost + outCost; - var costText = totalCost > 0 ? $" · {Services.TokenEstimator.FormatCost(totalCost)}" : ""; - StatusTokens.Text = $"↑{Services.TokenEstimator.Format(inputTokens)} ↓{Services.TokenEstimator.Format(outputTokens)}{costText}"; - } - - private static string TruncateForStatus(string? text, int max = 40) - { - if (string.IsNullOrEmpty(text)) return ""; - return text.Length <= max ? text : text[..max] + "…"; - } - - // ─── 헬퍼 ───────────────────────────────────────────────────────────── - private static System.Windows.Media.SolidColorBrush BrushFromHex(string hex) - { - var c = ThemeResourceHelper.HexColor(hex); - return new System.Windows.Media.SolidColorBrush(c); - } } diff --git a/src/AxCopilot/Views/ChatWindow.StatusAndUI.cs b/src/AxCopilot/Views/ChatWindow.StatusAndUI.cs new file mode 100644 index 0000000..ab1bb15 --- /dev/null +++ b/src/AxCopilot/Views/ChatWindow.StatusAndUI.cs @@ -0,0 +1,498 @@ +using System.Windows; +using System.Windows.Controls; +using System.Windows.Input; +using System.Windows.Media; +using System.Windows.Threading; +using AxCopilot.Models; +using AxCopilot.Services.Agent; + +namespace AxCopilot.Views; + +public partial class ChatWindow +{ + // ─── 메시지 우클릭 컨텍스트 메뉴 ─────────────────────────────────────── + + private void ShowMessageContextMenu(string content, string role) + { + var menu = CreateThemedContextMenu(); + var primaryText = ThemeResourceHelper.Primary(this); + var secondaryText = ThemeResourceHelper.Secondary(this); + + void AddItem(string icon, string label, Action action) + { + var sp = new StackPanel { Orientation = Orientation.Horizontal }; + sp.Children.Add(new TextBlock + { + Text = icon, FontFamily = ThemeResourceHelper.SegoeMdl2, + FontSize = 12, Foreground = secondaryText, + VerticalAlignment = VerticalAlignment.Center, Margin = new Thickness(0, 0, 8, 0), + }); + sp.Children.Add(new TextBlock + { + Text = label, FontSize = 12, Foreground = primaryText, + VerticalAlignment = VerticalAlignment.Center, + }); + var mi = new MenuItem { Header = sp, Padding = new Thickness(8, 6, 16, 6) }; + mi.Click += (_, _) => action(); + menu.Items.Add(mi); + } + + // 복사 + AddItem("\uE8C8", "텍스트 복사", () => + { + try { Clipboard.SetText(content); ShowToast("복사되었습니다"); } catch (Exception) { /* 클립보드 접근 실패 */ } + }); + + // 마크다운 복사 + AddItem("\uE943", "마크다운 복사", () => + { + try { Clipboard.SetText(content); ShowToast("마크다운으로 복사됨"); } catch (Exception) { /* 클립보드 접근 실패 */ } + }); + + // 인용하여 답장 + AddItem("\uE97A", "인용하여 답장", () => + { + var quote = content.Length > 200 ? content[..200] + "..." : content; + var lines = quote.Split('\n'); + var quoted = string.Join("\n", lines.Select(l => $"> {l}")); + InputBox.Text = quoted + "\n\n"; + InputBox.Focus(); + InputBox.CaretIndex = InputBox.Text.Length; + }); + + menu.Items.Add(new Separator()); + + // 재생성 (AI 응답만) + if (role == "assistant") + { + AddItem("\uE72C", "응답 재생성", () => _ = RegenerateLastAsync()); + } + + // 대화 분기 (Fork) + AddItem("\uE8A5", "여기서 분기", () => + { + ChatConversation? conv; + lock (_convLock) conv = _currentConversation; + if (conv == null) return; + + var idx = conv.Messages.FindLastIndex(m => m.Role == role && m.Content == content); + if (idx < 0) return; + + ForkConversation(conv, idx); + }); + + menu.Items.Add(new Separator()); + + // 이후 메시지 모두 삭제 + var msgContent = content; + var msgRole = role; + AddItem("\uE74D", "이후 메시지 모두 삭제", () => + { + ChatConversation? conv; + lock (_convLock) conv = _currentConversation; + if (conv == null) return; + + var idx = conv.Messages.FindLastIndex(m => m.Role == msgRole && m.Content == msgContent); + if (idx < 0) return; + + var removeCount = conv.Messages.Count - idx; + if (MessageBox.Show($"이 메시지 포함 {removeCount}개 메시지를 삭제하시겠습니까?", + "메시지 삭제", MessageBoxButton.YesNo, MessageBoxImage.Warning) != MessageBoxResult.Yes) + return; + + conv.Messages.RemoveRange(idx, removeCount); + try { _storage.Save(conv); } catch (Exception ex) { Services.LogService.Debug($"대화 저장 실패: {ex.Message}"); } + RenderMessages(); + ShowToast($"{removeCount}개 메시지 삭제됨"); + }); + + menu.IsOpen = true; + } + + // ─── 팁 알림 ────────────────────────────────────────────────────── + + private static readonly string[] Tips = + [ + "💡 작업 폴더에 AX.md 파일을 만들면 매번 시스템 프롬프트에 자동 주입됩니다. 프로젝트 설계 원칙이나 코딩 규칙을 기록하세요.", + "💡 Ctrl+1/2/3으로 Chat/Cowork/Code 탭을 빠르게 전환할 수 있습니다.", + "💡 Ctrl+F로 현재 대화 내 메시지를 검색할 수 있습니다.", + "💡 메시지를 우클릭하면 복사, 인용 답장, 재생성, 삭제를 할 수 있습니다.", + "💡 코드 블록을 더블클릭하면 전체화면으로 볼 수 있고, 💾 버튼으로 파일 저장이 가능합니다.", + "💡 Cowork 에이전트가 만든 파일은 자동으로 날짜_시간 접미사가 붙어 덮어쓰기를 방지합니다.", + "💡 Code 탭에서 개발 언어를 선택하면 해당 언어 우선으로 코드를 생성합니다.", + "💡 파일 탐색기(하단 바 '파일' 버튼)에서 더블클릭으로 프리뷰, 우클릭으로 관리할 수 있습니다.", + "💡 에이전트가 계획을 제시하면 '수정 요청'으로 방향을 바꾸거나 '취소'로 중단할 수 있습니다.", + "💡 Code 탭은 빌드/테스트를 자동으로 실행합니다. 프로젝트 폴더를 먼저 선택하세요.", + "💡 무드 갤러리에서 10가지 디자인 템플릿 중 원하는 스타일을 미리보기로 선택할 수 있습니다.", + "💡 Git 연동: Code 탭에서 에이전트가 git status, diff, commit을 수행합니다. (push는 직접)", + "💡 설정 → AX Agent → 공통에서 개발자 모드를 켜면 에이전트 동작을 스텝별로 검증할 수 있습니다.", + "💡 트레이 아이콘 우클릭 → '사용 통계'에서 대화 빈도와 토큰 사용량을 확인할 수 있습니다.", + "💡 대화 제목을 클릭하면 이름을 변경할 수 있습니다.", + "💡 LLM 오류 발생 시 '재시도' 버튼이 자동으로 나타납니다.", + "💡 검색란에서 대화 제목뿐 아니라 첫 메시지 내용까지 검색됩니다.", + "💡 프리셋 선택 후에도 대화가 리셋되지 않습니다. 진행 중인 대화에서 프리셋을 변경할 수 있습니다.", + "💡 Shift+Enter로 퍼지 검색 결과의 파일이 있는 폴더를 열 수 있습니다.", + "💡 최근 폴더를 우클릭하면 '폴더 열기', '경로 복사', '목록에서 삭제'가 가능합니다.", + "💡 Cowork/Code 에이전트 작업 완료 시 시스템 트레이에 알림이 표시됩니다.", + "💡 마크다운 테이블, 인용(>), 취소선(~~), 링크([text](url))가 모두 렌더링됩니다.", + "💡 ⚠ 데이터 폴더를 워크스페이스로 지정할 때는 반드시 백업을 먼저 만드세요!", + "💡 드라이브 루트(C:\\, D:\\)는 작업공간으로 설정할 수 없습니다. 하위 폴더를 선택하세요.", + ]; + private int _tipIndex; + private DispatcherTimer? _tipDismissTimer; + + private void ShowRandomTip() + { + if (!Llm.ShowTips) return; + if (_activeTab != "Cowork" && _activeTab != "Code") return; + + var tip = Tips[_tipIndex % Tips.Length]; + _tipIndex++; + + // 토스트 스타일로 표시 (기존 토스트와 다른 위치/색상) + ShowTip(tip); + } + + private void ShowTip(string message) + { + _tipDismissTimer?.Stop(); + + ToastText.Text = message; + ToastIcon.Text = "\uE82F"; // 전구 아이콘 + ToastBorder.Visibility = Visibility.Visible; + ToastBorder.BeginAnimation(UIElement.OpacityProperty, + new System.Windows.Media.Animation.DoubleAnimation(0, 1, TimeSpan.FromMilliseconds(300))); + + var duration = Llm.TipDurationSeconds; + if (duration <= 0) return; // 0이면 수동 닫기 (자동 사라짐 없음) + + _tipDismissTimer = new DispatcherTimer { Interval = TimeSpan.FromSeconds(duration) }; + _tipDismissTimer.Tick += (_, _) => + { + _tipDismissTimer.Stop(); + var fadeOut = new System.Windows.Media.Animation.DoubleAnimation(1, 0, TimeSpan.FromMilliseconds(300)); + fadeOut.Completed += (_, _) => ToastBorder.Visibility = Visibility.Collapsed; + ToastBorder.BeginAnimation(UIElement.OpacityProperty, fadeOut); + }; + _tipDismissTimer.Start(); + } + + // ─── 프로젝트 문맥 파일 (AX.md) ────────────────────────────────── + + /// + /// 작업 폴더에 AX.md가 있으면 내용을 읽어 시스템 프롬프트에 주입합니다. + /// Claude Code와 동일한 파일명/형식을 사용합니다. + /// + private static string LoadProjectContext(string workFolder) + { + if (string.IsNullOrEmpty(workFolder)) return ""; + + // Phase 30-C: HierarchicalMemoryService — 4-layer 계층 메모리 통합 조회 + try + { + var hierMemory = new AxCopilot.Services.Agent.HierarchicalMemoryService(); + var merged = hierMemory.BuildMergedContext(workFolder, 8000); + if (!string.IsNullOrWhiteSpace(merged)) + { + // @include 지시어 해석 + if (merged.Contains("@")) + { + try + { + var resolver = new AxCopilot.Services.Agent.AxMdIncludeResolver(); + merged = resolver.ResolveAsync(merged, workFolder).GetAwaiter().GetResult(); + } + catch (Exception) { /* @include 실패 시 원본 유지 */ } + } + return $"\n## Project Context (Hierarchical Memory)\n{merged}\n"; + } + } + catch (Exception) { /* 계층 메모리 실패 시 레거시 폴백 */ } + + // 레거시 폴백: 단일 AX.md 탐색 (작업 폴더 → 상위 폴더 순) + var searchDir = workFolder; + for (int i = 0; i < 3; i++) + { + if (string.IsNullOrEmpty(searchDir)) break; + var filePath = System.IO.Path.Combine(searchDir, "AX.md"); + if (System.IO.File.Exists(filePath)) + { + try + { + var content = System.IO.File.ReadAllText(filePath); + if (content.Contains("@")) + { + try + { + var resolver = new AxCopilot.Services.Agent.AxMdIncludeResolver(); + var baseDir = System.IO.Path.GetDirectoryName(filePath) ?? workFolder; + content = resolver.ResolveAsync(content, baseDir).GetAwaiter().GetResult(); + } + catch (Exception) { /* 비핵심 작업 실패 — UI 차단 방지 */ } + } + if (content.Length > 8000) content = content[..8000] + "\n... (8000자 초과 생략)"; + return $"\n## Project Context (from AX.md)\n{content}\n"; + } + catch (Exception) { /* 비핵심 작업 실패 — UI 차단 방지 */ } + } + searchDir = System.IO.Directory.GetParent(searchDir)?.FullName; + } + return ""; + } + + /// Phase 27-B: 현재 파일 경로에 매칭되는 스킬을 시스템 프롬프트에 자동 주입. + private string BuildPathBasedSkillSection() + { + try + { + // 현재 대화에서 언급된 파일 경로 추출 (최근 메시지에서) + var recentFiles = GetRecentlyMentionedFiles(); + if (recentFiles.Count == 0) return ""; + + var allSkills = AxCopilot.Services.Agent.SkillService.Skills; + var activator = new AxCopilot.Services.Agent.PathBasedSkillActivator(); + + var matchedSkills = new List(); + foreach (var file in recentFiles) + { + var matches = activator.GetActiveSkillsForFile(allSkills, file); + foreach (var m in matches) + { + if (!matchedSkills.Any(s => s.Name == m.Name)) + matchedSkills.Add(m); + } + } + + return activator.BuildSkillContextInjection(matchedSkills); + } + catch (Exception) { return ""; } + } + + /// 최근 대화에서 파일 경로를 추출합니다. + private List GetRecentlyMentionedFiles() + { + var files = new List(); + if (_currentConversation?.Messages == null) return files; + + // 최근 5개 메시지에서 파일 경로 패턴 탐색 + var recent = _currentConversation.Messages.TakeLast(5); + foreach (var msg in recent) + { + if (string.IsNullOrEmpty(msg.Content)) continue; + // 간단한 파일 경로 패턴: 확장자가 있는 경로 + var pathMatches = System.Text.RegularExpressions.Regex.Matches( + msg.Content, @"[\w./\\-]+\.\w{1,10}"); + foreach (System.Text.RegularExpressions.Match m in pathMatches) + { + var path = m.Value; + if (path.Contains('.') && !path.StartsWith("http")) + files.Add(path); + } + } + return files.Distinct().Take(10).ToList(); + } + + // ─── 무지개 글로우 애니메이션 ───────────────────────────────────────── + + private DispatcherTimer? _rainbowTimer; + private DateTime _rainbowStartTime; + + /// 입력창 테두리에 무지개 그라데이션 회전 애니메이션을 재생합니다 (3초). + private void PlayRainbowGlow() + { + if (!Llm.EnableChatRainbowGlow) return; + + _rainbowTimer?.Stop(); + _rainbowStartTime = DateTime.UtcNow; + + // 페이드인 (빠르게) + InputGlowBorder.BeginAnimation(UIElement.OpacityProperty, + new System.Windows.Media.Animation.DoubleAnimation(0, 0.9, TimeSpan.FromMilliseconds(150))); + + // 그라데이션 회전 타이머 (~60fps) — 스트리밍 종료까지 지속 + _rainbowTimer = new DispatcherTimer { Interval = TimeSpan.FromMilliseconds(16) }; + _rainbowTimer.Tick += (_, _) => + { + var elapsed = (DateTime.UtcNow - _rainbowStartTime).TotalMilliseconds; + + // 그라데이션 오프셋 회전 + var shift = (elapsed / 1500.0) % 1.0; // 1.5초에 1바퀴 (느리게) + var brush = InputGlowBorder.BorderBrush as LinearGradientBrush; + if (brush == null) return; + + // 시작/끝점 회전 (원형 이동) + var angle = shift * Math.PI * 2; + brush.StartPoint = new Point(0.5 + 0.5 * Math.Cos(angle), 0.5 + 0.5 * Math.Sin(angle)); + brush.EndPoint = new Point(0.5 - 0.5 * Math.Cos(angle), 0.5 - 0.5 * Math.Sin(angle)); + }; + _rainbowTimer.Start(); + } + + /// 레인보우 글로우 효과를 페이드아웃하며 중지합니다. + private void StopRainbowGlow() + { + _rainbowTimer?.Stop(); + _rainbowTimer = null; + if (InputGlowBorder.Opacity > 0) + { + var fadeOut = new System.Windows.Media.Animation.DoubleAnimation( + InputGlowBorder.Opacity, 0, TimeSpan.FromMilliseconds(600)); + fadeOut.Completed += (_, _) => InputGlowBorder.Opacity = 0; + InputGlowBorder.BeginAnimation(UIElement.OpacityProperty, fadeOut); + } + } + + // ─── 토스트 알림 ────────────────────────────────────────────────────── + + private DispatcherTimer? _toastHideTimer; + + private void ShowToast(string message, string icon = "\uE73E", int durationMs = 2000) + { + _toastHideTimer?.Stop(); + + ToastText.Text = message; + ToastIcon.Text = icon; + ToastBorder.Visibility = Visibility.Visible; + + // 페이드인 + ToastBorder.BeginAnimation(UIElement.OpacityProperty, + new System.Windows.Media.Animation.DoubleAnimation(0, 1, TimeSpan.FromMilliseconds(200))); + + // 자동 숨기기 + _toastHideTimer = new DispatcherTimer { Interval = TimeSpan.FromMilliseconds(durationMs) }; + _toastHideTimer.Tick += (_, _) => + { + _toastHideTimer.Stop(); + var fadeOut = new System.Windows.Media.Animation.DoubleAnimation(1, 0, TimeSpan.FromMilliseconds(300)); + fadeOut.Completed += (_, _) => ToastBorder.Visibility = Visibility.Collapsed; + ToastBorder.BeginAnimation(UIElement.OpacityProperty, fadeOut); + }; + _toastHideTimer.Start(); + } + + // ─── 하단 상태바 ────────────────────────────────────────────────────── + + private System.Windows.Media.Animation.Storyboard? _statusSpinStoryboard; + + private void UpdateStatusBar(AgentEvent evt) + { + var toolLabel = evt.ToolName switch + { + "file_read" or "document_read" => "파일 읽기", + "file_write" => "파일 쓰기", + "file_edit" => "파일 수정", + "html_create" => "HTML 생성", + "xlsx_create" => "Excel 생성", + "docx_create" => "Word 생성", + "csv_create" => "CSV 생성", + "md_create" => "Markdown 생성", + "folder_map" => "폴더 탐색", + "glob" => "파일 검색", + "grep" => "내용 검색", + "process" => "명령 실행", + _ => evt.ToolName, + }; + + switch (evt.Type) + { + case AgentEventType.Thinking: + SetStatus("생각 중...", spinning: true); + break; + case AgentEventType.Planning: + SetStatus($"계획 수립 중 — {evt.StepTotal}단계", spinning: true); + break; + case AgentEventType.ToolCall: + SetStatus($"{toolLabel} 실행 중...", spinning: true); + break; + case AgentEventType.ToolResult: + SetStatus(evt.Success ? $"{toolLabel} 완료" : $"{toolLabel} 실패", spinning: false); + break; + case AgentEventType.StepStart: + SetStatus($"[{evt.StepCurrent}/{evt.StepTotal}] {TruncateForStatus(evt.Summary)}", spinning: true); + break; + case AgentEventType.StepDone: + SetStatus($"[{evt.StepCurrent}/{evt.StepTotal}] 단계 완료", spinning: true); + break; + case AgentEventType.SkillCall: + SetStatus($"스킬 실행 중: {TruncateForStatus(evt.Summary)}", spinning: true); + break; + case AgentEventType.Complete: + SetStatus("작업 완료", spinning: false); + StopStatusAnimation(); + break; + case AgentEventType.Error: + SetStatus("오류 발생", spinning: false); + StopStatusAnimation(); + break; + case AgentEventType.Paused: + SetStatus("⏸ 일시정지", spinning: false); + break; + case AgentEventType.Resumed: + SetStatus("▶ 재개됨", spinning: true); + break; + } + } + + private void SetStatus(string text, bool spinning) + { + if (StatusLabel != null) StatusLabel.Text = text; + if (spinning) StartStatusAnimation(); + } + + private void StartStatusAnimation() + { + if (_statusSpinStoryboard != null) return; + + var anim = new System.Windows.Media.Animation.DoubleAnimation + { + From = 0, To = 360, + Duration = TimeSpan.FromSeconds(2), + RepeatBehavior = System.Windows.Media.Animation.RepeatBehavior.Forever, + }; + + _statusSpinStoryboard = new System.Windows.Media.Animation.Storyboard(); + System.Windows.Media.Animation.Storyboard.SetTarget(anim, StatusDiamond); + System.Windows.Media.Animation.Storyboard.SetTargetProperty(anim, + new PropertyPath("(UIElement.RenderTransform).(RotateTransform.Angle)")); + _statusSpinStoryboard.Children.Add(anim); + _statusSpinStoryboard.Begin(); + } + + private void StopStatusAnimation() + { + _statusSpinStoryboard?.Stop(); + _statusSpinStoryboard = null; + } + + private void SetStatusIdle() + { + StopStatusAnimation(); + if (StatusLabel != null) StatusLabel.Text = "대기 중"; + if (StatusElapsed != null) StatusElapsed.Text = ""; + if (StatusTokens != null) StatusTokens.Text = ""; + } + + private void UpdateStatusTokens(int inputTokens, int outputTokens) + { + if (StatusTokens == null) return; + var llm = Llm; + var (inCost, outCost) = Services.TokenEstimator.EstimateCost( + inputTokens, outputTokens, llm.Service, llm.Model); + var totalCost = inCost + outCost; + var costText = totalCost > 0 ? $" · {Services.TokenEstimator.FormatCost(totalCost)}" : ""; + StatusTokens.Text = $"↑{Services.TokenEstimator.Format(inputTokens)} ↓{Services.TokenEstimator.Format(outputTokens)}{costText}"; + } + + private static string TruncateForStatus(string? text, int max = 40) + { + if (string.IsNullOrEmpty(text)) return ""; + return text.Length <= max ? text : text[..max] + "…"; + } + + // ─── 헬퍼 ───────────────────────────────────────────────────────────── + private static System.Windows.Media.SolidColorBrush BrushFromHex(string hex) + { + var c = ThemeResourceHelper.HexColor(hex); + return new System.Windows.Media.SolidColorBrush(c); + } +}