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