[Phase 42] ChatWindow.ResponseHandling·LlmService 파셜 분할

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 <noreply@anthropic.com>
This commit is contained in:
2026-04-03 19:45:26 +09:00
parent c9a6e6442f
commit bf37800311
7 changed files with 1572 additions and 1502 deletions

View File

@@ -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차)

View File

@@ -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<object> BuildMessageList(List<ChatMessage> messages, bool openAiVision = false)
{
var result = new List<object>();
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<object>();
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;
}
/// <summary>비스트리밍 POST + 재시도 (일시적 오류 시 최대 2회)</summary>
private async Task<string> 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("요청 실패");
}
/// <summary>스트리밍 전용 — HTTP 요청 전송 + 에러 분류</summary>
private async Task<HttpResponseMessage> 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;
}
/// <summary>스트리밍 ReadLine에 청크 타임아웃 적용</summary>
private static async Task<string?> 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; // 타임아웃 시 스트림 종료
}
}
/// <summary>JSON 파싱 안전 래퍼 — 파싱 실패 시 상세 에러 메시지 반환</summary>
private static string SafeParseJson(string json, Func<JsonElement, string> 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}");
}
}
/// <summary>HTTP 에러 코드별 사용자 친화적 메시지</summary>
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();
}

View File

@@ -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<string> StreamAsync(
List<ChatMessage> 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<string> SendOllamaAsync(List<ChatMessage> 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<string> StreamOllamaAsync(
List<ChatMessage> 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<ChatMessage> 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<string> SendOpenAiCompatibleAsync(List<ChatMessage> 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<string> StreamOpenAiCompatibleAsync(
List<ChatMessage> 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<ChatMessage> 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<string> SendGeminiAsync(List<ChatMessage> messages, CancellationToken ct)
{
var llm = _settings.Settings.Llm;
var apiKey = ResolveApiKeyForService("gemini");
if (string.IsNullOrEmpty(apiKey))
throw new InvalidOperationException("Gemini API 키가 설정되지 않았습니다. 설정 > AX Agent에서 API 키를 입력하세요.");
var model = ResolveModel();
var body = BuildGeminiBody(messages);
var url = $"https://generativelanguage.googleapis.com/v1beta/models/{model}:generateContent?key={apiKey}";
var resp = await PostJsonWithRetryAsync(url, body, ct);
return SafeParseJson(resp, root =>
{
TryParseGeminiUsage(root);
var candidates = root.GetProperty("candidates");
if (candidates.GetArrayLength() == 0) return "(빈 응답)";
var parts = candidates[0].GetProperty("content").GetProperty("parts");
if (parts.GetArrayLength() == 0) return "(빈 응답)";
return parts[0].GetProperty("text").GetString() ?? "";
}, "Gemini 응답");
}
private async IAsyncEnumerable<string> StreamGeminiAsync(
List<ChatMessage> messages,
[EnumeratorCancellation] CancellationToken ct)
{
var llm = _settings.Settings.Llm;
var apiKey = ResolveApiKeyForService("gemini");
if (string.IsNullOrEmpty(apiKey))
throw new InvalidOperationException("Gemini API 키가 설정되지 않았습니다.");
var model = ResolveModel();
var body = BuildGeminiBody(messages);
var url = $"https://generativelanguage.googleapis.com/v1beta/models/{model}:streamGenerateContent?alt=sse&key={apiKey}";
using var req = new HttpRequestMessage(HttpMethod.Post, url) { Content = JsonContent(body) };
using var resp = await SendWithErrorClassificationAsync(req, ct);
using var stream = await resp.Content.ReadAsStreamAsync(ct);
using var reader = new StreamReader(stream);
while (!reader.EndOfStream && !ct.IsCancellationRequested)
{
var line = await ReadLineWithTimeoutAsync(reader, ct);
if (line == null) break;
if (string.IsNullOrEmpty(line) || !line.StartsWith("data: ")) continue;
var data = line["data: ".Length..];
string? parsed = null;
try
{
using var doc = JsonDocument.Parse(data);
TryParseGeminiUsage(doc.RootElement);
var candidates = doc.RootElement.GetProperty("candidates");
if (candidates.GetArrayLength() == 0) continue;
var sb = new StringBuilder();
var parts = candidates[0].GetProperty("content").GetProperty("parts");
foreach (var part in parts.EnumerateArray())
{
if (part.TryGetProperty("text", out var t))
{
var text = t.GetString();
if (!string.IsNullOrEmpty(text)) sb.Append(text);
}
}
if (sb.Length > 0) parsed = sb.ToString();
}
catch (JsonException ex)
{
LogService.Warn($"Gemini 스트리밍 JSON 파싱 오류: {ex.Message}");
}
if (parsed != null) yield return parsed;
}
}
private object BuildGeminiBody(List<ChatMessage> messages)
{
var llm = _settings.Settings.Llm;
var contents = new List<object>();
object? systemInstruction = null;
if (!string.IsNullOrEmpty(_systemPrompt))
{
systemInstruction = new { parts = new[] { new { text = _systemPrompt } } };
}
foreach (var m in messages)
{
if (m.Role == "system") continue;
var parts = new List<object> { new { text = m.Content } };
if (m.Images?.Count > 0)
{
foreach (var img in m.Images)
parts.Add(new { inlineData = new { mimeType = img.MimeType, data = img.Base64 } });
}
contents.Add(new
{
role = m.Role == "assistant" ? "model" : "user",
parts
});
}
if (systemInstruction != null)
return new
{
systemInstruction,
contents,
generationConfig = new { temperature = llm.Temperature, maxOutputTokens = llm.MaxContextTokens }
};
return new
{
contents,
generationConfig = new { temperature = llm.Temperature, maxOutputTokens = llm.MaxContextTokens }
};
}
// ═══════════════════════════════════════════════════════════════════════
// Claude (Anthropic Messages API)
// ═══════════════════════════════════════════════════════════════════════
private async Task<string> SendClaudeAsync(List<ChatMessage> messages, CancellationToken ct)
{
var llm = _settings.Settings.Llm;
var apiKey = llm.ApiKey;
if (string.IsNullOrEmpty(apiKey))
throw new InvalidOperationException("Claude API 키가 설정되지 않았습니다. 설정 > AX Agent에서 API 키를 입력하세요.");
var body = BuildClaudeBody(messages, stream: false);
var json = JsonSerializer.Serialize(body);
using var req = new HttpRequestMessage(HttpMethod.Post, "https://api.anthropic.com/v1/messages");
req.Content = new StringContent(json, Encoding.UTF8, "application/json");
req.Headers.Add("x-api-key", apiKey);
req.Headers.Add("anthropic-version", "2023-06-01");
using var resp = await _http.SendAsync(req, ct);
if (!resp.IsSuccessStatusCode)
{
var errBody = await resp.Content.ReadAsStringAsync(ct);
throw new HttpRequestException(ClassifyHttpError(resp, errBody));
}
var respJson = await resp.Content.ReadAsStringAsync(ct);
return SafeParseJson(respJson, root =>
{
TryParseClaudeUsage(root);
var content = root.GetProperty("content");
if (content.GetArrayLength() == 0) return "(빈 응답)";
return content[0].GetProperty("text").GetString() ?? "";
}, "Claude 응답");
}
private async IAsyncEnumerable<string> StreamClaudeAsync(
List<ChatMessage> messages,
[EnumeratorCancellation] CancellationToken ct)
{
var llm = _settings.Settings.Llm;
var apiKey = llm.ApiKey;
if (string.IsNullOrEmpty(apiKey))
throw new InvalidOperationException("Claude API 키가 설정되지 않았습니다.");
var body = BuildClaudeBody(messages, stream: true);
var json = JsonSerializer.Serialize(body);
using var req = new HttpRequestMessage(HttpMethod.Post, "https://api.anthropic.com/v1/messages");
req.Content = new StringContent(json, Encoding.UTF8, "application/json");
req.Headers.Add("x-api-key", apiKey);
req.Headers.Add("anthropic-version", "2023-06-01");
using var resp = await _http.SendAsync(req, HttpCompletionOption.ResponseHeadersRead, ct);
if (!resp.IsSuccessStatusCode)
{
var errBody = await resp.Content.ReadAsStringAsync(ct);
throw new HttpRequestException(ClassifyHttpError(resp, errBody));
}
using var stream = await resp.Content.ReadAsStreamAsync(ct);
using var reader = new StreamReader(stream);
while (!reader.EndOfStream && !ct.IsCancellationRequested)
{
var line = await ReadLineWithTimeoutAsync(reader, ct);
if (line == null) break;
if (string.IsNullOrEmpty(line) || !line.StartsWith("data: ")) continue;
var data = line["data: ".Length..];
string? text = null;
try
{
using var doc = JsonDocument.Parse(data);
var type = doc.RootElement.GetProperty("type").GetString();
if (type == "content_block_delta")
{
var delta = doc.RootElement.GetProperty("delta");
if (delta.TryGetProperty("text", out var t))
text = t.GetString();
}
else if (type is "message_start" or "message_delta")
{
// message_start: usage in .message.usage, message_delta: usage in .usage
if (doc.RootElement.TryGetProperty("message", out var msg) &&
msg.TryGetProperty("usage", out var u1))
TryParseClaudeUsageFromElement(u1);
else if (doc.RootElement.TryGetProperty("usage", out var u2))
TryParseClaudeUsageFromElement(u2);
}
}
catch (JsonException ex)
{
LogService.Warn($"Claude 스트리밍 JSON 파싱 오류: {ex.Message}");
}
if (!string.IsNullOrEmpty(text)) yield return text;
}
}
private object BuildClaudeBody(List<ChatMessage> messages, bool stream)
{
var llm = _settings.Settings.Llm;
var msgs = new List<object>();
foreach (var m in messages)
{
if (m.Role == "system") continue;
if (m.Images?.Count > 0)
{
// Claude Vision: content를 배열로 변환 (이미지 + 텍스트)
var contentParts = new List<object>();
foreach (var img in m.Images)
contentParts.Add(new { type = "image", source = new { type = "base64", media_type = img.MimeType, data = img.Base64 } });
contentParts.Add(new { type = "text", text = m.Content });
msgs.Add(new { role = m.Role, content = contentParts });
}
else
{
msgs.Add(new { role = m.Role, content = m.Content });
}
}
var activeModel = ResolveModel();
if (!string.IsNullOrEmpty(_systemPrompt))
{
return new
{
model = activeModel,
max_tokens = llm.MaxContextTokens,
temperature = llm.Temperature,
system = _systemPrompt,
messages = msgs,
stream
};
}
return new
{
model = activeModel,
max_tokens = llm.MaxContextTokens,
temperature = llm.Temperature,
messages = msgs,
stream
};
}
}

View File

@@ -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<string> StreamAsync(
List<ChatMessage> 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<string> SendOllamaAsync(List<ChatMessage> 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<string> StreamOllamaAsync(
List<ChatMessage> 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<ChatMessage> 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<string> SendOpenAiCompatibleAsync(List<ChatMessage> 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<string> StreamOpenAiCompatibleAsync(
List<ChatMessage> 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<ChatMessage> 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<string> SendGeminiAsync(List<ChatMessage> messages, CancellationToken ct)
{
var llm = _settings.Settings.Llm;
var apiKey = ResolveApiKeyForService("gemini");
if (string.IsNullOrEmpty(apiKey))
throw new InvalidOperationException("Gemini API 키가 설정되지 않았습니다. 설정 > AX Agent에서 API 키를 입력하세요.");
var model = ResolveModel();
var body = BuildGeminiBody(messages);
var url = $"https://generativelanguage.googleapis.com/v1beta/models/{model}:generateContent?key={apiKey}";
var resp = await PostJsonWithRetryAsync(url, body, ct);
return SafeParseJson(resp, root =>
{
TryParseGeminiUsage(root);
var candidates = root.GetProperty("candidates");
if (candidates.GetArrayLength() == 0) return "(빈 응답)";
var parts = candidates[0].GetProperty("content").GetProperty("parts");
if (parts.GetArrayLength() == 0) return "(빈 응답)";
return parts[0].GetProperty("text").GetString() ?? "";
}, "Gemini 응답");
}
private async IAsyncEnumerable<string> StreamGeminiAsync(
List<ChatMessage> messages,
[EnumeratorCancellation] CancellationToken ct)
{
var llm = _settings.Settings.Llm;
var apiKey = ResolveApiKeyForService("gemini");
if (string.IsNullOrEmpty(apiKey))
throw new InvalidOperationException("Gemini API 키가 설정되지 않았습니다.");
var model = ResolveModel();
var body = BuildGeminiBody(messages);
var url = $"https://generativelanguage.googleapis.com/v1beta/models/{model}:streamGenerateContent?alt=sse&key={apiKey}";
using var req = new HttpRequestMessage(HttpMethod.Post, url) { Content = JsonContent(body) };
using var resp = await SendWithErrorClassificationAsync(req, ct);
using var stream = await resp.Content.ReadAsStreamAsync(ct);
using var reader = new StreamReader(stream);
while (!reader.EndOfStream && !ct.IsCancellationRequested)
{
var line = await ReadLineWithTimeoutAsync(reader, ct);
if (line == null) break;
if (string.IsNullOrEmpty(line) || !line.StartsWith("data: ")) continue;
var data = line["data: ".Length..];
string? parsed = null;
try
{
using var doc = JsonDocument.Parse(data);
TryParseGeminiUsage(doc.RootElement);
var candidates = doc.RootElement.GetProperty("candidates");
if (candidates.GetArrayLength() == 0) continue;
var sb = new StringBuilder();
var parts = candidates[0].GetProperty("content").GetProperty("parts");
foreach (var part in parts.EnumerateArray())
{
if (part.TryGetProperty("text", out var t))
{
var text = t.GetString();
if (!string.IsNullOrEmpty(text)) sb.Append(text);
}
}
if (sb.Length > 0) parsed = sb.ToString();
}
catch (JsonException ex)
{
LogService.Warn($"Gemini 스트리밍 JSON 파싱 오류: {ex.Message}");
}
if (parsed != null) yield return parsed;
}
}
private object BuildGeminiBody(List<ChatMessage> messages)
{
var llm = _settings.Settings.Llm;
var contents = new List<object>();
object? systemInstruction = null;
if (!string.IsNullOrEmpty(_systemPrompt))
{
systemInstruction = new { parts = new[] { new { text = _systemPrompt } } };
}
foreach (var m in messages)
{
if (m.Role == "system") continue;
var parts = new List<object> { new { text = m.Content } };
if (m.Images?.Count > 0)
{
foreach (var img in m.Images)
parts.Add(new { inlineData = new { mimeType = img.MimeType, data = img.Base64 } });
}
contents.Add(new
{
role = m.Role == "assistant" ? "model" : "user",
parts
});
}
if (systemInstruction != null)
return new
{
systemInstruction,
contents,
generationConfig = new { temperature = llm.Temperature, maxOutputTokens = llm.MaxContextTokens }
};
return new
{
contents,
generationConfig = new { temperature = llm.Temperature, maxOutputTokens = llm.MaxContextTokens }
};
}
// ═══════════════════════════════════════════════════════════════════════
// Claude (Anthropic Messages API)
// ═══════════════════════════════════════════════════════════════════════
private async Task<string> SendClaudeAsync(List<ChatMessage> messages, CancellationToken ct)
{
var llm = _settings.Settings.Llm;
var apiKey = llm.ApiKey;
if (string.IsNullOrEmpty(apiKey))
throw new InvalidOperationException("Claude API 키가 설정되지 않았습니다. 설정 > AX Agent에서 API 키를 입력하세요.");
var body = BuildClaudeBody(messages, stream: false);
var json = JsonSerializer.Serialize(body);
using var req = new HttpRequestMessage(HttpMethod.Post, "https://api.anthropic.com/v1/messages");
req.Content = new StringContent(json, Encoding.UTF8, "application/json");
req.Headers.Add("x-api-key", apiKey);
req.Headers.Add("anthropic-version", "2023-06-01");
using var resp = await _http.SendAsync(req, ct);
if (!resp.IsSuccessStatusCode)
{
var errBody = await resp.Content.ReadAsStringAsync(ct);
throw new HttpRequestException(ClassifyHttpError(resp, errBody));
}
var respJson = await resp.Content.ReadAsStringAsync(ct);
return SafeParseJson(respJson, root =>
{
TryParseClaudeUsage(root);
var content = root.GetProperty("content");
if (content.GetArrayLength() == 0) return "(빈 응답)";
return content[0].GetProperty("text").GetString() ?? "";
}, "Claude 응답");
}
private async IAsyncEnumerable<string> StreamClaudeAsync(
List<ChatMessage> messages,
[EnumeratorCancellation] CancellationToken ct)
{
var llm = _settings.Settings.Llm;
var apiKey = llm.ApiKey;
if (string.IsNullOrEmpty(apiKey))
throw new InvalidOperationException("Claude API 키가 설정되지 않았습니다.");
var body = BuildClaudeBody(messages, stream: true);
var json = JsonSerializer.Serialize(body);
using var req = new HttpRequestMessage(HttpMethod.Post, "https://api.anthropic.com/v1/messages");
req.Content = new StringContent(json, Encoding.UTF8, "application/json");
req.Headers.Add("x-api-key", apiKey);
req.Headers.Add("anthropic-version", "2023-06-01");
using var resp = await _http.SendAsync(req, HttpCompletionOption.ResponseHeadersRead, ct);
if (!resp.IsSuccessStatusCode)
{
var errBody = await resp.Content.ReadAsStringAsync(ct);
throw new HttpRequestException(ClassifyHttpError(resp, errBody));
}
using var stream = await resp.Content.ReadAsStreamAsync(ct);
using var reader = new StreamReader(stream);
while (!reader.EndOfStream && !ct.IsCancellationRequested)
{
var line = await ReadLineWithTimeoutAsync(reader, ct);
if (line == null) break;
if (string.IsNullOrEmpty(line) || !line.StartsWith("data: ")) continue;
var data = line["data: ".Length..];
string? text = null;
try
{
using var doc = JsonDocument.Parse(data);
var type = doc.RootElement.GetProperty("type").GetString();
if (type == "content_block_delta")
{
var delta = doc.RootElement.GetProperty("delta");
if (delta.TryGetProperty("text", out var t))
text = t.GetString();
}
else if (type is "message_start" or "message_delta")
{
// message_start: usage in .message.usage, message_delta: usage in .usage
if (doc.RootElement.TryGetProperty("message", out var msg) &&
msg.TryGetProperty("usage", out var u1))
TryParseClaudeUsageFromElement(u1);
else if (doc.RootElement.TryGetProperty("usage", out var u2))
TryParseClaudeUsageFromElement(u2);
}
}
catch (JsonException ex)
{
LogService.Warn($"Claude 스트리밍 JSON 파싱 오류: {ex.Message}");
}
if (!string.IsNullOrEmpty(text)) yield return text;
}
}
private object BuildClaudeBody(List<ChatMessage> messages, bool stream)
{
var llm = _settings.Settings.Llm;
var msgs = new List<object>();
foreach (var m in messages)
{
if (m.Role == "system") continue;
if (m.Images?.Count > 0)
{
// Claude Vision: content를 배열로 변환 (이미지 + 텍스트)
var contentParts = new List<object>();
foreach (var img in m.Images)
contentParts.Add(new { type = "image", source = new { type = "base64", media_type = img.MimeType, data = img.Base64 } });
contentParts.Add(new { type = "text", text = m.Content });
msgs.Add(new { role = m.Role, content = contentParts });
}
else
{
msgs.Add(new { role = m.Role, content = m.Content });
}
}
var activeModel = ResolveModel();
if (!string.IsNullOrEmpty(_systemPrompt))
{
return new
{
model = activeModel,
max_tokens = llm.MaxContextTokens,
temperature = llm.Temperature,
system = _systemPrompt,
messages = msgs,
stream
};
}
return new
{
model = activeModel,
max_tokens = llm.MaxContextTokens,
temperature = llm.Temperature,
messages = msgs,
stream
};
}
// ─── 공용 헬퍼 ─────────────────────────────────────────────────────────
private List<object> BuildMessageList(List<ChatMessage> messages, bool openAiVision = false)
{
var result = new List<object>();
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<object>();
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;
}
/// <summary>비스트리밍 POST + 재시도 (일시적 오류 시 최대 2회)</summary>
private async Task<string> 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("요청 실패");
}
/// <summary>스트리밍 전용 — HTTP 요청 전송 + 에러 분류</summary>
private async Task<HttpResponseMessage> 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;
}
/// <summary>스트리밍 ReadLine에 청크 타임아웃 적용</summary>
private static async Task<string?> 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; // 타임아웃 시 스트림 종료
}
}
/// <summary>JSON 파싱 안전 래퍼 — 파싱 실패 시 상세 에러 메시지 반환</summary>
private static string SafeParseJson(string json, Func<JsonElement, string> 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}");
}
}
/// <summary>HTTP 에러 코드별 사용자 친화적 메시지</summary>
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();
}

View File

@@ -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<int> _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();
});
}
}

