[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:
@@ -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차)
|
||||
|
||||
|
||||
252
src/AxCopilot/Services/LlmService.Helpers.cs
Normal file
252
src/AxCopilot/Services/LlmService.Helpers.cs
Normal 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();
|
||||
}
|
||||
516
src/AxCopilot/Services/LlmService.Streaming.cs
Normal file
516
src/AxCopilot/Services/LlmService.Streaming.cs
Normal 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
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
|
||||
277
src/AxCopilot/Views/ChatWindow.MessageActions.cs
Normal file
277
src/AxCopilot/Views/ChatWindow.MessageActions.cs
Normal 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();
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
498
src/AxCopilot/Views/ChatWindow.StatusAndUI.cs
Normal file
498
src/AxCopilot/Views/ChatWindow.StatusAndUI.cs
Normal 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);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user