Compare commits

...

3 Commits

Author SHA1 Message Date
94dc325df4 메시지 좋아요 싫어요 토글 동작 수정
Some checks failed
Release Gate / gate (push) Has been cancelled
채팅 메시지 피드백 버튼 로직을 shared feedback 상태 기반으로 다시 구성해 좋아요와 싫어요가 모두 즉시 반응하고 상호배타적으로 동작하도록 정리했다.

좋아요는 활성 색상과 chip 배경으로 상태가 확실히 보이게 바꾸고, 싫어요를 다시 누르면 null 상태로 정상 해제되도록 수정했다.

README와 DEVELOPMENT 문서를 2026-04-06 18:09 (KST) 기준으로 갱신했고 dotnet build 검증에서 경고 0 / 오류 0을 확인했다.
2026-04-06 17:56:53 +09:00
606ecbe6cd IBM vLLM 배포형 채팅 요청 스키마 분기와 문서 반영
IBM/CP4D 인증을 사용하는 vLLM 등록 모델에서 배포형 /ml/v1/deployments/.../text/chat 계열 엔드포인트를 감지하도록 정리했다.

일반 OpenAI 호환 body 대신 messages+parameters 형태의 IBM deployment chat body를 사용하고 /v1/chat/completions를 강제로 붙이지 않도록 수정했다.

IBM 배포형 응답은 results.generated_text, output_text, choices.message.content를 함께 파싱하도록 보강했고 도구 호출 경로는 안전하게 일반 응답 폴백을 유도하도록 정리했다.

README와 DEVELOPMENT 문서를 2026-04-06 18:02 (KST) 기준으로 갱신했고 dotnet build 검증에서 경고 0 / 오류 0을 확인했다.
2026-04-06 17:49:48 +09:00
e439fd144e 런처 즉시 표시 유지하고 색인 시작 시점 지연
LauncherWindow 사전 생성은 복원해 런처 호출 반응성을 유지했습니다.

대신 인덱스 전체 스캔과 감시는 런처를 여는 순간이 아니라 실제 검색이 시작될 때만 한 번 지연 시작하도록 변경했습니다.

README와 DEVELOPMENT 문서를 2026-04-06 17:52(KST) 기준으로 갱신했고 Release 빌드에서 경고 0 오류 0을 확인했습니다.
2026-04-06 17:43:03 +09:00
7 changed files with 303 additions and 97 deletions

View File

