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