채팅 추가
This commit is contained in:
543
Assets/02_Scripts/Chat/AnthropicChatBackend.cs
Normal file
543
Assets/02_Scripts/Chat/AnthropicChatBackend.cs
Normal file
@@ -0,0 +1,543 @@
|
||||
using System;
|
||||
using System.Collections;
|
||||
using System.Collections.Generic;
|
||||
using System.Text;
|
||||
using UnityEngine;
|
||||
using UnityEngine.Networking;
|
||||
|
||||
/// <summary>
|
||||
/// Anthropic Messages API 를 UnityWebRequest 로 직접 친다. SSE 스트리밍.
|
||||
///
|
||||
/// 왜 공식 C# SDK 를 쓰지 않는가:
|
||||
/// SDK 는 netstandard2.0 타겟이 있지만 의존성(System.Text.Json 10.x,
|
||||
/// Microsoft.Extensions.AI.Abstractions)이 Unity Mono/IL2CPP 에서 AOT 문제를 낸다.
|
||||
/// 그게 원래 .NET 8 사이드카를 두기로 한 이유였다. 하지만 문제는 SDK 의존성이지
|
||||
/// HTTP 자체가 아니다. 순수 HTTP + 최소 JSON 은 Unity 에서 그냥 된다.
|
||||
/// ASR/TTS(sherpa-onnx)를 붙일 때가 되면 그때 사이드카로 옮기고
|
||||
/// IChatBackend 만 다시 구현하면 된다.
|
||||
///
|
||||
/// JSON 은 JsonUtility 로 처리한다. 요청은 모양이 고정이라 문제없고,
|
||||
/// 응답은 스트리밍 델타에서 text 하나만 뽑으면 되므로 모르는 필드는 무시된다.
|
||||
/// </summary>
|
||||
public class AnthropicChatBackend : MonoBehaviour, IChatBackend
|
||||
{
|
||||
const string Endpoint = "https://api.anthropic.com/v1/messages";
|
||||
const string ModelsEndpoint = "https://api.anthropic.com/v1/models";
|
||||
const string ApiVersion = "2023-06-01";
|
||||
|
||||
/// <summary>
|
||||
/// 설정 화면에 보여줄 모델 선택지. 표시 이름과 실제 ID 쌍.
|
||||
///
|
||||
/// 데스크톱 비서는 상시 떠 있으면서 잡담을 주고받으므로 비용과 지연이 그대로 체감된다.
|
||||
/// 그래서 성능순이 아니라 "일단 이거" 순으로 놓는다.
|
||||
/// </summary>
|
||||
public static readonly (string Label, string Id)[] SelectableModels =
|
||||
{
|
||||
("Opus 5", "claude-opus-5"),
|
||||
("Sonnet 5", "claude-sonnet-5"),
|
||||
("Haiku 4.5", "claude-haiku-4-5"),
|
||||
};
|
||||
|
||||
/// <summary>응답 깊이 선택지. 값이 클수록 더 생각하고, 더 느리고, 더 비싸다.</summary>
|
||||
public static readonly (string Label, string Id)[] SelectableEfforts =
|
||||
{
|
||||
("빠르게", "low"),
|
||||
("보통", "medium"),
|
||||
("깊게", "high"),
|
||||
};
|
||||
|
||||
[Tooltip("응답이 아예 오지 않을 때 포기하는 시간(초). 0 이면 무제한")]
|
||||
[SerializeField] int timeoutSeconds = 60;
|
||||
|
||||
[Tooltip("요청/응답 진행 상황을 콘솔에 남긴다. 응답이 안 올 때 어디서 막혔는지 보려면 켠다")]
|
||||
[SerializeField] bool verboseLog = true;
|
||||
|
||||
ChatConfig config;
|
||||
UnityWebRequest inflight;
|
||||
Coroutine routine;
|
||||
|
||||
public bool IsBusy => inflight != null;
|
||||
|
||||
/// <summary>설정을 주입한다. ChatController 가 시작할 때 한 번 부른다.</summary>
|
||||
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.ResolveAnthropicKey();
|
||||
if (string.IsNullOrEmpty(apiKey))
|
||||
{
|
||||
onError?.Invoke("Claude API 키가 없어. 설정에서 넣어줘.");
|
||||
return;
|
||||
}
|
||||
|
||||
routine = StartCoroutine(SendRoutine(apiKey, history, onDelta, onComplete, onError));
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 키와 모델 이름이 쓸 만한지 확인한다. 설정 화면의 "연결 테스트" 버튼이 부른다.
|
||||
///
|
||||
/// 메시지를 보내보는 대신 Models API 를 조회한다. 토큰을 한 개도 쓰지 않으면서
|
||||
/// 키가 유효한지(401)와 모델 이름이 맞는지(404)를 한 번에 가른다.
|
||||
/// 대화 요청과 별개의 요청 객체를 쓰므로 응답을 받는 중에도 눌러볼 수 있다.
|
||||
/// </summary>
|
||||
public void TestConnection(ChatConfig configToTest, Action<bool, string> onResult)
|
||||
{
|
||||
if (configToTest == null)
|
||||
{
|
||||
onResult?.Invoke(false, "설정이 비어 있어요.");
|
||||
return;
|
||||
}
|
||||
|
||||
string apiKey = configToTest.ResolveAnthropicKey();
|
||||
if (string.IsNullOrEmpty(apiKey))
|
||||
{
|
||||
onResult?.Invoke(false, "API 키를 먼저 입력해주세요.");
|
||||
return;
|
||||
}
|
||||
|
||||
StartCoroutine(TestRoutine(apiKey, configToTest.model, onResult));
|
||||
}
|
||||
|
||||
IEnumerator TestRoutine(string apiKey, string model, Action<bool, string> onResult)
|
||||
{
|
||||
string url = ModelsEndpoint + "/" + UnityWebRequest.EscapeURL(model);
|
||||
|
||||
using (var request = UnityWebRequest.Get(url))
|
||||
{
|
||||
request.SetRequestHeader("anthropic-version", ApiVersion);
|
||||
request.SetRequestHeader("x-api-key", apiKey);
|
||||
request.timeout = 20;
|
||||
|
||||
yield return request.SendWebRequest();
|
||||
|
||||
if (request.result == UnityWebRequest.Result.ConnectionError)
|
||||
{
|
||||
onResult?.Invoke(false, "네트워크에 연결할 수 없어요.");
|
||||
yield break;
|
||||
}
|
||||
|
||||
switch (request.responseCode)
|
||||
{
|
||||
case 200:
|
||||
onResult?.Invoke(true, $"연결 성공 — {model}");
|
||||
break;
|
||||
case 401:
|
||||
case 403:
|
||||
onResult?.Invoke(false, "API 키가 올바르지 않아요.");
|
||||
break;
|
||||
case 404:
|
||||
onResult?.Invoke(false, $"'{model}' 모델을 찾을 수 없어요.");
|
||||
break;
|
||||
case 429:
|
||||
// 키 자체는 유효하다. 한도만 찬 상태.
|
||||
onResult?.Invoke(true, "키는 정상인데 요청 한도에 걸려 있어요.");
|
||||
break;
|
||||
default:
|
||||
onResult?.Invoke(false, $"확인 실패 (오류 {request.responseCode})");
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
public void Cancel()
|
||||
{
|
||||
if (routine != null)
|
||||
{
|
||||
StopCoroutine(routine);
|
||||
routine = null;
|
||||
}
|
||||
|
||||
// Abort 는 다음 프레임에 완료되므로 참조를 먼저 끊어 IsBusy 를 즉시 내린다.
|
||||
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);
|
||||
|
||||
var request = new UnityWebRequest(Endpoint, 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("anthropic-version", ApiVersion);
|
||||
request.SetRequestHeader("x-api-key", apiKey);
|
||||
if (timeoutSeconds > 0) request.timeout = timeoutSeconds;
|
||||
|
||||
// 핸들러가 본문을 SSE 로 팔지 오류로 모아둘지는 상태 코드를 봐야 정해진다.
|
||||
handler.Bind(request, onDelta);
|
||||
|
||||
inflight = request;
|
||||
|
||||
float startedAt = Time.realtimeSinceStartup;
|
||||
if (verboseLog)
|
||||
{
|
||||
Debug.Log($"[Anthropic] 요청 전송: model={config.model} effort={config.effort} " +
|
||||
$"메시지 {history.Count}개 본문 {body.Length}자");
|
||||
}
|
||||
|
||||
yield return request.SendWebRequest();
|
||||
|
||||
routine = null;
|
||||
|
||||
if (verboseLog)
|
||||
{
|
||||
float elapsed = Time.realtimeSinceStartup - startedAt;
|
||||
Debug.Log($"[Anthropic] 응답 종료: HTTP {request.responseCode} result={request.result} " +
|
||||
$"{elapsed:F1}초 / 받은 글자 {handler.Text.Length} / " +
|
||||
$"첫 글자까지 {(handler.FirstTextAt > 0f ? (handler.FirstTextAt - startedAt).ToString("F1") + "초" : "없음")} / " +
|
||||
$"사고 블록 {(handler.SawThinking ? "있음" : "없음")}");
|
||||
}
|
||||
|
||||
// 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))
|
||||
{
|
||||
onError?.Invoke("응답이 비어 있어. 잠시 뒤 다시 시도해줘.");
|
||||
yield break;
|
||||
}
|
||||
|
||||
onComplete?.Invoke(full);
|
||||
}
|
||||
|
||||
/// <summary>실패를 사용자에게 그대로 보여줄 수 있는 한 문장으로 만든다.</summary>
|
||||
static string DescribeFailure(UnityWebRequest request, SseDownloadHandler handler)
|
||||
{
|
||||
// 본문에 구조화된 오류가 있으면 그게 가장 정확하다.
|
||||
string apiMessage = handler.ErrorMessage;
|
||||
if (!string.IsNullOrEmpty(apiMessage))
|
||||
{
|
||||
if (request.responseCode == 401) return "API 키가 거부됐어: " + apiMessage;
|
||||
if (request.responseCode == 429) return "요청이 너무 잦아. 잠시 뒤 다시 해줘: " + apiMessage;
|
||||
return "오류 " + request.responseCode + ": " + apiMessage;
|
||||
}
|
||||
|
||||
if (request.result == UnityWebRequest.Result.ConnectionError)
|
||||
return "연결에 실패했어. 네트워크를 확인해줘. (" + request.error + ")";
|
||||
|
||||
return "오류 " + request.responseCode + ": " + request.error;
|
||||
}
|
||||
|
||||
string BuildRequestJson(IReadOnlyList<ChatMessage> history)
|
||||
{
|
||||
var messages = new ReqMessage[history.Count];
|
||||
for (int i = 0; i < history.Count; i++)
|
||||
{
|
||||
messages[i] = new ReqMessage
|
||||
{
|
||||
role = history[i].RoleName,
|
||||
content = history[i].Text,
|
||||
};
|
||||
}
|
||||
|
||||
var request = new Request
|
||||
{
|
||||
model = config.model,
|
||||
max_tokens = Mathf.Max(64, config.maxTokens),
|
||||
stream = true,
|
||||
// 페르소나는 매 요청 동일한 접두사라 캐시 대상이다. 다만 캐시는
|
||||
// 1024 토큰 이상부터 걸리므로 짧은 페르소나에서는 그냥 무시된다.
|
||||
system = new[]
|
||||
{
|
||||
new ReqSystemBlock
|
||||
{
|
||||
type = "text",
|
||||
text = config.persona,
|
||||
cache_control = new ReqCacheControl { type = "ephemeral" },
|
||||
},
|
||||
},
|
||||
messages = messages,
|
||||
// 사고는 켜두되 깊이를 낮춘다. 완전히 끄면 Opus 5 에서 사고 흔적이
|
||||
// 본문에 새는 사례가 있고, effort 를 낮추는 쪽이 비용도 지연도 낫다.
|
||||
thinking = new ReqThinking { type = "adaptive" },
|
||||
output_config = new ReqOutputConfig { effort = config.effort },
|
||||
};
|
||||
|
||||
return JsonUtility.ToJson(request);
|
||||
}
|
||||
|
||||
// --- 요청 본문 ---
|
||||
// JsonUtility 는 선언한 필드를 빠짐없이 직렬화한다. 모양이 고정이라 그게 문제되지 않는다.
|
||||
|
||||
[Serializable]
|
||||
class Request
|
||||
{
|
||||
public string model;
|
||||
public int max_tokens;
|
||||
public bool stream;
|
||||
public ReqSystemBlock[] system;
|
||||
public ReqMessage[] messages;
|
||||
public ReqThinking thinking;
|
||||
public ReqOutputConfig output_config;
|
||||
}
|
||||
|
||||
[Serializable]
|
||||
class ReqSystemBlock
|
||||
{
|
||||
public string type;
|
||||
public string text;
|
||||
public ReqCacheControl cache_control;
|
||||
}
|
||||
|
||||
[Serializable]
|
||||
class ReqCacheControl
|
||||
{
|
||||
public string type;
|
||||
}
|
||||
|
||||
[Serializable]
|
||||
class ReqMessage
|
||||
{
|
||||
public string role;
|
||||
public string content;
|
||||
}
|
||||
|
||||
[Serializable]
|
||||
class ReqThinking
|
||||
{
|
||||
public string type;
|
||||
}
|
||||
|
||||
[Serializable]
|
||||
class ReqOutputConfig
|
||||
{
|
||||
public string effort;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// SSE 스트림을 줄 단위로 끊어 text_delta 만 뽑아낸다.
|
||||
///
|
||||
/// 기본 DownloadHandlerBuffer 는 응답이 다 와야 내용을 주므로 스트리밍이 되지 않는다.
|
||||
/// DownloadHandlerScript 는 청크가 도착할 때마다 ReceiveData 를 부른다
|
||||
/// (메인 스레드에서 불리므로 Unity 객체를 만져도 안전하다).
|
||||
///
|
||||
/// 청크 경계는 UTF-8 문자 중간에서도 잘리고 SSE 줄 중간에서도 잘린다.
|
||||
/// 그래서 상태를 가진 Decoder 와 줄 버퍼를 둘 다 유지한다.
|
||||
/// </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 SseDownloadHandler() : base(new byte[16 * 1024]) { }
|
||||
|
||||
/// <summary>지금까지 이어 붙인 응답 전체.</summary>
|
||||
public string Text => accumulated.ToString();
|
||||
|
||||
/// <summary>API 가 구조화된 오류를 보냈다면 그 message. 없으면 null.</summary>
|
||||
public string ErrorMessage { get; private set; }
|
||||
|
||||
/// <summary>
|
||||
/// 사고 델타를 한 번이라도 봤는지. display 가 기본값(omitted)이면 사고 델타의
|
||||
/// 글자가 비어 있어서, 겉보기엔 아무것도 안 오는 것과 구분되지 않는다.
|
||||
/// 응답이 늦을 때 "멈춘 것"인지 "생각 중"인지 가르는 단서다.
|
||||
/// </summary>
|
||||
public bool SawThinking { get; private set; }
|
||||
|
||||
/// <summary>첫 글자가 도착한 시각(realtimeSinceStartup). 아직이면 0.</summary>
|
||||
public float FirstTextAt { get; private set; }
|
||||
|
||||
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)
|
||||
{
|
||||
// "event: ..." 줄은 볼 필요가 없다. data 의 JSON 안에 같은 type 이 들어 있다.
|
||||
if (!line.StartsWith("data:", StringComparison.Ordinal)) return;
|
||||
|
||||
string json = line.Substring(5).Trim();
|
||||
if (json.Length == 0) return;
|
||||
|
||||
SseEnvelope evt;
|
||||
try
|
||||
{
|
||||
evt = JsonUtility.FromJson<SseEnvelope>(json);
|
||||
}
|
||||
catch (Exception)
|
||||
{
|
||||
return; // 모르는 모양의 이벤트는 조용히 넘긴다
|
||||
}
|
||||
|
||||
if (evt == null) return;
|
||||
|
||||
if (evt.type == "error")
|
||||
{
|
||||
ErrorMessage = evt.error != null && !string.IsNullOrEmpty(evt.error.message)
|
||||
? evt.error.message
|
||||
: "알 수 없는 오류";
|
||||
return;
|
||||
}
|
||||
|
||||
if (evt.type != "content_block_delta" || evt.delta == null) return;
|
||||
|
||||
// 사고 델타는 display 가 omitted 면 글자가 비어서 온다. 버리되, 왔다는
|
||||
// 사실은 남긴다 — 응답이 늦을 때 멈춘 건지 생각 중인지 가르는 단서다.
|
||||
if (evt.delta.type == "thinking_delta")
|
||||
{
|
||||
SawThinking = true;
|
||||
return;
|
||||
}
|
||||
|
||||
if (evt.delta.type == "text_delta" && !string.IsNullOrEmpty(evt.delta.text))
|
||||
{
|
||||
if (FirstTextAt <= 0f) FirstTextAt = Time.realtimeSinceStartup;
|
||||
accumulated.Append(evt.delta.text);
|
||||
onDelta?.Invoke(evt.delta.text);
|
||||
}
|
||||
}
|
||||
|
||||
void ParseError(string json)
|
||||
{
|
||||
try
|
||||
{
|
||||
var envelope = JsonUtility.FromJson<SseEnvelope>(json);
|
||||
if (envelope != null && envelope.error != null && !string.IsNullOrEmpty(envelope.error.message))
|
||||
{
|
||||
ErrorMessage = envelope.error.message;
|
||||
}
|
||||
}
|
||||
catch (Exception)
|
||||
{
|
||||
// 본문이 JSON 이 아니면 상태 코드만으로 보고한다.
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// --- 응답 파싱 ---
|
||||
// 필요한 필드만 선언한다. JsonUtility 는 모르는 필드를 조용히 버린다.
|
||||
// 값을 넣는 쪽이 JsonUtility 라 컴파일러는 "한 번도 대입되지 않았다"고 본다.
|
||||
#pragma warning disable 0649
|
||||
|
||||
[Serializable]
|
||||
class SseEnvelope
|
||||
{
|
||||
public string type;
|
||||
public SseDelta delta;
|
||||
public SseError error;
|
||||
}
|
||||
|
||||
[Serializable]
|
||||
class SseDelta
|
||||
{
|
||||
public string type;
|
||||
public string text;
|
||||
}
|
||||
|
||||
[Serializable]
|
||||
class SseError
|
||||
{
|
||||
public string type;
|
||||
public string message;
|
||||
}
|
||||
|
||||
#pragma warning restore 0649
|
||||
}
|
||||
Reference in New Issue
Block a user