@@ -1308,3 +1308,13 @@ MIT License
- 업데이트: 2026-04-06 17:43 (KST) - 업데이트: 2026-04-06 17:43 (KST)
- 추가로 앱 시작 시 [LauncherWindow](/E:/AX%20Copilot%20-%20Codex/src/AxCopilot/Views/LauncherWindow.xaml.cs) 를 미리 생성하지 않고, 실제로 런처를 처음 열 때만 `EnsureLauncherCreated()`로 만들도록 바꿨다. 이로써 보이지 않는 상태의 런처 UI, 바인딩, 보조 타이머 준비 비용을 평소에는 지연시켰다. - 추가로 앱 시작 시 [LauncherWindow](/E:/AX%20Copilot%20-%20Codex/src/AxCopilot/Views/LauncherWindow.xaml.cs) 를 미리 생성하지 않고, 실제로 런처를 처음 열 때만 `EnsureLauncherCreated()`로 만들도록 바꿨다. 이로써 보이지 않는 상태의 런처 UI, 바인딩, 보조 타이머 준비 비용을 평소에는 지연시켰다.
- [App.xaml.cs](/E:/AX%20Copilot%20-%20Codex/src/AxCopilot/App.xaml.cs) 에서 트레이 메뉴의 `PrepareForDisplay()` 사전 렌더 호출도 제거해, 사용하지도 않는 트레이 팝업 레이아웃 계산을 앱 시작 직후 강제로 하지 않도록 정리했다. - [App.xaml.cs](/E:/AX%20Copilot%20-%20Codex/src/AxCopilot/App.xaml.cs) 에서 트레이 메뉴의 `PrepareForDisplay()` 사전 렌더 호출도 제거해, 사용하지도 않는 트레이 팝업 레이아웃 계산을 앱 시작 직후 강제로 하지 않도록 정리했다.
- 업데이트: 2026-04-06 17:52 (KST)
- 런처 표시 체감 속도를 유지하기 위해 [LauncherWindow](/E:/AX%20Copilot%20-%20Codex/src/AxCopilot/Views/LauncherWindow.xaml.cs) 사전 생성은 다시 복원했다. 대신 무거운 후보를 색인으로 더 좁히기 위해, [App.xaml.cs](/E:/AX%20Copilot%20-%20Codex/src/AxCopilot/App.xaml.cs)의 인덱스 워밍업 진입점을 런처 표시 시점이 아니라 실제 검색 시점으로 옮겼다.
- [LauncherViewModel.cs](/E:/AX%20Copilot%20-%20Codex/src/AxCopilot/ViewModels/LauncherViewModel.cs) 의 `SearchAsync(...)` 시작 시에만 `EnsureIndexWarmupStarted()`를 호출하도록 바꿔, 사용자가 런처를 단순 호출만 할 때는 전체 인덱스 스캔과 파일 감시가 돌지 않게 정리했다.
- 업데이트: 2026-04-06 18:02 (KST)
- IBM 연결형 vLLM에서 `model_id` 또는 `mode`를 body에 넣지 말라는 응답이 오던 문제를 수정했다. [LlmService.cs](/E:/AX%20Copilot%20-%20Codex/src/AxCopilot/Services/LlmService.cs)에 IBM/CP4D 인증 + `/ml/v1/deployments/.../text/chat` 계열 엔드포인트를 감지하는 분기를 추가하고, 이 경우 일반 OpenAI 호환 body 대신 `messages + parameters` 형태의 IBM deployment chat body를 사용하도록 바꿨다.
- 같은 파일에서 IBM deployment chat 경로는 `/v1/chat/completions`를 더 이상 강제로 붙이지 않고, 스트리밍 여부에 따라 `/text/chat` 또는 `/text/chat_stream` URL을 사용하도록 정리했다. 응답 파싱도 `results[].generated_text`, `output_text`, `choices[].message.content`를 함께 지원하게 확장했다.
- [LlmService.ToolUse.cs](/E:/AX%20Copilot%20-%20Codex/src/AxCopilot/Services/LlmService.ToolUse.cs) 에서는 IBM deployment chat API가 감지되면 OpenAI function-calling body를 그대로 보내지 않고 `ToolCallNotSupportedException`으로 일반 응답 경로 폴백을 유도하도록 안전장치를 추가했다.
- 업데이트: 2026-04-06 18:09 (KST)
- 채팅 메시지의 좋아요/싫어요 토글을 다시 정리했다. [ChatWindow.MessageInteractions.cs](/E:/AX%20Copilot%20-%20Codex/src/AxCopilot/Views/ChatWindow.MessageInteractions.cs) 에서 두 버튼이 각자 상태를 따로 들고 있던 구조를 없애고, 하나의 shared feedback 상태(`like/dislike/null`)를 기준으로 상호배타 토글되도록 재구성했다.
- 이제 `좋아요`도 즉시 색상/배경 상태가 바뀌고, `싫어요`를 다시 누르면 원래 상태(null)로 정상 해제된다. 버튼 시각 표현도 같은 glyph를 유지하되 active 색상과 라운드 chip 배경/테두리로 구분해, 특정 filled glyph가 보이지 않던 문제를 함께 줄였다.

View File

