diff --git a/.gitignore b/.gitignore
index aa16a98..68a534f 100644
--- a/.gitignore
+++ b/.gitignore
@@ -92,3 +92,5 @@ ProjectSettings/Packages/com.unity.learn.iet-framework/Settings.json
ProjectSettings/Packages/com.unity.testtools.codecoverage/Settings.json
*.log
+
+/[Mm]odels/
diff --git a/Assets/01_Scenes/MainScene.unity b/Assets/01_Scenes/MainScene.unity
index 9b5bf0f..5f92178 100644
--- a/Assets/01_Scenes/MainScene.unity
+++ b/Assets/01_Scenes/MainScene.unity
@@ -1,3 +1,3 @@
version https://git-lfs.github.com/spec/v1
-oid sha256:85bc27b8e51f498881808e664903fbeaefb6c280a83c50129d70e327e9960edd
-size 17952
+oid sha256:ad0ec5eb1b9d5b5a0f1ac95c698ab0f0f959a8b0ab80200db26a6ec5eaf3aae3
+size 21109
diff --git a/Assets/02_Scripts/Character/CharacterDragger.cs b/Assets/02_Scripts/Character/CharacterDragger.cs
index 8376810..4454014 100644
--- a/Assets/02_Scripts/Character/CharacterDragger.cs
+++ b/Assets/02_Scripts/Character/CharacterDragger.cs
@@ -45,6 +45,7 @@ public class CharacterDragger : MonoBehaviour
Transform target;
bool pressing; // 버튼이 눌린 상태(아직 드래그는 아닐 수 있음)
bool dragging; // 임계값을 넘겨 실제 드래그 중
+ bool prevButtonDown; // 눌린 순간만 잡아내기 위한 직전 프레임 상태
Vector2 pressStartPos;
Vector2 grabScreenOffset; // 캐릭터 원점 - 커서 (화면 좌표)
Vector2 boundsOffMin, boundsOffMax; // 원점 기준 캐릭터의 화면상 범위
@@ -56,6 +57,14 @@ public class CharacterDragger : MonoBehaviour
/// 드래그가 끝난 순간 호출. 창 올라타기가 착지 지점을 다시 계산할 때 쓴다.
public event System.Action DragEnded;
+ ///
+ /// 캐릭터를 끌지 않고 그냥 눌렀다 뗀 순간 호출. 채팅창 토글이 이걸 쓴다.
+ ///
+ /// 클릭과 드래그의 구분은 이미 dragThresholdPixels 로 하고 있으므로 판정을
+ /// 새로 만들지 않고 여기에 얹는다. 조금이라도 끌었으면 클릭이 아니다.
+ ///
+ public event System.Action Clicked;
+
void Awake()
{
hitTest = GetComponent();
@@ -74,7 +83,13 @@ void Update()
bool buttonDown = mouse != null && mouse.leftButton.isPressed;
#endif
- if (!pressing && buttonDown && hitTest.IsOverCharacter)
+ // 눌린 "순간"만 잡는다. 이미 누른 채로 커서가 캐릭터 위로 지나가는 경우
+ // (다른 창을 끌고 오다가 캐릭터를 스치는 등)에 잡히면 안 된다.
+ // 클릭으로 채팅창이 토글되면서부터는 이 오작동이 눈에 띄게 된다.
+ bool justPressed = buttonDown && !prevButtonDown;
+ prevButtonDown = buttonDown;
+
+ if (!pressing && justPressed && hitTest.IsOverCharacter)
{
BeginPress();
}
@@ -152,6 +167,7 @@ void EndPress()
target = null;
if (wasDragging && dragged != null) DragEnded?.Invoke(dragged);
+ else if (!wasDragging && dragged != null) Clicked?.Invoke();
}
bool TryGetCursor(out Vector2 screenPos)
diff --git a/Assets/02_Scripts/Chat.meta b/Assets/02_Scripts/Chat.meta
new file mode 100644
index 0000000..aa542c5
--- /dev/null
+++ b/Assets/02_Scripts/Chat.meta
@@ -0,0 +1,8 @@
+fileFormatVersion: 2
+guid: af53affe611c89e4cb0292ed9052ee44
+folderAsset: yes
+DefaultImporter:
+ externalObjects: {}
+ userData:
+ assetBundleName:
+ assetBundleVariant:
diff --git a/Assets/02_Scripts/Chat/AnthropicChatBackend.cs b/Assets/02_Scripts/Chat/AnthropicChatBackend.cs
new file mode 100644
index 0000000..7e94172
--- /dev/null
+++ b/Assets/02_Scripts/Chat/AnthropicChatBackend.cs
@@ -0,0 +1,543 @@
+using System;
+using System.Collections;
+using System.Collections.Generic;
+using System.Text;
+using UnityEngine;
+using UnityEngine.Networking;
+
+///
+/// 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 하나만 뽑으면 되므로 모르는 필드는 무시된다.
+///
+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";
+
+ ///
+ /// 설정 화면에 보여줄 모델 선택지. 표시 이름과 실제 ID 쌍.
+ ///
+ /// 데스크톱 비서는 상시 떠 있으면서 잡담을 주고받으므로 비용과 지연이 그대로 체감된다.
+ /// 그래서 성능순이 아니라 "일단 이거" 순으로 놓는다.
+ ///
+ 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"),
+ };
+
+ /// 응답 깊이 선택지. 값이 클수록 더 생각하고, 더 느리고, 더 비싸다.
+ 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;
+
+ /// 설정을 주입한다. ChatController 가 시작할 때 한 번 부른다.
+ public void Configure(ChatConfig chatConfig)
+ {
+ config = chatConfig;
+ }
+
+ public void Send(IReadOnlyList history,
+ Action onDelta,
+ Action onComplete,
+ Action 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));
+ }
+
+ ///
+ /// 키와 모델 이름이 쓸 만한지 확인한다. 설정 화면의 "연결 테스트" 버튼이 부른다.
+ ///
+ /// 메시지를 보내보는 대신 Models API 를 조회한다. 토큰을 한 개도 쓰지 않으면서
+ /// 키가 유효한지(401)와 모델 이름이 맞는지(404)를 한 번에 가른다.
+ /// 대화 요청과 별개의 요청 객체를 쓰므로 응답을 받는 중에도 눌러볼 수 있다.
+ ///
+ public void TestConnection(ChatConfig configToTest, Action 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 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 history,
+ Action onDelta,
+ Action onComplete,
+ Action 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);
+ }
+
+ /// 실패를 사용자에게 그대로 보여줄 수 있는 한 문장으로 만든다.
+ 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 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;
+ }
+
+ ///
+ /// SSE 스트림을 줄 단위로 끊어 text_delta 만 뽑아낸다.
+ ///
+ /// 기본 DownloadHandlerBuffer 는 응답이 다 와야 내용을 주므로 스트리밍이 되지 않는다.
+ /// DownloadHandlerScript 는 청크가 도착할 때마다 ReceiveData 를 부른다
+ /// (메인 스레드에서 불리므로 Unity 객체를 만져도 안전하다).
+ ///
+ /// 청크 경계는 UTF-8 문자 중간에서도 잘리고 SSE 줄 중간에서도 잘린다.
+ /// 그래서 상태를 가진 Decoder 와 줄 버퍼를 둘 다 유지한다.
+ ///
+ 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 onDelta;
+
+ // 버퍼를 미리 잡아두면 청크마다 새 배열이 생기지 않는다.
+ // 스트리밍은 초당 수십 번 콜백이 오므로 여기서 나오는 쓰레기가 무시할 양이 아니다.
+ public SseDownloadHandler() : base(new byte[16 * 1024]) { }
+
+ /// 지금까지 이어 붙인 응답 전체.
+ public string Text => accumulated.ToString();
+
+ /// API 가 구조화된 오류를 보냈다면 그 message. 없으면 null.
+ public string ErrorMessage { get; private set; }
+
+ ///
+ /// 사고 델타를 한 번이라도 봤는지. display 가 기본값(omitted)이면 사고 델타의
+ /// 글자가 비어 있어서, 겉보기엔 아무것도 안 오는 것과 구분되지 않는다.
+ /// 응답이 늦을 때 "멈춘 것"인지 "생각 중"인지 가르는 단서다.
+ ///
+ public bool SawThinking { get; private set; }
+
+ /// 첫 글자가 도착한 시각(realtimeSinceStartup). 아직이면 0.
+ public float FirstTextAt { get; private set; }
+
+ public void Bind(UnityWebRequest owner, Action 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(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(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
+}
diff --git a/Assets/02_Scripts/Chat/AnthropicChatBackend.cs.meta b/Assets/02_Scripts/Chat/AnthropicChatBackend.cs.meta
new file mode 100644
index 0000000..1833bae
--- /dev/null
+++ b/Assets/02_Scripts/Chat/AnthropicChatBackend.cs.meta
@@ -0,0 +1,2 @@
+fileFormatVersion: 2
+guid: 9d26819ef7f9d3142b8c3377f1210066
\ No newline at end of file
diff --git a/Assets/02_Scripts/Chat/ChatConfig.cs b/Assets/02_Scripts/Chat/ChatConfig.cs
new file mode 100644
index 0000000..a8a8dd8
--- /dev/null
+++ b/Assets/02_Scripts/Chat/ChatConfig.cs
@@ -0,0 +1,196 @@
+using System;
+using System.IO;
+using UnityEngine;
+
+///
+/// 채팅 설정. 실행 파일 옆 Config/chat.json 에서 읽는다.
+///
+/// API 키를 빌드에 넣지 않는 이유는 모델을 빌드에 넣지 않는 이유와 같다 —
+/// 앱은 빈 껍데기로 배포하고 사용자가 자기 것을 넣는다. 키가 코드나 씬에
+/// 직렬화되면 배포본에서 그대로 추출된다.
+///
+/// 제공자(Claude / Gemini)별로 키와 모델을 따로 둔다. 하나만 두고 돌려쓰면
+/// 제공자를 바꿀 때마다 키를 다시 붙여넣어야 한다.
+///
+/// 키 탐색 순서
+/// 1. Config/chat.json 의 값 — 앱 안 설정 화면에서 입력한 것
+/// 2. 환경 변수 (ANTHROPIC_API_KEY / GEMINI_API_KEY) — 개발 중 편의
+///
+/// 앱에서 입력한 값이 환경 변수를 이긴다. 반대로 하면 설정 화면에서 키를 바꿔도
+/// 아무 일이 안 일어나는 것처럼 보여서 사용자가 원인을 찾을 수 없다.
+///
+[Serializable]
+public class ChatConfig
+{
+ public const string ProviderAnthropic = "anthropic";
+ public const string ProviderGemini = "gemini";
+
+ /// 어느 제공자를 쓸지. ProviderAnthropic 또는 ProviderGemini.
+ public string provider = ProviderAnthropic;
+
+ // --- Anthropic ---
+
+ /// Anthropic API 키. 비우면 환경 변수 ANTHROPIC_API_KEY 를 쓴다.
+ public string apiKey = "";
+
+ public string model = "claude-opus-5";
+
+ ///
+ /// 사고 깊이. Anthropic 전용이다. 잡담에 깊이 생각할 이유가 없고 지연이 그대로 체감된다.
+ /// low / medium / high / xhigh / max.
+ ///
+ public string effort = "low";
+
+ // --- Gemini ---
+
+ /// Gemini API 키. 비우면 환경 변수 GEMINI_API_KEY 를 쓴다.
+ public string geminiApiKey = "";
+
+ public string geminiModel = "gemini-3.7-flash";
+
+ // --- 공통 ---
+
+ ///
+ /// 응답 길이 상한. 데스크톱 비서의 대사는 짧아야 하므로 크게 잡을 이유가 없다.
+ /// 스트리밍이라 HTTP 타임아웃 걱정은 없지만, 상한이 크면 모델이 길게 쓰는 경향이 있다.
+ ///
+ public int maxTokens = 2048;
+
+ /// 대화 기록을 몇 턴까지 들고 갈지. 넘으면 오래된 것부터 버린다.
+ public int maxHistoryMessages = 40;
+
+ /// 캐릭터 페르소나. 시스템 프롬프트로 들어간다. 제공자와 무관하게 공용이다.
+ [TextArea(4, 20)]
+ public string persona =
+ "너는 사용자의 데스크톱 위에 사는 작은 캐릭터 비서다.\n" +
+ "말투는 친근한 반말. 사용자가 존댓말을 쓰면 존댓말로 맞춰준다.\n" +
+ "화면 한켠의 작은 말풍선에 표시되므로 답변은 두세 문장 안에서 끝낸다.\n" +
+ "목록이나 표는 쓰지 않고, 길어질 것 같으면 핵심만 말한 뒤 더 들을지 되묻는다.\n" +
+ "모르는 것은 아는 척하지 않는다.";
+
+ const string FileName = "chat.json";
+
+ /// 실행 파일 옆 Config 폴더. VrmCharacterLoader.ModelsDirectory 와 같은 기준.
+ public static string ConfigDirectory
+ {
+ get
+ {
+ string baseDir = Directory.GetParent(Application.dataPath)?.FullName ?? Application.dataPath;
+ return Path.Combine(baseDir, "Config");
+ }
+ }
+
+ public static string ConfigPath => Path.Combine(ConfigDirectory, FileName);
+
+ /// 설정을 읽는다. 파일이 없으면 기본값으로 템플릿을 만들고 그 기본값을 돌려준다.
+ public static ChatConfig Load()
+ {
+ var config = new ChatConfig();
+
+ try
+ {
+ string path = ConfigPath;
+ if (File.Exists(path))
+ {
+ string json = File.ReadAllText(path);
+ // 덮어쓰기가 아니라 채워넣기다. 사용자가 일부 항목만 적어도
+ // 나머지는 위에 선언한 기본값이 살아남는다. 항목을 새로 추가해도
+ // 예전 설정 파일이 그대로 열린다.
+ JsonUtility.FromJsonOverwrite(json, config);
+ }
+ else
+ {
+ Directory.CreateDirectory(ConfigDirectory);
+ File.WriteAllText(path, JsonUtility.ToJson(config, true));
+ Debug.Log($"[ChatConfig] 설정 템플릿을 만들었습니다: {path}");
+ }
+ }
+ catch (Exception e)
+ {
+ Debug.LogError($"[ChatConfig] 설정을 읽지 못했습니다. 기본값을 씁니다: {e.Message}");
+ }
+
+ config.Normalize();
+ return config;
+ }
+
+ /// 설정을 파일에 쓴다. 설정 화면의 저장 버튼이 부른다.
+ public bool Save()
+ {
+ Normalize();
+
+ try
+ {
+ Directory.CreateDirectory(ConfigDirectory);
+ File.WriteAllText(ConfigPath, JsonUtility.ToJson(this, true));
+ return true;
+ }
+ catch (Exception e)
+ {
+ Debug.LogError($"[ChatConfig] 설정을 저장하지 못했습니다: {e.Message}");
+ return false;
+ }
+ }
+
+ /// 손으로 고친 설정 파일에 모르는 값이 적혀 있을 수 있다. 아는 값으로 되돌린다.
+ void Normalize()
+ {
+ if (provider != ProviderGemini) provider = ProviderAnthropic;
+ }
+
+ // ------------------------------------------------------------------ 제공자
+
+ public bool IsGemini => provider == ProviderGemini;
+
+ /// 지금 제공자에서 쓸 모델 ID.
+ public string ActiveModel => IsGemini ? geminiModel : model;
+
+ /// 사람에게 보여줄 제공자 이름.
+ public string ProviderLabel => IsGemini ? "Gemini" : "Claude";
+
+ // ------------------------------------------------------------------ 키
+
+ /// 환경 변수를 읽는다. 없거나 접근이 막혀 있으면 null.
+ static string ReadEnvironment(string name)
+ {
+ try
+ {
+ string value = Environment.GetEnvironmentVariable(name);
+ return string.IsNullOrWhiteSpace(value) ? null : value.Trim();
+ }
+ catch (Exception)
+ {
+ // 환경 변수 접근이 막힌 환경도 있다.
+ return null;
+ }
+ }
+
+ public static string EnvironmentKeyFor(string providerId) =>
+ providerId == ProviderGemini
+ ? ReadEnvironment("GEMINI_API_KEY")
+ : ReadEnvironment("ANTHROPIC_API_KEY");
+
+ /// Anthropic 백엔드가 쓸 키. 없으면 null.
+ public string ResolveAnthropicKey() =>
+ !string.IsNullOrWhiteSpace(apiKey) ? apiKey.Trim() : EnvironmentKeyFor(ProviderAnthropic);
+
+ /// Gemini 백엔드가 쓸 키. 없으면 null.
+ public string ResolveGeminiKey() =>
+ !string.IsNullOrWhiteSpace(geminiApiKey) ? geminiApiKey.Trim() : EnvironmentKeyFor(ProviderGemini);
+
+ /// 지금 제공자의 키. 없으면 null.
+ public string ResolveApiKey() => IsGemini ? ResolveGeminiKey() : ResolveAnthropicKey();
+
+ /// 지금 제공자로 대화를 시작할 수 있는지.
+ public bool HasApiKey => !string.IsNullOrEmpty(ResolveApiKey());
+
+ /// 설정 파일에는 키가 없고 환경 변수만 있는 상태인지. 설정 화면에서 안내한다.
+ public bool UsingEnvironmentKey
+ {
+ get
+ {
+ string typed = IsGemini ? geminiApiKey : apiKey;
+ return string.IsNullOrWhiteSpace(typed) && EnvironmentKeyFor(provider) != null;
+ }
+ }
+}
diff --git a/Assets/02_Scripts/Chat/ChatConfig.cs.meta b/Assets/02_Scripts/Chat/ChatConfig.cs.meta
new file mode 100644
index 0000000..0b03665
--- /dev/null
+++ b/Assets/02_Scripts/Chat/ChatConfig.cs.meta
@@ -0,0 +1,2 @@
+fileFormatVersion: 2
+guid: 13145ad34b529d6499646494ce3c9d3f
\ No newline at end of file
diff --git a/Assets/02_Scripts/Chat/ChatController.cs b/Assets/02_Scripts/Chat/ChatController.cs
new file mode 100644
index 0000000..5cf67af
--- /dev/null
+++ b/Assets/02_Scripts/Chat/ChatController.cs
@@ -0,0 +1,377 @@
+using System;
+using UnityEngine;
+using UnityEngine.InputSystem;
+
+///
+/// 채팅 기능의 배선판. 캐릭터 클릭으로 창을 토글하고, 창이 열려 있는 동안
+/// 창 레이어(클릭 통과 / 포커스 / Esc)를 채팅에 맞게 바꿔준다.
+///
+/// 여기서만 다루는 두 가지 까다로운 문제:
+///
+/// 1. 클릭 통과 — 기본 상태에서 창은 캐릭터 위에서만 클릭을 받는다. 그대로면
+/// 채팅창을 눌러도 클릭이 뒤 창으로 새어나간다. 채팅창 사각형을 히트테스트에
+/// 등록해 그 위에서도 클릭을 받게 한다.
+///
+/// 2. 포커스 — 창에 WS_EX_NOACTIVATE 가 걸려 있어 포커스를 받지 않는다.
+/// 마우스는 포커스 없이도 오지만 키보드는 오지 않는다. 채팅창이 열려 있는
+/// 동안만 NOACTIVATE 를 내리고 창을 활성화한다. 닫을 때 원래 쓰던 창으로
+/// 포커스를 돌려준다 — 비서가 작업을 방해하면 안 된다.
+///
+public class ChatController : MonoBehaviour
+{
+ [Header("참조 (비우면 씬에서 탐색)")]
+ [SerializeField] ChatWindowUI ui;
+ [SerializeField] AnthropicChatBackend anthropicBackend;
+ [SerializeField] GeminiChatBackend geminiBackend;
+ [SerializeField] ClickThroughHitTest hitTest;
+ [SerializeField] CharacterDragger dragger;
+ [SerializeField] TransparentWindow window;
+ [SerializeField] VrmCharacterLoader loader;
+ [SerializeField] Camera viewCamera;
+
+ [Header("배치")]
+ [Tooltip("캐릭터를 따라다닌다. 끄면 처음 연 자리에 머문다")]
+ [SerializeField] bool followCharacter = true;
+
+ [Tooltip("캐릭터와 채팅창 사이 간격(픽셀)")]
+ [SerializeField] float gapFromCharacter = 16f;
+
+ ChatSession session;
+ ChatConfig config;
+
+ // 키가 없어 설정 화면을 자동으로 띄운 뒤에는 잔소리를 반복하지 않는다.
+ bool announcedMissingKey;
+
+ // 캐릭터의 화면상 크기. 깊이가 고정이라 위치가 바뀌어도 변하지 않으므로 캐시한다.
+ Vector2 characterOffMin, characterOffMax;
+ bool characterMeasured;
+
+ Func hitRegion;
+
+#if !UNITY_EDITOR && UNITY_STANDALONE_WIN
+ IntPtr previousForeground = IntPtr.Zero;
+#endif
+
+ public bool IsOpen => ui != null && ui.IsOpen;
+
+ void Awake()
+ {
+ if (viewCamera == null) viewCamera = Camera.main;
+ if (window == null) window = FindFirstObjectByType();
+ if (hitTest == null) hitTest = FindFirstObjectByType();
+ if (dragger == null) dragger = FindFirstObjectByType();
+ if (loader == null) loader = FindFirstObjectByType();
+
+ if (ui == null) ui = GetComponentInChildren(true);
+ if (ui == null) ui = gameObject.AddComponent();
+
+ if (anthropicBackend == null) anthropicBackend = GetComponent();
+ if (anthropicBackend == null) anthropicBackend = gameObject.AddComponent();
+
+ if (geminiBackend == null) geminiBackend = GetComponent();
+ if (geminiBackend == null) geminiBackend = gameObject.AddComponent();
+
+ config = ChatConfig.Load();
+ ConfigureBackends();
+ session = new ChatSession(ActiveBackend, config.maxHistoryMessages);
+ }
+
+ /// 지금 설정이 가리키는 백엔드.
+ IChatBackend ActiveBackend => config.IsGemini ? (IChatBackend)geminiBackend : anthropicBackend;
+
+ ///
+ /// 두 백엔드 모두에 설정을 넣는다.
+ ///
+ /// 쓰지 않는 쪽까지 넣어두는 이유: 설정 화면의 연결 테스트는 저장 전 값으로
+ /// 지금 안 쓰는 제공자를 시험할 수도 있고, 저장 직후 제공자가 바뀌면
+ /// 그 백엔드가 곧바로 요청을 받게 된다.
+ ///
+ void ConfigureBackends()
+ {
+ anthropicBackend.Configure(config);
+ geminiBackend.Configure(config);
+ }
+
+ void OnEnable()
+ {
+ ui.Submitted += OnSubmitted;
+ ui.CloseRequested += Close;
+ ui.SettingsRequested += OpenSettings;
+ ui.CancelRequested += OnCancelRequested;
+
+ ui.Settings.SaveRequested += OnSettingsSaved;
+ ui.Settings.TestRequested += OnSettingsTestRequested;
+
+ session.UserMessageAdded += ui.AppendUserMessage;
+ session.ResponseStarted += ui.BeginResponse;
+ session.ResponseDelta += ui.AppendResponseDelta;
+ session.ResponseCompleted += OnResponseCompleted;
+ session.ResponseFailed += ui.ShowError;
+
+ if (dragger != null) dragger.Clicked += Toggle;
+ if (loader != null) loader.Loaded += OnCharacterLoaded;
+
+ if (hitTest != null)
+ {
+ hitRegion = ui.ContainsScreenPoint;
+ hitTest.RegisterInteractiveRegion(hitRegion);
+ }
+ }
+
+ void OnDisable()
+ {
+ ui.Submitted -= OnSubmitted;
+ ui.CloseRequested -= Close;
+ ui.SettingsRequested -= OpenSettings;
+ ui.CancelRequested -= OnCancelRequested;
+
+ if (ui.Settings != null)
+ {
+ ui.Settings.SaveRequested -= OnSettingsSaved;
+ ui.Settings.TestRequested -= OnSettingsTestRequested;
+ }
+
+ session.UserMessageAdded -= ui.AppendUserMessage;
+ session.ResponseStarted -= ui.BeginResponse;
+ session.ResponseDelta -= ui.AppendResponseDelta;
+ session.ResponseCompleted -= OnResponseCompleted;
+ session.ResponseFailed -= ui.ShowError;
+
+ if (dragger != null) dragger.Clicked -= Toggle;
+ if (loader != null) loader.Loaded -= OnCharacterLoaded;
+
+ if (hitTest != null && hitRegion != null)
+ {
+ hitTest.UnregisterInteractiveRegion(hitRegion);
+ hitRegion = null;
+ }
+
+ // 창 스타일을 되돌려 놓지 않으면 채팅창이 열린 채로 비활성화됐을 때
+ // NOACTIVATE 가 꺼진 상태로 남아 사용자 작업의 포커스를 계속 뺏는다.
+ if (IsOpen) Close();
+ }
+
+ void Update()
+ {
+ if (!IsOpen) return;
+
+ // 이 창은 Esc 를 종료 단축키로 쓴다. 채팅 중에는 Esc 가 "닫기"여야 한다.
+ if (window != null) window.SuppressEscapeQuit();
+
+ var keyboard = Keyboard.current;
+ if (keyboard != null && keyboard.escapeKey.wasPressedThisFrame)
+ {
+ // 설정이 덮여 있으면 대화로만 돌아간다. 한 번 더 눌러야 창이 닫힌다.
+ if (ui.Settings != null && ui.Settings.IsOpen) ui.CloseSettings();
+ else Close();
+ return;
+ }
+
+ if (followCharacter) PlaceNextToCharacter();
+ }
+
+ // ------------------------------------------------------------------ 열고 닫기
+
+ public void Toggle()
+ {
+ if (IsOpen) Close();
+ else Open();
+ }
+
+ public void Open()
+ {
+ if (IsOpen) return;
+
+ MeasureCharacter();
+ PlaceNextToCharacter();
+
+ // 창을 보이기 전에 포커스를 먼저 확보한다. 그래야 입력칸이 활성화되는
+ // 시점에 이미 키보드가 우리 쪽으로 오고 있다.
+ AcquireKeyboardFocus();
+ ui.Open();
+
+ PromptForKeyIfMissing();
+ }
+
+ /// 설정 화면을 연다. 트레이 메뉴 등에서도 부를 수 있게 열어둔다.
+ public void OpenSettings()
+ {
+ if (!IsOpen) Open();
+ ui.OpenSettings(config);
+ }
+
+ ///
+ /// 키가 없으면 오류 대신 설정 화면을 띄운다.
+ ///
+ /// 처음 쓰는 사람이 가장 막히기 쉬운 지점이다. "키가 없다"는 문장만 보여주고
+ /// 파일을 찾아 열게 하면 대부분 여기서 멈춘다.
+ ///
+ void PromptForKeyIfMissing()
+ {
+ if (config.HasApiKey) return;
+
+ if (!announcedMissingKey)
+ {
+ announcedMissingKey = true;
+ ui.ShowNotice($"대화하려면 {config.ProviderLabel} API 키가 필요해.\n" +
+ "설정을 열어둘게 — 키를 넣고 저장해줘. 제공자도 여기서 고를 수 있어.");
+ }
+
+ ui.OpenSettings(config);
+ }
+
+ public void Close()
+ {
+ if (!IsOpen) return;
+
+ ui.Close();
+ ReleaseKeyboardFocus();
+ }
+
+ /// 대화를 비운다. 트레이 메뉴 등에서 부를 수 있게 열어둔다.
+ public void ClearConversation()
+ {
+ session.Clear();
+ ui.ClearMessages();
+ }
+
+ void OnSubmitted(string text)
+ {
+ if (!config.HasApiKey)
+ {
+ PromptForKeyIfMissing();
+ return;
+ }
+
+ session.Send(text);
+ }
+
+ /// 기다리다 지쳐 취소했을 때. 요청을 끊고 입력을 되살린다.
+ void OnCancelRequested()
+ {
+ session.Cancel();
+ ui.CancelResponse();
+ }
+
+ /// 설정 화면의 저장 버튼. 파일에 쓰고 백엔드에 즉시 반영한다.
+ void OnSettingsSaved(ChatConfig updated)
+ {
+ config = updated;
+
+ if (!config.Save())
+ {
+ ui.ShowError($"설정을 저장하지 못했어.\n{ChatConfig.ConfigPath} 에 쓸 수 있는지 확인해줘.");
+ return;
+ }
+
+ // 다음 요청부터 바로 새 값이 쓰인다. 앱을 다시 켤 필요 없다.
+ ConfigureBackends();
+ session.Backend = ActiveBackend; // 제공자가 바뀌었으면 여기서 갈아끼워진다
+ session.MaxHistoryMessages = config.maxHistoryMessages;
+
+ ui.CloseSettings();
+
+ if (config.HasApiKey)
+ {
+ announcedMissingKey = false;
+ ui.ShowNotice($"설정을 저장했어. {config.ProviderLabel} 로 대화할게.");
+ }
+ else
+ {
+ ui.ShowNotice($"설정을 저장했어. 다만 {config.ProviderLabel} API 키가 비어 있어.");
+ }
+ }
+
+ /// 연결 테스트는 화면에서 고른 제공자로 보낸다. 저장 여부와 무관하다.
+ void OnSettingsTestRequested(ChatConfig candidate, Action reply)
+ {
+ IChatBackend target = candidate.IsGemini ? (IChatBackend)geminiBackend : anthropicBackend;
+ target.TestConnection(candidate, reply);
+ }
+
+ void OnResponseCompleted(string _)
+ {
+ ui.EndResponse();
+ }
+
+ void OnCharacterLoaded(ICharacterAvatar avatar)
+ {
+ // 모델이 바뀌면 화면상 크기가 달라진다. 다음 배치 때 다시 잰다.
+ characterMeasured = false;
+ }
+
+ // ------------------------------------------------------------------ 배치
+
+ Transform CharacterRoot =>
+ loader != null && loader.Current != null ? loader.Current.Root : null;
+
+ void MeasureCharacter()
+ {
+ if (characterMeasured) return;
+
+ var root = CharacterRoot;
+ if (root == null || viewCamera == null) return;
+
+ // 렌더러를 전부 훑는 계산이라 매 프레임 할 일이 아니다. 캐릭터는 고정 깊이
+ // 평면 위에서만 움직이므로 화면상 크기는 한 번 재면 계속 유효하다.
+ characterMeasured = CharacterScreenBounds.TryMeasure(
+ viewCamera, root, out characterOffMin, out characterOffMax);
+ }
+
+ /// 캐릭터 옆에 창을 붙인다. 오른쪽에 자리가 없으면 왼쪽으로 넘긴다.
+ void PlaceNextToCharacter()
+ {
+ MeasureCharacter();
+
+ var root = CharacterRoot;
+ if (root == null || viewCamera == null || !characterMeasured) return;
+
+ Vector2 origin = viewCamera.WorldToScreenPoint(root.position);
+ float left = origin.x + characterOffMin.x;
+ float right = origin.x + characterOffMax.x;
+ float top = origin.y + characterOffMax.y;
+
+ Vector2 size = ui.PanelPixelSize;
+
+ float x = right + gapFromCharacter;
+ if (x + size.x > Screen.width) x = left - gapFromCharacter - size.x;
+
+ // 창 위쪽을 캐릭터 머리 높이에 맞춘다. 말풍선처럼 보이는 위치다.
+ float y = top - size.y;
+
+ ui.SetPanelScreenPosition(new Vector2(x, y));
+ }
+
+ // ------------------------------------------------------------------ 포커스
+
+ void AcquireKeyboardFocus()
+ {
+ if (hitTest != null) hitTest.SetNoActivate(false);
+
+#if !UNITY_EDITOR && UNITY_STANDALONE_WIN
+ previousForeground = Win32.GetForegroundWindow();
+ if (window != null && window.Hwnd != IntPtr.Zero)
+ {
+ Win32.ForceForeground(window.Hwnd);
+ }
+#endif
+ }
+
+ void ReleaseKeyboardFocus()
+ {
+ if (hitTest != null) hitTest.SetNoActivate(true);
+
+#if !UNITY_EDITOR && UNITY_STANDALONE_WIN
+ // 쓰던 창으로 포커스를 돌려준다. 이걸 빼먹으면 채팅을 닫은 뒤
+ // 타이핑하던 앱에 글자가 안 들어가서 사용자가 한 번 더 클릭해야 한다.
+ IntPtr self = window != null ? window.Hwnd : IntPtr.Zero;
+ if (previousForeground != IntPtr.Zero &&
+ previousForeground != self &&
+ Win32.IsWindow(previousForeground))
+ {
+ Win32.ForceForeground(previousForeground);
+ }
+ previousForeground = IntPtr.Zero;
+#endif
+ }
+}
diff --git a/Assets/02_Scripts/Chat/ChatController.cs.meta b/Assets/02_Scripts/Chat/ChatController.cs.meta
new file mode 100644
index 0000000..4eb13c0
--- /dev/null
+++ b/Assets/02_Scripts/Chat/ChatController.cs.meta
@@ -0,0 +1,2 @@
+fileFormatVersion: 2
+guid: 368054bb1581f7848a76858b19700f17
\ No newline at end of file
diff --git a/Assets/02_Scripts/Chat/ChatSession.cs b/Assets/02_Scripts/Chat/ChatSession.cs
new file mode 100644
index 0000000..8500699
--- /dev/null
+++ b/Assets/02_Scripts/Chat/ChatSession.cs
@@ -0,0 +1,155 @@
+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);
+ }
+ }
+}
diff --git a/Assets/02_Scripts/Chat/ChatSession.cs.meta b/Assets/02_Scripts/Chat/ChatSession.cs.meta
new file mode 100644
index 0000000..a01c20f
--- /dev/null
+++ b/Assets/02_Scripts/Chat/ChatSession.cs.meta
@@ -0,0 +1,2 @@
+fileFormatVersion: 2
+guid: b67fa2c2c2ac11a43bc3896eae41eda1
\ No newline at end of file
diff --git a/Assets/02_Scripts/Chat/ChatSettingsUI.cs b/Assets/02_Scripts/Chat/ChatSettingsUI.cs
new file mode 100644
index 0000000..6de8973
--- /dev/null
+++ b/Assets/02_Scripts/Chat/ChatSettingsUI.cs
@@ -0,0 +1,529 @@
+using System;
+using UnityEngine;
+using UnityEngine.UI;
+
+///
+/// 설정 화면. 채팅 패널 위를 덮는 겹판으로 만든다.
+///
+/// 별도 창이 아니라 겹판인 이유:
+/// - 클릭 통과 히트테스트에 등록된 사각형이 채팅 패널 하나뿐이라, 같은 사각형
+/// 안에 있으면 히트테스트를 손댈 필요가 없다.
+/// - 캐릭터를 따라다니는 배치 계산도 그대로 재사용된다.
+///
+/// MonoBehaviour 가 아닌 이유: 씬 수명주기가 필요 없고, 채팅창이 자기 자식으로
+/// 만들어 들고 있으면 충분하다. 네트워크 요청이 필요한 "연결 테스트"는 직접 하지 않고
+/// TestRequested 이벤트로 넘긴다 — UI 는 백엔드를 몰라야 한다.
+///
+public class ChatSettingsUI
+{
+ const float HeaderHeight = 34f;
+ const float FooterHeight = 48f;
+ const float RowHeight = 34f;
+ const float SegmentHeight = 30f;
+
+ static readonly (string Label, string Id)[] Providers =
+ {
+ ("Claude", ChatConfig.ProviderAnthropic),
+ ("Gemini", ChatConfig.ProviderGemini),
+ };
+
+ readonly RectTransform root;
+ readonly Segmented providerSegments;
+ readonly InputField keyField;
+ readonly Text keyPlaceholder;
+ readonly Text keyStatus;
+ readonly Text testResult;
+ readonly Button testButton;
+ readonly Button revealButton;
+ readonly GameObject anthropicModelRow;
+ readonly GameObject geminiModelRow;
+ readonly Segmented anthropicModelSegments;
+ readonly Segmented geminiModelSegments;
+ readonly GameObject effortGroup;
+ readonly Segmented effortSegments;
+ readonly InputField personaField;
+
+ ChatConfig working;
+
+ /// 지금 입력칸이 어느 제공자의 키를 들고 있는지. 전환할 때 되돌려 넣어야 한다.
+ string editingProvider = ChatConfig.ProviderAnthropic;
+
+ /// 저장 버튼. 인자는 UI 값이 반영된 설정 객체.
+ public event Action SaveRequested;
+
+ /// 설정을 닫고 대화로 돌아간다.
+ public event Action CloseRequested;
+
+ /// 연결 테스트. 두 번째 인자로 결과를 돌려받는다 (성공 여부, 사람이 읽을 문장).
+ public event Action> TestRequested;
+
+ public bool IsOpen => root != null && root.gameObject.activeSelf;
+
+ public ChatSettingsUI(RectTransform parent)
+ {
+ root = ChatUiBuilder.NewRect("Settings", parent);
+ ChatUiBuilder.Stretch(root);
+
+ // 아래 대화 내용이 비쳐 보이면 읽기 어렵다. 패널 배경을 불투명하게 덮는다.
+ var background = root.gameObject.AddComponent();
+ background.sprite = ChatUiTheme.RoundedPanel;
+ background.type = Image.Type.Sliced;
+ background.color = new Color(
+ ChatUiTheme.PanelBackground.r, ChatUiTheme.PanelBackground.g,
+ ChatUiTheme.PanelBackground.b, 1f);
+
+ BuildHeader();
+ RectTransform content = BuildScrollArea();
+
+ // --- 제공자 ---
+ AddSection(content, "AI 제공자");
+ var providerRow = ChatUiBuilder.NewRect("ProviderRow", content);
+ ChatUiBuilder.SetHeight(providerRow, SegmentHeight);
+ providerSegments = new Segmented(providerRow, Providers, OnProviderChanged);
+
+ // --- API 키 ---
+ AddSection(content, "API 키");
+
+ var keyRow = ChatUiBuilder.NewRect("KeyRow", content);
+ ChatUiBuilder.SetHeight(keyRow, RowHeight);
+
+ keyField = ChatUiBuilder.NewInputField("Key", keyRow, "sk-ant-...", 13);
+ keyField.contentType = InputField.ContentType.Password;
+ keyPlaceholder = keyField.placeholder as Text;
+ var keyRect = keyField.GetComponent();
+ keyRect.anchorMin = Vector2.zero;
+ keyRect.anchorMax = Vector2.one;
+ keyRect.offsetMin = Vector2.zero;
+ keyRect.offsetMax = new Vector2(-52f, 0f);
+ keyField.onValueChanged.AddListener(_ => RefreshKeyStatus());
+
+ revealButton = ChatUiBuilder.NewButton("Reveal", keyRow, "보기", 12,
+ ChatUiTheme.InputBackground, ChatUiTheme.DimText);
+ var revealRect = revealButton.GetComponent();
+ revealRect.anchorMin = new Vector2(1f, 0f);
+ revealRect.anchorMax = Vector2.one;
+ revealRect.pivot = new Vector2(1f, 0.5f);
+ revealRect.sizeDelta = new Vector2(46f, 0f);
+ revealRect.anchoredPosition = Vector2.zero;
+ revealButton.onClick.AddListener(ToggleReveal);
+
+ keyStatus = ChatUiBuilder.NewText("KeyStatus", content, string.Empty, 11, ChatUiTheme.DimText);
+ keyStatus.alignment = TextAnchor.UpperLeft;
+ keyStatus.horizontalOverflow = HorizontalWrapMode.Wrap;
+ keyStatus.verticalOverflow = VerticalWrapMode.Overflow;
+ ChatUiBuilder.SetHeight(keyStatus.transform, 48f);
+
+ var testRow = ChatUiBuilder.NewRect("TestRow", content);
+ ChatUiBuilder.SetHeight(testRow, SegmentHeight);
+
+ testButton = ChatUiBuilder.NewButton("Test", testRow, "연결 테스트", 12,
+ ChatUiTheme.InputBackground, ChatUiTheme.PrimaryText);
+ var testRect = testButton.GetComponent();
+ testRect.anchorMin = Vector2.zero;
+ testRect.anchorMax = new Vector2(0f, 1f);
+ testRect.pivot = new Vector2(0f, 0.5f);
+ testRect.sizeDelta = new Vector2(88f, 0f);
+ testRect.anchoredPosition = Vector2.zero;
+ testButton.onClick.AddListener(RunTest);
+
+ testResult = ChatUiBuilder.NewText("TestResult", testRow, string.Empty, 11, ChatUiTheme.DimText);
+ testResult.alignment = TextAnchor.MiddleLeft;
+ testResult.horizontalOverflow = HorizontalWrapMode.Wrap;
+ var resultRect = testResult.rectTransform;
+ resultRect.anchorMin = Vector2.zero;
+ resultRect.anchorMax = Vector2.one;
+ resultRect.offsetMin = new Vector2(96f, 0f);
+ resultRect.offsetMax = Vector2.zero;
+
+ // --- 모델 ---
+ // 제공자마다 목록이 다르므로 두 벌을 만들어 두고 보이는 쪽만 바꾼다.
+ // 하나를 만들어 놓고 항목을 갈아끼우면 버튼을 부수고 다시 짓게 된다.
+ AddSection(content, "모델");
+
+ var anthropicRow = ChatUiBuilder.NewRect("AnthropicModels", content);
+ ChatUiBuilder.SetHeight(anthropicRow, SegmentHeight);
+ anthropicModelRow = anthropicRow.gameObject;
+ anthropicModelSegments = new Segmented(anthropicRow, AnthropicChatBackend.SelectableModels);
+
+ var geminiRow = ChatUiBuilder.NewRect("GeminiModels", content);
+ ChatUiBuilder.SetHeight(geminiRow, SegmentHeight);
+ geminiModelRow = geminiRow.gameObject;
+ geminiModelSegments = new Segmented(geminiRow, GeminiChatBackend.SelectableModels);
+
+ // --- 응답 깊이 (Claude 전용) ---
+ // Gemini 에는 대응하는 설정을 넣지 않았다. 모델 세대마다 thinkingConfig 모양이
+ // 달라서 잘못 보내면 400 이 난다. 그래서 항목 자체를 숨긴다.
+ var effortContainer = ChatUiBuilder.NewRect("EffortGroup", content);
+ ChatUiBuilder.MakeVerticalList(effortContainer, new RectOffset(0, 0, 0, 0), 5f);
+ effortGroup = effortContainer.gameObject;
+
+ AddSection(effortContainer, "응답 깊이");
+ var effortRow = ChatUiBuilder.NewRect("EffortRow", effortContainer);
+ ChatUiBuilder.SetHeight(effortRow, SegmentHeight);
+ effortSegments = new Segmented(effortRow, AnthropicChatBackend.SelectableEfforts);
+
+ // --- 성격 ---
+ AddSection(content, "성격 (캐릭터가 어떻게 말할지)");
+ personaField = ChatUiBuilder.NewInputField("Persona", content, "예: 친근한 반말로 짧게 대답해줘", 12, true);
+ personaField.characterLimit = 4000;
+ ChatUiBuilder.SetHeight(personaField.transform, 130f);
+
+ BuildFooter();
+
+ root.gameObject.SetActive(false);
+ }
+
+ // ------------------------------------------------------------------ 열고 닫기
+
+ /// 현재 설정을 화면에 채우고 연다. 취소하면 이 객체는 건드려지지 않는다.
+ public void Open(ChatConfig config)
+ {
+ working = config ?? ChatConfig.Load();
+
+ // 순서가 중요하다. Select 는 값이 바뀌면 OnProviderChanged 를 부르고, 그 안의
+ // StashKey 가 "입력칸에 지금 들어있는 글자"를 editingProvider 자리에 써넣는다.
+ // 입력칸을 먼저 채워두지 않으면 지난번에 열었을 때의 글자가 엉뚱한 제공자의
+ // 키를 덮어쓴다. 먼저 채워두면 콜백이 돌아도 같은 값을 같은 자리에 다시 넣을 뿐이다.
+ editingProvider = working.provider;
+ keyField.text = KeyFor(editingProvider);
+ SetRevealed(false);
+
+ providerSegments.Select(working.provider);
+
+ anthropicModelSegments.Select(working.model);
+ geminiModelSegments.Select(working.geminiModel);
+ effortSegments.Select(working.effort);
+ personaField.text = working.persona ?? string.Empty;
+
+ testResult.text = string.Empty;
+ ApplyProviderToScreen(editingProvider);
+
+ root.gameObject.SetActive(true);
+ }
+
+ public void Close()
+ {
+ if (root != null) root.gameObject.SetActive(false);
+ }
+
+ void Apply()
+ {
+ if (working == null) return;
+
+ StashKey();
+
+ working.provider = providerSegments.Value;
+ working.model = anthropicModelSegments.Value;
+ working.geminiModel = geminiModelSegments.Value;
+ working.effort = effortSegments.Value;
+ working.persona = personaField.text;
+
+ SaveRequested?.Invoke(working);
+ }
+
+ // ------------------------------------------------------------------ 제공자 전환
+
+ void OnProviderChanged(string providerId)
+ {
+ // 입력칸에 들고 있던 키를 원래 제공자 자리에 되돌려 놓고 새 제공자 것을 꺼낸다.
+ // 이걸 안 하면 제공자를 왔다갔다 할 때 방금 붙여넣은 키가 사라진다.
+ StashKey();
+ editingProvider = providerId;
+ keyField.text = KeyFor(providerId);
+ SetRevealed(false);
+
+ testResult.text = string.Empty;
+ ApplyProviderToScreen(providerId);
+ }
+
+ /// 지금 입력칸의 값을 editingProvider 자리에 저장한다.
+ void StashKey()
+ {
+ if (working == null) return;
+
+ string typed = keyField.text.Trim();
+ if (editingProvider == ChatConfig.ProviderGemini) working.geminiApiKey = typed;
+ else working.apiKey = typed;
+ }
+
+ string KeyFor(string providerId)
+ {
+ if (working == null) return string.Empty;
+ return (providerId == ChatConfig.ProviderGemini ? working.geminiApiKey : working.apiKey)
+ ?? string.Empty;
+ }
+
+ /// 제공자에 맞게 보이는 항목과 안내 문구를 바꾼다.
+ void ApplyProviderToScreen(string providerId)
+ {
+ bool gemini = providerId == ChatConfig.ProviderGemini;
+
+ anthropicModelRow.SetActive(!gemini);
+ geminiModelRow.SetActive(gemini);
+ effortGroup.SetActive(!gemini);
+
+ if (keyPlaceholder != null) keyPlaceholder.text = gemini ? "AIza..." : "sk-ant-...";
+
+ RefreshKeyStatus();
+ }
+
+ void RefreshKeyStatus()
+ {
+ bool gemini = editingProvider == ChatConfig.ProviderGemini;
+ bool hasTyped = !string.IsNullOrWhiteSpace(keyField.text);
+ bool hasEnv = ChatConfig.EnvironmentKeyFor(editingProvider) != null;
+
+ string where = gemini ? "aistudio.google.com" : "console.anthropic.com";
+ string envName = gemini ? "GEMINI_API_KEY" : "ANTHROPIC_API_KEY";
+
+ if (hasTyped)
+ {
+ keyStatus.text = "저장하면 이 키를 씁니다.";
+ keyStatus.color = ChatUiTheme.DimText;
+ }
+ else if (hasEnv)
+ {
+ keyStatus.text = $"지금은 환경 변수 {envName} 를 쓰고 있어요.\n여기에 입력하면 그쪽이 우선합니다.";
+ keyStatus.color = ChatUiTheme.DimText;
+ }
+ else
+ {
+ keyStatus.text = $"키가 없어요. {where} 에서 발급받아 붙여넣어 주세요.";
+ keyStatus.color = new Color(0.93f, 0.72f, 0.42f, 1f);
+ }
+
+ // 무료 티어의 대가는 결정하는 자리에서 알려야 의미가 있다.
+ if (gemini)
+ {
+ keyStatus.text += "\n※ 무료 등급은 주고받은 대화가 Google 제품 개선에 쓰입니다.";
+ }
+ }
+
+ void ToggleReveal()
+ {
+ SetRevealed(keyField.contentType == InputField.ContentType.Password);
+ }
+
+ void SetRevealed(bool revealed)
+ {
+ keyField.contentType = revealed
+ ? InputField.ContentType.Standard
+ : InputField.ContentType.Password;
+
+ // contentType 을 바꾸면 표시 문자열을 다시 그려야 별표/원문이 실제로 바뀐다.
+ keyField.ForceLabelUpdate();
+
+ var label = revealButton.GetComponentInChildren();
+ if (label != null) label.text = revealed ? "숨김" : "보기";
+ }
+
+ void RunTest()
+ {
+ if (TestRequested == null || working == null) return;
+
+ // 아직 저장하지 않은 화면의 값으로 시험해야 사용자가 기대한 결과가 나온다.
+ string typed = keyField.text.Trim();
+ bool gemini = providerSegments.Value == ChatConfig.ProviderGemini;
+
+ var candidate = new ChatConfig { provider = providerSegments.Value };
+ if (gemini)
+ {
+ candidate.geminiApiKey = typed;
+ candidate.geminiModel = geminiModelSegments.Value;
+ }
+ else
+ {
+ candidate.apiKey = typed;
+ candidate.model = anthropicModelSegments.Value;
+ }
+
+ testButton.interactable = false;
+ testResult.text = "확인 중…";
+ testResult.color = ChatUiTheme.DimText;
+
+ TestRequested.Invoke(candidate, (ok, message) =>
+ {
+ testButton.interactable = true;
+ testResult.text = message;
+ testResult.color = ok
+ ? new Color(0.55f, 0.85f, 0.6f, 1f)
+ : new Color(0.95f, 0.6f, 0.6f, 1f);
+ });
+ }
+
+ // ------------------------------------------------------------------ 계층 만들기
+
+ void BuildHeader()
+ {
+ var header = ChatUiBuilder.NewRect("Header", root);
+ header.anchorMin = new Vector2(0f, 1f);
+ header.anchorMax = Vector2.one;
+ header.pivot = new Vector2(0.5f, 1f);
+ header.sizeDelta = new Vector2(0f, HeaderHeight);
+ header.anchoredPosition = Vector2.zero;
+
+ // 기호 대신 한글을 쓴다. OS 한글 글꼴에 없는 기호는 빈칸으로 나온다.
+ var back = ChatUiBuilder.NewButton("Back", header, "뒤로", 12, Color.clear, ChatUiTheme.DimText);
+ var backRect = back.GetComponent();
+ backRect.anchorMin = new Vector2(0f, 0.5f);
+ backRect.anchorMax = new Vector2(0f, 0.5f);
+ backRect.pivot = new Vector2(0f, 0.5f);
+ backRect.sizeDelta = new Vector2(40f, HeaderHeight);
+ backRect.anchoredPosition = new Vector2(6f, 0f);
+ back.onClick.AddListener(() => CloseRequested?.Invoke());
+
+ var title = ChatUiBuilder.NewText("Title", header, "설정", 13, ChatUiTheme.DimText);
+ title.alignment = TextAnchor.MiddleCenter;
+ ChatUiBuilder.Stretch(title.rectTransform, 52f, 0f);
+
+ var divider = ChatUiBuilder.NewImage("Divider", header, null, new Color(1f, 1f, 1f, 0.08f));
+ var dividerRect = divider.rectTransform;
+ dividerRect.anchorMin = Vector2.zero;
+ dividerRect.anchorMax = new Vector2(1f, 0f);
+ dividerRect.pivot = new Vector2(0.5f, 0f);
+ dividerRect.sizeDelta = new Vector2(-16f, 1f);
+ dividerRect.anchoredPosition = Vector2.zero;
+ }
+
+ RectTransform BuildScrollArea()
+ {
+ var scrollRect = ChatUiBuilder.NewRect("Scroll", root);
+ scrollRect.anchorMin = Vector2.zero;
+ scrollRect.anchorMax = Vector2.one;
+ scrollRect.offsetMin = new Vector2(10f, FooterHeight);
+ scrollRect.offsetMax = new Vector2(-10f, -HeaderHeight);
+
+ var scroll = scrollRect.gameObject.AddComponent();
+ scroll.horizontal = false;
+ scroll.vertical = true;
+ scroll.movementType = ScrollRect.MovementType.Clamped;
+ scroll.scrollSensitivity = 26f;
+
+ var viewport = ChatUiBuilder.NewRect("Viewport", scrollRect);
+ ChatUiBuilder.Stretch(viewport);
+ viewport.gameObject.AddComponent();
+
+ var content = ChatUiBuilder.NewRect("Content", viewport);
+ content.anchorMin = new Vector2(0f, 1f);
+ content.anchorMax = Vector2.one;
+ content.pivot = new Vector2(0.5f, 1f);
+ content.sizeDelta = Vector2.zero;
+
+ ChatUiBuilder.MakeVerticalList(content, new RectOffset(0, 6, 6, 12), 5f);
+ var fitter = content.gameObject.AddComponent();
+ fitter.verticalFit = ContentSizeFitter.FitMode.PreferredSize;
+
+ scroll.viewport = viewport;
+ scroll.content = content;
+ return content;
+ }
+
+ void BuildFooter()
+ {
+ var footer = ChatUiBuilder.NewRect("Footer", root);
+ footer.anchorMin = Vector2.zero;
+ footer.anchorMax = new Vector2(1f, 0f);
+ footer.pivot = new Vector2(0.5f, 0f);
+ footer.sizeDelta = new Vector2(0f, FooterHeight);
+ footer.anchoredPosition = Vector2.zero;
+
+ var cancel = ChatUiBuilder.NewButton("Cancel", footer, "취소", 13,
+ ChatUiTheme.InputBackground, ChatUiTheme.DimText);
+ var cancelRect = cancel.GetComponent();
+ cancelRect.anchorMin = new Vector2(0f, 0.5f);
+ cancelRect.anchorMax = new Vector2(0f, 0.5f);
+ cancelRect.pivot = new Vector2(0f, 0.5f);
+ cancelRect.sizeDelta = new Vector2(70f, 30f);
+ cancelRect.anchoredPosition = new Vector2(10f, 0f);
+ cancel.onClick.AddListener(() => CloseRequested?.Invoke());
+
+ var save = ChatUiBuilder.NewButton("Save", footer, "저장", 13,
+ ChatUiTheme.AccentButton, ChatUiTheme.PrimaryText);
+ var saveRect = save.GetComponent();
+ saveRect.anchorMin = new Vector2(1f, 0.5f);
+ saveRect.anchorMax = new Vector2(1f, 0.5f);
+ saveRect.pivot = new Vector2(1f, 0.5f);
+ saveRect.sizeDelta = new Vector2(80f, 30f);
+ saveRect.anchoredPosition = new Vector2(-10f, 0f);
+ save.onClick.AddListener(Apply);
+ }
+
+ static void AddSection(RectTransform content, string label)
+ {
+ var text = ChatUiBuilder.NewSectionLabel(label, content, label);
+ ChatUiBuilder.SetHeight(text.transform, 20f);
+ }
+
+ ///
+ /// 몇 개 안 되는 선택지를 가로로 나눠 붙인 버튼 묶음.
+ ///
+ /// uGUI Dropdown 을 코드로 만들려면 템플릿 계층(뷰포트/스크롤/아이템/체크마크)을
+ /// 통째로 손으로 세워야 한다. 선택지가 서너 개뿐이라 이쪽이 훨씬 간단하고,
+ /// 목록을 펼치지 않아도 현재 값이 보인다.
+ ///
+ class Segmented
+ {
+ readonly (string Label, string Id)[] options;
+ readonly Image[] backgrounds;
+ readonly Action onChanged;
+
+ public string Value { get; private set; }
+
+ public Segmented(RectTransform row, (string Label, string Id)[] options,
+ Action onChanged = null)
+ {
+ this.options = options;
+ this.onChanged = onChanged;
+ backgrounds = new Image[options.Length];
+
+ for (int i = 0; i < options.Length; i++)
+ {
+ int index = i;
+ var button = ChatUiBuilder.NewButton(options[i].Id, row, options[i].Label, 12,
+ ChatUiTheme.InputBackground, ChatUiTheme.PrimaryText);
+
+ var rect = button.GetComponent();
+ rect.anchorMin = new Vector2(i / (float)options.Length, 0f);
+ rect.anchorMax = new Vector2((i + 1) / (float)options.Length, 1f);
+ rect.offsetMin = new Vector2(i == 0 ? 0f : 2f, 0f);
+ rect.offsetMax = new Vector2(i == options.Length - 1 ? 0f : -2f, 0f);
+
+ backgrounds[i] = button.targetGraphic as Image;
+ button.onClick.AddListener(() => Select(this.options[index].Id));
+ }
+
+ // 생성 시점에는 알릴 상대가 아직 없으므로 콜백 없이 첫 항목만 칠한다.
+ Paint(0);
+ Value = options[0].Id;
+ }
+
+ public void Select(string id)
+ {
+ int selected = -1;
+ for (int i = 0; i < options.Length; i++)
+ {
+ if (options[i].Id == id) { selected = i; break; }
+ }
+
+ // 설정 파일에 목록에 없는 값이 적혀 있을 수 있다. 그때는 첫 항목으로 되돌린다.
+ if (selected < 0) selected = 0;
+
+ bool changed = Value != options[selected].Id;
+ Value = options[selected].Id;
+ Paint(selected);
+
+ if (changed) onChanged?.Invoke(Value);
+ }
+
+ void Paint(int selected)
+ {
+ for (int i = 0; i < backgrounds.Length; i++)
+ {
+ if (backgrounds[i] == null) continue;
+ backgrounds[i].color = i == selected
+ ? ChatUiTheme.AccentButton
+ : ChatUiTheme.InputBackground;
+ }
+ }
+ }
+}
diff --git a/Assets/02_Scripts/Chat/ChatSettingsUI.cs.meta b/Assets/02_Scripts/Chat/ChatSettingsUI.cs.meta
new file mode 100644
index 0000000..a8c4bcb
--- /dev/null
+++ b/Assets/02_Scripts/Chat/ChatSettingsUI.cs.meta
@@ -0,0 +1,2 @@
+fileFormatVersion: 2
+guid: 7abd865888980df439c835020ee8d676
\ No newline at end of file
diff --git a/Assets/02_Scripts/Chat/ChatTypes.cs b/Assets/02_Scripts/Chat/ChatTypes.cs
new file mode 100644
index 0000000..ebd4b36
--- /dev/null
+++ b/Assets/02_Scripts/Chat/ChatTypes.cs
@@ -0,0 +1,67 @@
+using System;
+using System.Collections.Generic;
+
+/// 대화 한 줄의 화자. API 의 role 문자열로 그대로 바뀐다.
+public enum ChatRole
+{
+ User,
+ Assistant,
+}
+
+/// 대화 기록 한 줄.
+[Serializable]
+public struct ChatMessage
+{
+ public ChatRole Role;
+ public string Text;
+
+ public ChatMessage(ChatRole role, string text)
+ {
+ Role = role;
+ Text = text;
+ }
+
+ /// API 가 쓰는 role 문자열.
+ public string RoleName => Role == ChatRole.User ? "user" : "assistant";
+}
+
+///
+/// 대화 백엔드의 유일한 창구.
+///
+/// 지금 구현체는 Anthropic API 를 직접 치는 하나뿐이지만,
+/// 나중에 ASR/TTS 를 붙이면서 .NET 8 사이드카로 옮길 때 이 인터페이스만 다시 구현하면 된다.
+/// UI 와 대화 기록 관리는 백엔드가 무엇인지 전혀 모른다.
+///
+/// 스트리밍이 인터페이스에 박혀 있는 이유: 첫 글자가 뜨기까지의 지연이 체감 품질을
+/// 좌우하고, 나중에 문장 경계에서 잘라 TTS 로 넘기려면 델타 단위 수신이 필수다.
+///
+public interface IChatBackend
+{
+ /// 요청이 진행 중인지. true 면 새 요청을 보내지 않는다.
+ bool IsBusy { get; }
+
+ ///
+ /// 대화 기록 전체를 보내고 응답을 스트리밍으로 받는다.
+ ///
+ /// 지금까지의 대화. 마지막 항목이 이번 사용자 발화다.
+ /// 응답 조각이 도착할 때마다. 이어 붙이면 전체 응답이 된다.
+ /// 응답이 끝났을 때. 인자는 이어 붙인 전체 응답.
+ /// 실패 사유. 사용자에게 그대로 보여줄 수 있는 문장이어야 한다.
+ void Send(IReadOnlyList history,
+ Action onDelta,
+ Action onComplete,
+ Action onError);
+
+ /// 진행 중인 요청을 중단한다. 진행 중이 아니면 아무것도 하지 않는다.
+ void Cancel();
+
+ ///
+ /// 키와 모델 이름이 쓸 만한지 확인한다. 설정 화면의 "연결 테스트" 버튼이 쓴다.
+ ///
+ /// 인터페이스에 넣은 이유: 무엇이 유효한 자격증명인지는 제공자마다 다르고,
+ /// 설정 화면은 그걸 알 필요가 없어야 한다.
+ ///
+ /// 아직 저장하지 않은, 화면에 입력된 값.
+ /// (성공 여부, 사용자에게 보여줄 문장).
+ void TestConnection(ChatConfig candidate, Action onResult);
+}
diff --git a/Assets/02_Scripts/Chat/ChatTypes.cs.meta b/Assets/02_Scripts/Chat/ChatTypes.cs.meta
new file mode 100644
index 0000000..dc64c03
--- /dev/null
+++ b/Assets/02_Scripts/Chat/ChatTypes.cs.meta
@@ -0,0 +1,2 @@
+fileFormatVersion: 2
+guid: 7262648598a182145b82d1d1ddca6b52
\ No newline at end of file
diff --git a/Assets/02_Scripts/Chat/ChatUiBuilder.cs b/Assets/02_Scripts/Chat/ChatUiBuilder.cs
new file mode 100644
index 0000000..5cd6842
--- /dev/null
+++ b/Assets/02_Scripts/Chat/ChatUiBuilder.cs
@@ -0,0 +1,136 @@
+using UnityEngine;
+using UnityEngine.UI;
+
+///
+/// uGUI 조각을 코드로 만드는 공용 부품. 채팅창과 설정창이 같은 것을 쓴다.
+///
+/// 에셋(프리팹/스프라이트/폰트)을 하나도 만들지 않는 것이 이 프로젝트의 방침이다.
+/// 이유는 ChatUiTheme 에 적어뒀다.
+///
+public static class ChatUiBuilder
+{
+ public static RectTransform NewRect(string name, Transform parent)
+ {
+ var go = new GameObject(name, typeof(RectTransform));
+ var rect = go.GetComponent();
+ rect.SetParent(parent, false);
+ return rect;
+ }
+
+ /// 부모를 가득 채우도록 늘린다.
+ public static void Stretch(RectTransform rect, float horizontal = 0f, float vertical = 0f)
+ {
+ rect.anchorMin = Vector2.zero;
+ rect.anchorMax = Vector2.one;
+ rect.offsetMin = new Vector2(horizontal, vertical);
+ rect.offsetMax = new Vector2(-horizontal, -vertical);
+ }
+
+ public static Image NewImage(string name, Transform parent, Sprite sprite, Color color)
+ {
+ var rect = NewRect(name, parent);
+ var image = rect.gameObject.AddComponent();
+ image.sprite = sprite;
+ if (sprite != null) image.type = Image.Type.Sliced;
+ image.color = color;
+ return image;
+ }
+
+ public static Text NewText(string name, Transform parent, string value, int fontSize, Color color)
+ {
+ var rect = NewRect(name, parent);
+ var text = rect.gameObject.AddComponent();
+ text.font = ChatUiTheme.Font;
+ text.fontSize = fontSize;
+ text.color = color;
+ text.text = value;
+ text.supportRichText = false;
+ // 글자가 스크롤 드래그나 버튼 클릭을 가로채지 않게 한다.
+ text.raycastTarget = false;
+ return text;
+ }
+
+ public static Button NewButton(string name, Transform parent, string label, int fontSize,
+ Color background, Color foreground)
+ {
+ var image = NewImage(name, parent, ChatUiTheme.RoundedSmall, background);
+ var rect = image.rectTransform;
+
+ var button = rect.gameObject.AddComponent