Files
MyCharacterAgent/Assets/02_Scripts/Chat/GeminiChatBackend.cs
2026-09-01 14:40:02 +09:00

611 lines
20 KiB
C#

using System;
using System.Collections;
using System.Collections.Generic;
using System.Text;
using UnityEngine;
using UnityEngine.Networking;
/// <summary>
/// Google Gemini API 를 UnityWebRequest 로 직접 친다. SSE 스트리밍.
///
/// AnthropicChatBackend 와 형태가 거의 같다. 다른 점만 정리하면:
/// - 인증: x-goog-api-key 헤더 (쿼리스트링 ?key= 도 되지만 URL 은 로그에 남는다)
/// - 대화 역할: "user" / "model" — assistant 가 아니다
/// - 페르소나: system 이 아니라 systemInstruction, contents 와 형제
/// - 델타 위치: candidates[0].content.parts[0].text
/// - 사고 깊이(effort)에 해당하는 설정은 넣지 않는다. 모델 세대마다 thinkingConfig
/// 모양이 달라서 잘못 보내면 400 이 난다. 기본값에 맡긴다.
///
/// 무료 티어 주의: Google 가격 정책상 무료 티어는 주고받은 내용이 제품 개선에
/// 사용된다. 유료 티어는 사용되지 않는다. 설정 화면에서 사용자에게 그대로 알린다.
/// </summary>
public class GeminiChatBackend : MonoBehaviour, IChatBackend
{
const string BaseUrl = "https://generativelanguage.googleapis.com/v1beta/models/";
const string ModelsListUrl = "https://generativelanguage.googleapis.com/v1beta/models";
const string KeyHeader = "x-goog-api-key";
[Tooltip("응답이 아예 오지 않을 때 포기하는 시간(초). 0 이면 무제한")]
[SerializeField] int timeoutSeconds = 60;
[Tooltip("요청/응답 진행 상황을 콘솔에 남긴다. 응답이 안 올 때 어디서 막혔는지 보려면 켠다")]
[SerializeField] bool verboseLog = true;
/// <summary>
/// 설정 화면에 보여줄 모델 선택지. 전부 무료 티어에 포함된 Flash 계열이다.
/// 데스크톱 비서는 짧게 여러 번 주고받으므로 Pro 보다 Flash 가 맞다.
/// </summary>
public static readonly (string Label, string Id)[] SelectableModels =
{
("3.7 Flash", "gemini-3.7-flash"),
("3.5 Flash", "gemini-3.5-flash"),
("2.5 Flash", "gemini-2.5-flash"),
};
ChatConfig config;
UnityWebRequest inflight;
Coroutine routine;
public bool IsBusy => inflight != null;
public void Configure(ChatConfig chatConfig)
{
config = chatConfig;
}
public void Send(IReadOnlyList<ChatMessage> history,
Action<string> onDelta,
Action<string> onComplete,
Action<string> onError)
{
if (IsBusy)
{
onError?.Invoke("아직 이전 응답을 받는 중이야.");
return;
}
if (config == null) config = ChatConfig.Load();
string apiKey = config.ResolveGeminiKey();
if (string.IsNullOrEmpty(apiKey))
{
onError?.Invoke("Gemini API 키가 없어. 설정에서 넣어줘.");
return;
}
routine = StartCoroutine(SendRoutine(apiKey, history, onDelta, onComplete, onError));
}
public void Cancel()
{
if (routine != null)
{
StopCoroutine(routine);
routine = null;
}
var request = inflight;
inflight = null;
if (request != null)
{
try { request.Abort(); } catch (Exception) { }
request.Dispose();
}
}
void OnDestroy()
{
Cancel();
}
IEnumerator SendRoutine(string apiKey,
IReadOnlyList<ChatMessage> history,
Action<string> onDelta,
Action<string> onComplete,
Action<string> onError)
{
string body = BuildRequestJson(history);
// 모델 ID 는 영숫자와 . - 뿐이라 이스케이프할 것이 없다. EscapeURL 을 거치면
// 오히려 예상 못 한 변환이 끼어들 여지만 생긴다.
string url = BaseUrl + config.geminiModel + ":streamGenerateContent?alt=sse";
var request = new UnityWebRequest(url, UnityWebRequest.kHttpVerbPOST);
var handler = new SseDownloadHandler();
request.uploadHandler = new UploadHandlerRaw(Encoding.UTF8.GetBytes(body));
request.downloadHandler = handler;
request.SetRequestHeader("content-type", "application/json");
request.SetRequestHeader("accept", "text/event-stream");
request.SetRequestHeader(KeyHeader, apiKey);
if (timeoutSeconds > 0) request.timeout = timeoutSeconds;
handler.Bind(request, onDelta);
inflight = request;
float startedAt = Time.realtimeSinceStartup;
if (verboseLog)
{
Debug.Log($"[Gemini] 요청 전송: model={config.geminiModel} " +
$"메시지 {history.Count}개 본문 {body.Length}자");
}
yield return request.SendWebRequest();
routine = null;
if (verboseLog)
{
float elapsed = Time.realtimeSinceStartup - startedAt;
Debug.Log($"[Gemini] 응답 종료: HTTP {request.responseCode} result={request.result} " +
$"{elapsed:F1}초 / 받은 글자 {handler.Text.Length} / " +
$"finishReason={handler.FinishReason ?? "-"}\nURL: {url}");
// 오류 본문에 이유가 그대로 적혀 있다. 404 라면 어떤 모델명이 왜 거부됐는지 나온다.
if (!string.IsNullOrEmpty(handler.RawError))
{
Debug.LogWarning($"[Gemini] 오류 본문: {handler.RawError}");
}
}
// Cancel() 이 먼저 참조를 끊었다면 이 응답은 버려진 것이다.
if (inflight != request)
{
request.Dispose();
yield break;
}
inflight = null;
bool failed = request.result != UnityWebRequest.Result.Success || request.responseCode >= 400;
string failure = failed ? DescribeFailure(request, handler) : null;
string full = failed ? null : handler.Text;
request.Dispose();
if (failed)
{
onError?.Invoke(failure);
yield break;
}
if (string.IsNullOrWhiteSpace(full))
{
// 안전 필터에 걸리면 텍스트 없이 finishReason 만 온다.
onError?.Invoke(string.IsNullOrEmpty(handler.FinishReason) ||
handler.FinishReason == "STOP"
? "응답이 비어 있어. 잠시 뒤 다시 시도해줘."
: $"응답이 중단됐어 ({handler.FinishReason}).");
yield break;
}
onComplete?.Invoke(full);
}
static string DescribeFailure(UnityWebRequest request, SseDownloadHandler handler)
{
string apiMessage = handler.ErrorMessage;
if (!string.IsNullOrEmpty(apiMessage))
{
if (request.responseCode == 400) return "요청이 거부됐어: " + apiMessage;
if (request.responseCode == 401 || request.responseCode == 403)
return "API 키가 올바르지 않아: " + apiMessage;
if (request.responseCode == 429) return "무료 한도에 걸렸어. 잠시 뒤 다시 해줘.";
return "오류 " + request.responseCode + ": " + apiMessage;
}
if (request.result == UnityWebRequest.Result.ConnectionError)
return "연결에 실패했어. 네트워크를 확인해줘. (" + request.error + ")";
return "오류 " + request.responseCode + ": " + request.error;
}
string BuildRequestJson(IReadOnlyList<ChatMessage> history)
{
var contents = new Content[history.Count];
for (int i = 0; i < history.Count; i++)
{
contents[i] = new Content
{
// Anthropic 은 assistant, Gemini 는 model 이다.
role = history[i].Role == ChatRole.User ? "user" : "model",
parts = new[] { new Part { text = history[i].Text } },
};
}
var request = new Request
{
contents = contents,
systemInstruction = new SystemInstruction
{
parts = new[] { new Part { text = config.persona } },
},
generationConfig = new GenerationConfig
{
maxOutputTokens = Mathf.Max(64, config.maxTokens),
},
};
return JsonUtility.ToJson(request);
}
// ------------------------------------------------------------------ 연결 테스트
/// <summary>
/// 메시지를 보내지 않고 모델 정보만 조회한다. 토큰을 쓰지 않으면서
/// 키(403)와 모델 이름(404)을 한 번에 가른다.
/// </summary>
public void TestConnection(ChatConfig candidate, Action<bool, string> onResult)
{
if (candidate == null)
{
onResult?.Invoke(false, "설정이 비어 있어요.");
return;
}
string apiKey = candidate.ResolveGeminiKey();
if (string.IsNullOrEmpty(apiKey))
{
onResult?.Invoke(false, "API 키를 먼저 입력해주세요.");
return;
}
StartCoroutine(TestRoutine(apiKey, candidate.geminiModel, onResult));
}
IEnumerator TestRoutine(string apiKey, string model, Action<bool, string> onResult)
{
long code;
string errorBody;
using (var request = UnityWebRequest.Get(BaseUrl + model))
{
request.SetRequestHeader(KeyHeader, apiKey);
request.timeout = 20;
yield return request.SendWebRequest();
if (request.result == UnityWebRequest.Result.ConnectionError)
{
onResult?.Invoke(false, "네트워크에 연결할 수 없어요.");
yield break;
}
code = request.responseCode;
errorBody = request.downloadHandler != null ? request.downloadHandler.text : null;
}
switch (code)
{
case 200:
onResult?.Invoke(true, $"연결 성공 — {model}");
yield break;
case 400:
case 401:
case 403:
if (verboseLog && !string.IsNullOrEmpty(errorBody))
{
Debug.LogWarning($"[Gemini] 키 확인 실패 본문: {errorBody}");
}
onResult?.Invoke(false, "API 키가 올바르지 않아요.");
yield break;
case 429:
onResult?.Invoke(true, "키는 정상인데 요청 한도에 걸려 있어요.");
yield break;
case 404:
// 모델 이름이 거부됐다. 문서를 뒤지는 대신 이 키로 실제 쓸 수 있는
// 목록을 서버에 물어본다. 어떤 이름을 골라야 하는지 그게 정답이다.
yield return ListUsableModels(apiKey, model, onResult);
yield break;
default:
onResult?.Invoke(false, $"확인 실패 (오류 {code})");
yield break;
}
}
/// <summary>
/// 이 키로 쓸 수 있고 streamGenerateContent 를 지원하는 모델을 찾아 알려준다.
///
/// 모델 이름은 세대마다 바뀌고 계정/지역에 따라 접근 권한도 다르다. 문서에 있다고
/// 내 키로 된다는 보장이 없으므로, 막혔을 때는 서버에 직접 묻는 쪽이 확실하다.
/// </summary>
IEnumerator ListUsableModels(string apiKey, string triedModel, Action<bool, string> onResult)
{
using (var request = UnityWebRequest.Get(ModelsListUrl))
{
request.SetRequestHeader(KeyHeader, apiKey);
request.timeout = 20;
yield return request.SendWebRequest();
if (request.responseCode != 200)
{
onResult?.Invoke(false, $"'{triedModel}' 모델을 찾을 수 없어요.");
yield break;
}
ModelListResponse list = null;
try
{
list = JsonUtility.FromJson<ModelListResponse>(request.downloadHandler.text);
}
catch (Exception)
{
// 목록을 못 읽어도 원래 결과는 알려줘야 한다.
}
var usable = new List<string>();
if (list != null && list.models != null)
{
foreach (var m in list.models)
{
if (m == null || string.IsNullOrEmpty(m.name)) continue;
if (m.supportedGenerationMethods == null) continue;
if (Array.IndexOf(m.supportedGenerationMethods, "streamGenerateContent") < 0) continue;
// "models/gemini-2.5-flash" -> "gemini-2.5-flash"
string id = m.name.StartsWith("models/", StringComparison.Ordinal)
? m.name.Substring(7)
: m.name;
usable.Add(id);
}
}
if (verboseLog)
{
Debug.Log($"[Gemini] 이 키로 쓸 수 있는 모델 {usable.Count}개:\n" +
string.Join("\n", usable));
}
if (usable.Count == 0)
{
onResult?.Invoke(false, $"'{triedModel}' 을 쓸 수 없어요. 쓸 수 있는 모델도 없습니다.");
yield break;
}
// 잡담용으로는 Flash 계열이 맞다. 있으면 그걸 먼저 권한다.
string suggestion = usable.Find(id => id.Contains("flash")) ?? usable[0];
onResult?.Invoke(false,
$"'{triedModel}' 은 못 써요. 예: {suggestion} (전체 목록은 Console 참고)");
}
}
// ------------------------------------------------------------------ 요청 본문
[Serializable]
class Request
{
public Content[] contents;
public SystemInstruction systemInstruction;
public GenerationConfig generationConfig;
}
[Serializable]
class Content
{
public string role;
public Part[] parts;
}
/// <summary>페르소나. Content 와 달리 role 이 없다.</summary>
[Serializable]
class SystemInstruction
{
public Part[] parts;
}
[Serializable]
class Part
{
public string text;
}
[Serializable]
class GenerationConfig
{
public int maxOutputTokens;
}
// ------------------------------------------------------------------ 응답
/// <summary>
/// AnthropicChatBackend 의 것과 같은 구조다. 이벤트 모양만 다르다.
/// (공통 부모로 빼는 것도 생각했지만, 스트림 형식이 제공자마다 또 달라질 수 있어
/// 섣불리 묶기보다 각자 두는 쪽이 나중에 덜 아프다)
/// </summary>
class SseDownloadHandler : DownloadHandlerScript
{
readonly Decoder decoder = Encoding.UTF8.GetDecoder();
readonly StringBuilder lineBuffer = new StringBuilder();
readonly StringBuilder accumulated = new StringBuilder();
readonly StringBuilder rawError = new StringBuilder();
char[] charBuffer = new char[2048];
UnityWebRequest request;
Action<string> onDelta;
public string Text => accumulated.ToString();
public string ErrorMessage { get; private set; }
/// <summary>STOP 이 아니면 안전 필터 등으로 잘린 것이다.</summary>
public string FinishReason { get; private set; }
/// <summary>4xx 일 때 서버가 보낸 본문 원문. 진단용.</summary>
public string RawError => rawError.ToString();
public SseDownloadHandler() : base(new byte[16 * 1024]) { }
public void Bind(UnityWebRequest owner, Action<string> deltaCallback)
{
request = owner;
onDelta = deltaCallback;
}
protected override bool ReceiveData(byte[] data, int dataLength)
{
// 여기서 false 를 돌려주면 UnityWebRequest 가 다운로드를 중단한다.
// 빈 청크는 스트림이 끝났다는 뜻이 아니므로 계속 받아야 한다.
if (data == null || dataLength == 0) return true;
int needed = decoder.GetCharCount(data, 0, dataLength, false);
if (charBuffer.Length < needed) charBuffer = new char[Mathf.NextPowerOfTwo(needed)];
int decoded = decoder.GetChars(data, 0, dataLength, charBuffer, 0, false);
// 오류는 SSE 가 아니라 JSON 한 덩어리로 온다.
if (request != null && request.responseCode >= 400)
{
rawError.Append(charBuffer, 0, decoded);
return true;
}
for (int i = 0; i < decoded; i++)
{
char c = charBuffer[i];
if (c == '\n')
{
HandleLine(lineBuffer.ToString());
lineBuffer.Length = 0;
}
else if (c != '\r')
{
lineBuffer.Append(c);
}
}
return true;
}
protected override void CompleteContent()
{
if (lineBuffer.Length > 0)
{
HandleLine(lineBuffer.ToString());
lineBuffer.Length = 0;
}
if (rawError.Length > 0 && ErrorMessage == null)
{
ParseError(rawError.ToString());
}
}
void HandleLine(string line)
{
if (!line.StartsWith("data:", StringComparison.Ordinal)) return;
string json = line.Substring(5).Trim();
if (json.Length == 0) return;
Chunk chunk;
try
{
chunk = JsonUtility.FromJson<Chunk>(json);
}
catch (Exception)
{
return;
}
if (chunk == null) return;
if (chunk.error != null && !string.IsNullOrEmpty(chunk.error.message))
{
ErrorMessage = chunk.error.message;
return;
}
if (chunk.candidates == null || chunk.candidates.Length == 0) return;
var candidate = chunk.candidates[0];
if (!string.IsNullOrEmpty(candidate.finishReason)) FinishReason = candidate.finishReason;
if (candidate.content == null || candidate.content.parts == null) return;
// 한 청크에 조각이 여러 개 들어올 수 있다.
for (int i = 0; i < candidate.content.parts.Length; i++)
{
string text = candidate.content.parts[i].text;
if (string.IsNullOrEmpty(text)) continue;
accumulated.Append(text);
onDelta?.Invoke(text);
}
}
void ParseError(string json)
{
try
{
var envelope = JsonUtility.FromJson<Chunk>(json);
if (envelope != null && envelope.error != null && !string.IsNullOrEmpty(envelope.error.message))
{
ErrorMessage = envelope.error.message;
}
}
catch (Exception)
{
// 본문이 JSON 이 아니면 상태 코드만으로 보고한다.
}
}
}
// 값을 넣는 쪽이 JsonUtility 라 컴파일러는 "한 번도 대입되지 않았다"고 본다.
#pragma warning disable 0649
[Serializable]
class Chunk
{
public Candidate[] candidates;
public ErrorBody error;
}
[Serializable]
class Candidate
{
public ResponseContent content;
public string finishReason;
}
[Serializable]
class ResponseContent
{
public ResponsePart[] parts;
public string role;
}
[Serializable]
class ResponsePart
{
public string text;
}
[Serializable]
class ErrorBody
{
public int code;
public string message;
public string status;
}
/// <summary>models.list 응답. 어떤 모델을 쓸 수 있는지 서버에 물을 때만 쓴다.</summary>
[Serializable]
class ModelListResponse
{
public ModelInfo[] models;
}
[Serializable]
class ModelInfo
{
public string name; // "models/gemini-2.5-flash"
public string[] supportedGenerationMethods; // "generateContent", "streamGenerateContent" 등
}
#pragma warning restore 0649
}