@@ -4992,3 +4992,10 @@ ow + toggle ?쒓컖 ?몄뼱濡??ㅼ떆 ?뺣젹?덈떎.
- Document update: 2026-04-06 17:35 (KST) - Changed launcher search warmup from eager startup work to on-demand initialization. `App.xaml.cs` now starts the first `IndexService.BuildAsync()` scan and `StartWatchers()` only when the launcher is actually shown through `ShowLauncherWindow()`, instead of running a full index scan and watcher hookup at boot even when the launcher is never opened. - Document update: 2026-04-06 17:35 (KST) - Changed launcher search warmup from eager startup work to on-demand initialization. `App.xaml.cs` now starts the first `IndexService.BuildAsync()` scan and `StartWatchers()` only when the launcher is actually shown through `ShowLauncherWindow()`, instead of running a full index scan and watcher hookup at boot even when the launcher is never opened.
- Document update: 2026-04-06 17:43 (KST) - Deferred `LauncherWindow` construction itself. `App.xaml.cs` now keeps only `LauncherViewModel` alive at startup and creates the actual `LauncherWindow` through `EnsureLauncherCreated()` the first time the launcher is shown, rather than instantiating the full hidden window at boot. - Document update: 2026-04-06 17:43 (KST) - Deferred `LauncherWindow` construction itself. `App.xaml.cs` now keeps only `LauncherViewModel` alive at startup and creates the actual `LauncherWindow` through `EnsureLauncherCreated()` the first time the launcher is shown, rather than instantiating the full hidden window at boot.
- Document update: 2026-04-06 17:43 (KST) - Removed the eager tray-menu warmup path (`PrepareForDisplay()`) from startup. This avoids doing popup layout/measure work for the tray menu until the user actually opens it, reducing idle desktop overhead further. - Document update: 2026-04-06 17:43 (KST) - Removed the eager tray-menu warmup path (`PrepareForDisplay()`) from startup. This avoids doing popup layout/measure work for the tray menu until the user actually opens it, reducing idle desktop overhead further.
- Document update: 2026-04-06 17:52 (KST) - Restored eager `LauncherWindow` construction so launcher open latency stays low, but kept the tray-menu warmup removed. This keeps the launcher responsive while continuing to trim startup work elsewhere.
- Document update: 2026-04-06 17:52 (KST) - Moved index warmup from launcher-show time to actual search time. `LauncherViewModel.SearchAsync(...)` now triggers `App.EnsureIndexWarmupStarted()` only when the user performs a real search, so opening the launcher by itself no longer starts a full file scan and watcher bootstrap.
- Document update: 2026-04-06 18:02 (KST) - Added IBM deployment-chat detection in `LlmService.cs` for vLLM registered models using `ibm_iam` or `cp4d_*` auth with `/ml/v1/deployments/...`-style endpoints. These requests now use IBM deployment chat URLs (`/text/chat` or `/text/chat_stream`) instead of appending `/v1/chat/completions`.
- Document update: 2026-04-06 18:02 (KST) - Added an IBM deployment request body builder in `LlmService.cs` that omits OpenAI-style `model` and `stream` fields and sends `messages + parameters` instead. This directly addresses IBM responses complaining that `model_id` or `mode` must not be specified in the request body.
- Document update: 2026-04-06 18:02 (KST) - Hardened vLLM response handling for IBM deployment endpoints by accepting `results[].generated_text`, `output_text`, and `choices[].message.content`, and by short-circuiting tool-use requests in `LlmService.ToolUse.cs` with a `ToolCallNotSupportedException` so IBM deployment chat connections do not receive an incompatible OpenAI function-calling payload.
- Document update: 2026-04-06 18:09 (KST) - Reworked message feedback toggles in `ChatWindow.MessageInteractions.cs`. `좋아요/싫어요` no longer keep separate local state with sibling reset callbacks; both buttons now derive from one shared `like/dislike/null` state and persist that single value back to the conversation/session.
- Document update: 2026-04-06 18:09 (KST) - The feedback buttons now use the same glyph in both idle/active states and express activation through color plus rounded chip background/border, which avoids cases where the like filled-glyph was visually missing and ensures pressing `싫어요` again properly returns the message to an unselected state.

View File