View File

@@ -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("</body></html>");
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<int> _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) ──────────────────────────────────
/// <summary>
/// 작업 폴더에 AX.md가 있으면 내용을 읽어 시스템 프롬프트에 주입합니다.
/// Claude Code와 동일한 파일명/형식을 사용합니다.
/// </summary>
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 "";
}
/// <summary>Phase 27-B: 현재 파일 경로에 매칭되는 스킬을 시스템 프롬프트에 자동 주입.</summary>
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<AxCopilot.Services.Agent.SkillDefinition>();
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 ""; }
}
/// <summary>최근 대화에서 파일 경로를 추출합니다.</summary>
private List<string> GetRecentlyMentionedFiles()
{
var files = new List<string>();
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;
/// <summary>입력창 테두리에 무지개 그라데이션 회전 애니메이션을 재생합니다 (3초).</summary>
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();
}
/// <summary>레인보우 글로우 효과를 페이드아웃하며 중지합니다.</summary>
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);
}
}

View File

@@ -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) ──────────────────────────────────
/// <summary>
/// 작업 폴더에 AX.md가 있으면 내용을 읽어 시스템 프롬프트에 주입합니다.
/// Claude Code와 동일한 파일명/형식을 사용합니다.
/// </summary>
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 "";
}
/// <summary>Phase 27-B: 현재 파일 경로에 매칭되는 스킬을 시스템 프롬프트에 자동 주입.</summary>
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<AxCopilot.Services.Agent.SkillDefinition>();
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 ""; }
}
/// <summary>최근 대화에서 파일 경로를 추출합니다.</summary>
private List<string> GetRecentlyMentionedFiles()
{
var files = new List<string>();
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;
/// <summary>입력창 테두리에 무지개 그라데이션 회전 애니메이션을 재생합니다 (3초).</summary>
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();
}
/// <summary>레인보우 글로우 효과를 페이드아웃하며 중지합니다.</summary>
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);
}
}