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.6-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; } } /// /// 설정 파일에 모르는 값이 적혀 있을 수 있다. 아는 값으로 되돌린다. /// /// 특히 모델 이름은 제공자가 언제든 없앤다(예: gemini-2.5-flash 는 신규 사용자에게 /// 제공 중단됐다). 설정 화면의 선택지에 없는 값이 파일에 남아 있으면, 화면은 /// 첫 항목을 칠해 보여주는데 실제 요청은 예전 값으로 나간다 — 보이는 것과 /// 쓰이는 것이 어긋난다. 그 상태가 제일 찾기 어렵다. /// void Normalize() { if (provider != ProviderGemini) provider = ProviderAnthropic; model = Coerce(model, AnthropicChatBackend.SelectableModels, "model"); effort = Coerce(effort, AnthropicChatBackend.SelectableEfforts, "effort"); geminiModel = Coerce(geminiModel, GeminiChatBackend.SelectableModels, "geminiModel"); } static string Coerce(string value, (string Label, string Id)[] options, string field) { foreach (var option in options) { if (option.Id == value) return value; } Debug.LogWarning($"[ChatConfig] {field} 의 '{value}' 는 선택지에 없어 " + $"'{options[0].Id}' 로 되돌립니다."); return options[0].Id; } // ------------------------------------------------------------------ 제공자 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; } } }