@@ -14,7 +14,6 @@ public partial class App : System.Windows.Application
private System.Threading.Mutex? _singleInstanceMutex; private System.Threading.Mutex? _singleInstanceMutex;
private InputListener? _inputListener; private InputListener? _inputListener;
private LauncherWindow? _launcher; private LauncherWindow? _launcher;
private LauncherViewModel? _launcherViewModel;
private NotifyIcon? _trayIcon; private NotifyIcon? _trayIcon;
private Views.TrayMenuWindow? _trayMenu; private Views.TrayMenuWindow? _trayMenu;
private SettingsService? _settings; private SettingsService? _settings;
@@ -278,7 +277,11 @@ public partial class App : System.Windows.Application
_pluginHost = pluginHost; _pluginHost = pluginHost;
// ─── 런처 윈도우 ────────────────────────────────────────────────────── // ─── 런처 윈도우 ──────────────────────────────────────────────────────
_launcherViewModel = new LauncherViewModel(commandResolver, settings); var vm = new LauncherViewModel(commandResolver, settings);
_launcher = new LauncherWindow(vm)
{
OpenSettingsAction = OpenSettings
};
// ─── 클립보드 히스토리 초기화 (메시지 펌프 시작 직후 — 런처 표시 불필요) ── // ─── 클립보드 히스토리 초기화 (메시지 펌프 시작 직후 — 런처 표시 불필요) ──
Dispatcher.BeginInvoke( Dispatcher.BeginInvoke(
@@ -667,7 +670,7 @@ public partial class App : System.Windows.Application
/// <summary>AX Agent 창 열기 (트레이 메뉴 등에서 호출).</summary> /// <summary>AX Agent 창 열기 (트레이 메뉴 등에서 호출).</summary>
private Views.ChatWindow? _chatWindow; private Views.ChatWindow? _chatWindow;
private void EnsureIndexWarmupStarted() public void EnsureIndexWarmupStarted()
{ {
if (_indexService == null) return; if (_indexService == null) return;
if (Interlocked.Exchange(ref _indexWarmupStarted, 1) == 1) return; if (Interlocked.Exchange(ref _indexWarmupStarted, 1) == 1) return;
@@ -688,22 +691,10 @@ public partial class App : System.Windows.Application
private void ShowLauncherWindow() private void ShowLauncherWindow()
{ {
EnsureLauncherCreated();
if (_launcher == null) return; if (_launcher == null) return;
EnsureIndexWarmupStarted();
_launcher.Show(); _launcher.Show();
} }
private void EnsureLauncherCreated()
{
if (_launcher != null || _launcherViewModel == null) return;
_launcher = new LauncherWindow(_launcherViewModel)
{
OpenSettingsAction = OpenSettings
};
}
private void OpenAiChat() private void OpenAiChat()
{ {
if (_settings == null) return; if (_settings == null) return;

View File

@@ -431,13 +431,20 @@ public partial class LlmService
List<ChatMessage> messages, IReadOnlyCollection<IAgentTool> tools, CancellationToken ct) List<ChatMessage> messages, IReadOnlyCollection<IAgentTool> tools, CancellationToken ct)
{ {
var activeService = ResolveService(); var activeService = ResolveService();
var body = BuildOpenAiToolBody(messages, tools);
// 등록 모델의 커스텀 엔드포인트 우선 사용 (ResolveServerInfo) // 등록 모델의 커스텀 엔드포인트 우선 사용 (ResolveServerInfo)
var (resolvedEp, _, allowInsecureTls) = ResolveServerInfo(); var (resolvedEp, _, allowInsecureTls) = ResolveServerInfo();
var endpoint = string.IsNullOrEmpty(resolvedEp) var endpoint = string.IsNullOrEmpty(resolvedEp)
? ResolveEndpointForService(activeService) ? ResolveEndpointForService(activeService)
: resolvedEp; : resolvedEp;
var registered = GetActiveRegisteredModel();
if (UsesIbmDeploymentChatApi(activeService, registered, endpoint))
{
throw new ToolCallNotSupportedException(
"IBM 배포형 vLLM 연결은 OpenAI 도구 호출 형식과 다를 수 있어 일반 대화 경로로 폴백합니다.");
}
var body = BuildOpenAiToolBody(messages, tools);
var url = activeService.ToLowerInvariant() == "ollama" var url = activeService.ToLowerInvariant() == "ollama"
? endpoint.TrimEnd('/') + "/api/chat" ? endpoint.TrimEnd('/') + "/api/chat"

View File

@@ -320,6 +320,103 @@ public partial class LlmService : IDisposable
m.Alias == modelName)); m.Alias == modelName));
} }
private Models.RegisteredModel? GetActiveRegisteredModel()
{
var llm = _settings.Settings.Llm;
return FindRegisteredModel(llm, ResolveService(), ResolveModel());
}
private static bool UsesIbmDeploymentChatApi(string service, Models.RegisteredModel? registered, string? endpoint)
{
if (!string.Equals(NormalizeServiceName(service), "vllm", StringComparison.OrdinalIgnoreCase))
return false;
if (registered == null)
return false;
var authType = (registered.AuthType ?? "").Trim().ToLowerInvariant();
if (authType is not ("ibm_iam" or "cp4d" or "cp4d_password" or "cp4d_api_key"))
return false;
var normalizedEndpoint = (endpoint ?? "").Trim().ToLowerInvariant();
return normalizedEndpoint.Contains("/ml/") ||
normalizedEndpoint.Contains("/deployments/") ||
normalizedEndpoint.Contains("/text/chat");
}
private string BuildIbmDeploymentChatUrl(string endpoint, bool stream)
{
var trimmed = (endpoint ?? "").Trim();
if (string.IsNullOrWhiteSpace(trimmed))
throw new InvalidOperationException("IBM 배포형 vLLM 엔드포인트가 비어 있습니다.");
var normalized = trimmed.ToLowerInvariant();
if (normalized.Contains("/text/chat_stream"))
return stream ? trimmed : trimmed.Replace("/text/chat_stream", "/text/chat", StringComparison.OrdinalIgnoreCase);
if (normalized.Contains("/text/chat"))
return stream ? trimmed.Replace("/text/chat", "/text/chat_stream", StringComparison.OrdinalIgnoreCase) : trimmed;
if (normalized.Contains("/deployments/"))
return trimmed.TrimEnd('/') + (stream ? "/text/chat_stream" : "/text/chat");
return trimmed;
}
private object BuildIbmDeploymentBody(List<ChatMessage> messages)
{
var msgs = new List<object>();
if (!string.IsNullOrWhiteSpace(_systemPrompt))
msgs.Add(new { role = "system", content = _systemPrompt });
foreach (var m in messages)
{
if (m.Role == "system")
continue;
msgs.Add(new
{
role = m.Role == "assistant" ? "assistant" : "user",
content = m.Content
});
}
return new
{
messages = msgs,
parameters = new
{
temperature = ResolveTemperature(),
max_new_tokens = ResolveOpenAiCompatibleMaxTokens()
}
};
}
private static string ExtractIbmDeploymentText(JsonElement root)
{
if (root.TryGetProperty("choices", out var choices) && choices.ValueKind == JsonValueKind.Array && choices.GetArrayLength() > 0)
{
var message = choices[0].TryGetProperty("message", out var choiceMessage) ? choiceMessage : default;
if (message.ValueKind == JsonValueKind.Object &&
message.TryGetProperty("content", out var content))
return content.GetString() ?? "";
}
if (root.TryGetProperty("results", out var results) && results.ValueKind == JsonValueKind.Array && results.GetArrayLength() > 0)
{
var first = results[0];
if (first.TryGetProperty("generated_text", out var generatedText))
return generatedText.GetString() ?? "";
if (first.TryGetProperty("output_text", out var outputText))
return outputText.GetString() ?? "";
}
if (root.TryGetProperty("generated_text", out var generated))
return generated.GetString() ?? "";
if (root.TryGetProperty("message", out var messageValue) && messageValue.ValueKind == JsonValueKind.String)
return messageValue.GetString() ?? "";
return "";
}
/// <summary> /// <summary>
/// 현재 활성 모델의 인증 헤더 값을 반환합니다. /// 현재 활성 모델의 인증 헤더 값을 반환합니다.
/// IBM IAM / CP4D 인증인 경우 토큰을 자동 발급/캐싱하여 반환합니다. /// IBM IAM / CP4D 인증인 경우 토큰을 자동 발급/캐싱하여 반환합니다.
@@ -606,8 +703,14 @@ public partial class LlmService : IDisposable
var llm = _settings.Settings.Llm; var llm = _settings.Settings.Llm;
var (endpoint, _, allowInsecureTls) = ResolveServerInfo(); var (endpoint, _, allowInsecureTls) = ResolveServerInfo();
var ep = string.IsNullOrEmpty(endpoint) ? llm.Endpoint : endpoint; var ep = string.IsNullOrEmpty(endpoint) ? llm.Endpoint : endpoint;
var body = BuildOpenAiBody(messages, stream: false); var registered = GetActiveRegisteredModel();
var url = ep.TrimEnd('/') + "/v1/chat/completions"; var usesIbmDeploymentApi = UsesIbmDeploymentChatApi("vllm", registered, ep);
var body = usesIbmDeploymentApi
? BuildIbmDeploymentBody(messages)
: BuildOpenAiBody(messages, stream: false);
var url = usesIbmDeploymentApi
? BuildIbmDeploymentChatUrl(ep, stream: false)
: ep.TrimEnd('/') + "/v1/chat/completions";
var json = JsonSerializer.Serialize(body); var json = JsonSerializer.Serialize(body);
using var req = new HttpRequestMessage(HttpMethod.Post, url) using var req = new HttpRequestMessage(HttpMethod.Post, url)
@@ -621,6 +724,12 @@ public partial class LlmService : IDisposable
return SafeParseJson(respBody, root => return SafeParseJson(respBody, root =>
{ {
TryParseOpenAiUsage(root); TryParseOpenAiUsage(root);
if (usesIbmDeploymentApi)
{
var parsed = ExtractIbmDeploymentText(root);
return string.IsNullOrWhiteSpace(parsed) ? "(빈 응답)" : parsed;
}
var choices = root.GetProperty("choices"); var choices = root.GetProperty("choices");
if (choices.GetArrayLength() == 0) return "(빈 응답)"; if (choices.GetArrayLength() == 0) return "(빈 응답)";
return choices[0].GetProperty("message").GetProperty("content").GetString() ?? ""; return choices[0].GetProperty("message").GetProperty("content").GetString() ?? "";
@@ -634,8 +743,14 @@ public partial class LlmService : IDisposable
var llm = _settings.Settings.Llm; var llm = _settings.Settings.Llm;
var (endpoint, _, allowInsecureTls) = ResolveServerInfo(); var (endpoint, _, allowInsecureTls) = ResolveServerInfo();
var ep = string.IsNullOrEmpty(endpoint) ? llm.Endpoint : endpoint; var ep = string.IsNullOrEmpty(endpoint) ? llm.Endpoint : endpoint;
var body = BuildOpenAiBody(messages, stream: true); var registered = GetActiveRegisteredModel();
var url = ep.TrimEnd('/') + "/v1/chat/completions"; var usesIbmDeploymentApi = UsesIbmDeploymentChatApi("vllm", registered, ep);
var body = usesIbmDeploymentApi
? BuildIbmDeploymentBody(messages)
: BuildOpenAiBody(messages, stream: true);
var url = usesIbmDeploymentApi
? BuildIbmDeploymentChatUrl(ep, stream: true)
: ep.TrimEnd('/') + "/v1/chat/completions";
using var req = new HttpRequestMessage(HttpMethod.Post, url) { Content = JsonContent(body) }; using var req = new HttpRequestMessage(HttpMethod.Post, url) { Content = JsonContent(body) };
await ApplyAuthHeaderAsync(req, ct); await ApplyAuthHeaderAsync(req, ct);
@@ -657,12 +772,43 @@ public partial class LlmService : IDisposable
{ {
using var doc = JsonDocument.Parse(data); using var doc = JsonDocument.Parse(data);
TryParseOpenAiUsage(doc.RootElement); TryParseOpenAiUsage(doc.RootElement);
var choices = doc.RootElement.GetProperty("choices"); if (usesIbmDeploymentApi)
if (choices.GetArrayLength() > 0)
{ {
var delta = choices[0].GetProperty("delta"); if (doc.RootElement.TryGetProperty("status", out var status) &&
if (delta.TryGetProperty("content", out var c)) string.Equals(status.GetString(), "error", StringComparison.OrdinalIgnoreCase))
text = c.GetString(); {
var detail = doc.RootElement.TryGetProperty("message", out var message)
? message.GetString()
: "IBM vLLM 스트리밍 오류";
throw new InvalidOperationException(detail);
}
if (doc.RootElement.TryGetProperty("results", out var results) &&
results.ValueKind == JsonValueKind.Array &&
results.GetArrayLength() > 0)
{
var first = results[0];
if (first.TryGetProperty("generated_text", out var generatedText))
text = generatedText.GetString();
else if (first.TryGetProperty("output_text", out var outputText))
text = outputText.GetString();
}
else if (doc.RootElement.TryGetProperty("choices", out var ibmChoices) && ibmChoices.GetArrayLength() > 0)
{
var delta = ibmChoices[0].GetProperty("delta");
if (delta.TryGetProperty("content", out var c))
text = c.GetString();
}
}
else
{
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) catch (JsonException ex)

View File

@@ -280,6 +280,8 @@ public partial class LauncherViewModel : INotifyPropertyChanged
private async Task SearchAsync(string query) private async Task SearchAsync(string query)
{ {
(System.Windows.Application.Current as App)?.EnsureIndexWarmupStarted();
// CTS 취소는 setter에서 이미 처리됨. 새 토큰만 발급. // CTS 취소는 setter에서 이미 처리됨. 새 토큰만 발급.
_searchCts = new CancellationTokenSource(); _searchCts = new CancellationTokenSource();
var ct = _searchCts.Token; var ct = _searchCts.Token;

View File

@@ -11,87 +11,76 @@ namespace AxCopilot.Views;
public partial class ChatWindow public partial class ChatWindow
{ {
/// <summary>좋아요/싫어요 토글 피드백 버튼 (상태 영구 저장)</summary> /// <summary>좋아요/싫어요 피드백 버튼을 생성합니다.</summary>
private Button CreateFeedbackButton(string outline, string filled, string tooltip, private Button CreateFeedbackButton(
Brush normalColor, Brush activeColor, ChatMessage? message = null, string feedbackType = "", string iconGlyph,
Action? resetSibling = null, Action<Action>? registerReset = null) string tooltip,
Brush normalColor,
Brush activeColor,
Func<bool> isActive,
Action toggle)
{ {
var hoverBrush = TryFindResource("PrimaryText") as Brush ?? Brushes.White; var hoverBrush = TryFindResource("PrimaryText") as Brush ?? Brushes.White;
var isActive = message?.Feedback == feedbackType; var activeBackground = TryFindResource("ItemHoverBackground") as Brush
?? new SolidColorBrush(Color.FromArgb(18, 255, 255, 255));
var icon = new TextBlock var icon = new TextBlock
{ {
Text = isActive ? filled : outline, Text = iconGlyph,
FontFamily = new FontFamily("Segoe MDL2 Assets"), FontFamily = new FontFamily("Segoe MDL2 Assets"),
FontSize = 12, FontSize = 12,
Foreground = isActive ? activeColor : normalColor, Foreground = normalColor,
VerticalAlignment = VerticalAlignment.Center, VerticalAlignment = VerticalAlignment.Center,
HorizontalAlignment = HorizontalAlignment.Center,
RenderTransformOrigin = new Point(0.5, 0.5), RenderTransformOrigin = new Point(0.5, 0.5),
RenderTransform = new ScaleTransform(1, 1) RenderTransform = new ScaleTransform(1, 1)
}; };
var chip = new Border
{
Background = Brushes.Transparent,
BorderBrush = Brushes.Transparent,
BorderThickness = new Thickness(1),
CornerRadius = new CornerRadius(8),
Padding = new Thickness(6, 4, 6, 4),
Margin = new Thickness(0, 0, 4, 0),
Child = icon
};
var btn = new Button var btn = new Button
{ {
Content = icon, Content = chip,
Background = Brushes.Transparent, Background = Brushes.Transparent,
BorderThickness = new Thickness(0), BorderThickness = new Thickness(0),
Cursor = Cursors.Hand, Cursor = Cursors.Hand,
Padding = new Thickness(6, 4, 6, 4), Padding = new Thickness(0),
Margin = new Thickness(0, 0, 4, 0),
ToolTip = tooltip ToolTip = tooltip
}; };
registerReset?.Invoke(() => void RefreshVisual()
{ {
isActive = false; var active = isActive();
icon.Text = outline; icon.Foreground = active ? activeColor : normalColor;
icon.Foreground = normalColor; chip.Background = active ? activeBackground : Brushes.Transparent;
}); chip.BorderBrush = active ? activeColor : Brushes.Transparent;
}
btn.MouseEnter += (_, _) => { if (!isActive) icon.Foreground = hoverBrush; }; RefreshVisual();
btn.MouseLeave += (_, _) => { if (!isActive) icon.Foreground = normalColor; };
btn.MouseEnter += (_, _) =>
{
if (!isActive())
icon.Foreground = hoverBrush;
};
btn.MouseLeave += (_, _) =>
{
if (!isActive())
icon.Foreground = normalColor;
};
btn.Click += (_, _) => btn.Click += (_, _) =>
{ {
isActive = !isActive; toggle();
icon.Text = isActive ? filled : outline; RefreshVisual();
icon.Foreground = isActive ? activeColor : normalColor;
if (isActive)
{
resetSibling?.Invoke();
}
if (message != null)
{
try
{
var feedback = isActive ? feedbackType : null;
var session = ChatSession;
if (session != null)
{
lock (_convLock)
{
session.UpdateMessageFeedback(_activeTab, message, feedback, _storage);
_currentConversation = session.CurrentConversation;
}
}
else
{
message.Feedback = feedback;
ChatConversation? conv;
lock (_convLock)
{
conv = _currentConversation;
}
if (conv != null)
{
_storage.Save(conv);
}
}
}
catch
{
}
}
var scale = (ScaleTransform)icon.RenderTransform; var scale = (ScaleTransform)icon.RenderTransform;
var bounce = new DoubleAnimation(1.3, 1.0, TimeSpan.FromMilliseconds(250)) var bounce = new DoubleAnimation(1.3, 1.0, TimeSpan.FromMilliseconds(250))
@@ -106,23 +95,77 @@ public partial class ChatWindow
scale.BeginAnimation(ScaleTransform.ScaleXProperty, bounce); scale.BeginAnimation(ScaleTransform.ScaleXProperty, bounce);
scale.BeginAnimation(ScaleTransform.ScaleYProperty, bounce); scale.BeginAnimation(ScaleTransform.ScaleYProperty, bounce);
}; };
return btn; return btn;
} }
/// <summary>좋아요/싫어요 버튼을 상호 배타로 연결하여 추가</summary> /// <summary>좋아요/싫어요 버튼을 상호배타 토글로 추가합니다.</summary>
private void AddLinkedFeedbackButtons(StackPanel actionBar, Brush btnColor, ChatMessage? message) private void AddLinkedFeedbackButtons(StackPanel actionBar, Brush btnColor, ChatMessage? message)
{ {
Action? resetLikeAction = null; string? currentFeedback = message?.Feedback;
Action? resetDislikeAction = null;
var likeBtn = CreateFeedbackButton("\uE8E1", "\uEB51", "좋아요", btnColor, void PersistFeedback()
new SolidColorBrush(Color.FromRgb(0x38, 0xA1, 0x69)), message, "like", {
resetSibling: () => resetDislikeAction?.Invoke(), if (message == null)
registerReset: reset => resetLikeAction = reset); return;
var dislikeBtn = CreateFeedbackButton("\uE8E0", "\uEB50", "싫어요", btnColor,
new SolidColorBrush(Color.FromRgb(0xE5, 0x3E, 0x3E)), message, "dislike", try
resetSibling: () => resetLikeAction?.Invoke(), {
registerReset: reset => resetDislikeAction = reset); var feedback = string.IsNullOrWhiteSpace(currentFeedback) ? null : currentFeedback;
var session = ChatSession;
if (session != null)
{
lock (_convLock)
{
session.UpdateMessageFeedback(_activeTab, message, feedback, _storage);
_currentConversation = session.CurrentConversation;
}
}
else
{
message.Feedback = feedback;
ChatConversation? conv;
lock (_convLock)
{
conv = _currentConversation;
}
if (conv != null)
_storage.Save(conv);
}
}
catch
{
}
}
var likeBtn = CreateFeedbackButton(
"\uE8E1",
"좋아요",
btnColor,
new SolidColorBrush(Color.FromRgb(0x38, 0xA1, 0x69)),
() => string.Equals(currentFeedback, "like", StringComparison.OrdinalIgnoreCase),
() =>
{
currentFeedback = string.Equals(currentFeedback, "like", StringComparison.OrdinalIgnoreCase)
? null
: "like";
PersistFeedback();
});
var dislikeBtn = CreateFeedbackButton(
"\uE8E0",
"싫어요",
btnColor,
new SolidColorBrush(Color.FromRgb(0xE5, 0x3E, 0x3E)),
() => string.Equals(currentFeedback, "dislike", StringComparison.OrdinalIgnoreCase),
() =>
{
currentFeedback = string.Equals(currentFeedback, "dislike", StringComparison.OrdinalIgnoreCase)
? null
: "dislike";
PersistFeedback();
});
actionBar.Children.Add(likeBtn); actionBar.Children.Add(likeBtn);
actionBar.Children.Add(dislikeBtn); actionBar.Children.Add(dislikeBtn);
@@ -157,7 +200,7 @@ public partial class ChatWindow
return new TextBlock return new TextBlock
{ {
Text = string.Join(" · ", parts), Text = string.Join(" ", parts),
FontSize = 9.75, FontSize = 9.75,
Foreground = TryFindResource("SecondaryText") as Brush ?? Brushes.Gray, Foreground = TryFindResource("SecondaryText") as Brush ?? Brushes.Gray,
HorizontalAlignment = HorizontalAlignment.Left, HorizontalAlignment = HorizontalAlignment.Left,
@@ -386,7 +429,7 @@ public partial class ChatWindow
} }
catch (Exception ex) catch (Exception ex)
{ {
Services.LogService.Debug($"대화 저장 실패: {ex.Message}"); Services.LogService.Debug($"편집 저장 실패: {ex.Message}");
} }
RefreshConversationList(); RefreshConversationList();