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;
}
}
}
}