611 lines
20 KiB
C#
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
|
|
}
|