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);
}
}
}