using System; using System.Collections.Generic; using UnityEngine; /// /// 대화 기록을 들고 백엔드에 요청을 넘긴다. UI 와 백엔드 사이의 유일한 연결점. /// /// UI 는 이 클래스의 이벤트만 구독하고 백엔드를 전혀 모른다. 반대로 백엔드는 /// UI 를 모른다. 나중에 대화 흐름을 사이드카로 옮기거나 음성 입력을 붙일 때 /// 이 자리가 그대로 접합부가 된다. /// /// MonoBehaviour 가 아닌 이유: 씬에 붙일 이유가 없고, 테스트에서 그냥 new 할 수 있다. /// public class ChatSession { readonly List history = new List(); IChatBackend backend; int maxHistoryMessages; /// 사용자 발화가 기록에 들어갔을 때. public event Action UserMessageAdded; /// 응답이 시작될 때. UI 는 여기서 빈 말풍선을 만든다. public event Action ResponseStarted; /// 응답 조각이 도착할 때마다. public event Action ResponseDelta; /// 응답이 끝났을 때. 인자는 전체 응답. public event Action ResponseCompleted; /// 실패했을 때. 인자는 사용자에게 보여줄 문장. public event Action ResponseFailed; public ChatSession(IChatBackend backend, int maxHistoryMessages) { this.backend = backend ?? throw new ArgumentNullException(nameof(backend)); this.maxHistoryMessages = Mathf.Max(2, maxHistoryMessages); } /// /// 지금 쓰는 백엔드. 설정에서 제공자(Claude / Gemini)를 바꾸면 여기가 교체된다. /// /// 세션을 새로 만들지 않고 갈아끼우는 이유는 대화를 잇기 위해서다. 기록은 /// 역할과 글자뿐이라 제공자가 달라져도 그대로 넘어간다. /// 교체 순간 진행 중이던 응답은 버린다 — 다른 곳으로 보낸 요청의 답을 /// 새 제공자의 대화에 이어붙일 수는 없다. /// public IChatBackend Backend { get => backend; set { if (value == null || ReferenceEquals(value, backend)) return; Cancel(); backend = value; } } /// /// 들고 갈 대화 길이. 설정에서 바꾸면 여기로 들어온다. /// 세션을 새로 만들지 않고 갈아끼우는 이유는 지금까지의 대화를 잃지 않기 위해서다. /// public int MaxHistoryMessages { get => maxHistoryMessages; set { maxHistoryMessages = Mathf.Max(2, value); TrimHistory(); } } /// 응답을 기다리는 중인지. public bool IsBusy => backend.IsBusy; public IReadOnlyList History => history; /// 사용자 발화를 기록에 넣고 응답을 요청한다. public void Send(string text) { if (string.IsNullOrWhiteSpace(text)) return; if (backend.IsBusy) { ResponseFailed?.Invoke("아직 대답하는 중이야. 잠깐만."); return; } text = text.Trim(); history.Add(new ChatMessage(ChatRole.User, text)); TrimHistory(); UserMessageAdded?.Invoke(text); ResponseStarted?.Invoke(); backend.Send( history, delta => ResponseDelta?.Invoke(delta), full => { history.Add(new ChatMessage(ChatRole.Assistant, full)); TrimHistory(); ResponseCompleted?.Invoke(full); }, reason => { // 실패한 턴의 사용자 발화는 기록에서 뺀다. 남겨두면 다음 요청에서 // user 가 연달아 두 번 나오는 모양이 되고, 재시도할 때 중복된다. if (history.Count > 0 && history[history.Count - 1].Role == ChatRole.User) { history.RemoveAt(history.Count - 1); } ResponseFailed?.Invoke(reason); }); } /// 진행 중인 응답을 중단한다. public void Cancel() { if (!backend.IsBusy) return; backend.Cancel(); if (history.Count > 0 && history[history.Count - 1].Role == ChatRole.User) { history.RemoveAt(history.Count - 1); } } /// 대화를 처음부터 다시 시작한다. public void Clear() { Cancel(); history.Clear(); } /// /// 오래된 대화를 버린다. /// /// 앞에서부터 버리되 assistant 로 시작하지 않도록 맞춘다. API 는 user 로 시작하는 /// 기록을 기대하고, assistant 로 시작하면 프리필로 해석돼 최신 모델에서는 거부된다. /// void TrimHistory() { while (history.Count > maxHistoryMessages) { history.RemoveAt(0); } while (history.Count > 0 && history[0].Role == ChatRole.Assistant) { history.RemoveAt(0); } } }