Files
2026-09-01 14:44:46 +09:00

220 lines
8.7 KiB
C#

using System;
using System.IO;
using UnityEngine;
/// <summary>
/// 채팅 설정. 실행 파일 옆 Config/chat.json 에서 읽는다.
///
/// API 키를 빌드에 넣지 않는 이유는 모델을 빌드에 넣지 않는 이유와 같다 —
/// 앱은 빈 껍데기로 배포하고 사용자가 자기 것을 넣는다. 키가 코드나 씬에
/// 직렬화되면 배포본에서 그대로 추출된다.
///
/// 제공자(Claude / Gemini)별로 키와 모델을 따로 둔다. 하나만 두고 돌려쓰면
/// 제공자를 바꿀 때마다 키를 다시 붙여넣어야 한다.
///
/// 키 탐색 순서
/// 1. Config/chat.json 의 값 — 앱 안 설정 화면에서 입력한 것
/// 2. 환경 변수 (ANTHROPIC_API_KEY / GEMINI_API_KEY) — 개발 중 편의
///
/// 앱에서 입력한 값이 환경 변수를 이긴다. 반대로 하면 설정 화면에서 키를 바꿔도
/// 아무 일이 안 일어나는 것처럼 보여서 사용자가 원인을 찾을 수 없다.
/// </summary>
[Serializable]
public class ChatConfig
{
public const string ProviderAnthropic = "anthropic";
public const string ProviderGemini = "gemini";
/// <summary>어느 제공자를 쓸지. ProviderAnthropic 또는 ProviderGemini.</summary>
public string provider = ProviderAnthropic;
// --- Anthropic ---
/// <summary>Anthropic API 키. 비우면 환경 변수 ANTHROPIC_API_KEY 를 쓴다.</summary>
public string apiKey = "";
public string model = "claude-opus-5";
/// <summary>
/// 사고 깊이. Anthropic 전용이다. 잡담에 깊이 생각할 이유가 없고 지연이 그대로 체감된다.
/// low / medium / high / xhigh / max.
/// </summary>
public string effort = "low";
// --- Gemini ---
/// <summary>Gemini API 키. 비우면 환경 변수 GEMINI_API_KEY 를 쓴다.</summary>
public string geminiApiKey = "";
public string geminiModel = "gemini-3.6-flash";
// --- 공통 ---
/// <summary>
/// 응답 길이 상한. 데스크톱 비서의 대사는 짧아야 하므로 크게 잡을 이유가 없다.
/// 스트리밍이라 HTTP 타임아웃 걱정은 없지만, 상한이 크면 모델이 길게 쓰는 경향이 있다.
/// </summary>
public int maxTokens = 2048;
/// <summary>대화 기록을 몇 턴까지 들고 갈지. 넘으면 오래된 것부터 버린다.</summary>
public int maxHistoryMessages = 40;
/// <summary>캐릭터 페르소나. 시스템 프롬프트로 들어간다. 제공자와 무관하게 공용이다.</summary>
[TextArea(4, 20)]
public string persona =
"너는 사용자의 데스크톱 위에 사는 작은 캐릭터 비서다.\n" +
"말투는 친근한 반말. 사용자가 존댓말을 쓰면 존댓말로 맞춰준다.\n" +
"화면 한켠의 작은 말풍선에 표시되므로 답변은 두세 문장 안에서 끝낸다.\n" +
"목록이나 표는 쓰지 않고, 길어질 것 같으면 핵심만 말한 뒤 더 들을지 되묻는다.\n" +
"모르는 것은 아는 척하지 않는다.";
const string FileName = "chat.json";
/// <summary>실행 파일 옆 Config 폴더. VrmCharacterLoader.ModelsDirectory 와 같은 기준.</summary>
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);
/// <summary>설정을 읽는다. 파일이 없으면 기본값으로 템플릿을 만들고 그 기본값을 돌려준다.</summary>
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;
}
/// <summary>설정을 파일에 쓴다. 설정 화면의 저장 버튼이 부른다.</summary>
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;
}
}
/// <summary>
/// 설정 파일에 모르는 값이 적혀 있을 수 있다. 아는 값으로 되돌린다.
///
/// 특히 모델 이름은 제공자가 언제든 없앤다(예: gemini-2.5-flash 는 신규 사용자에게
/// 제공 중단됐다). 설정 화면의 선택지에 없는 값이 파일에 남아 있으면, 화면은
/// 첫 항목을 칠해 보여주는데 실제 요청은 예전 값으로 나간다 — 보이는 것과
/// 쓰이는 것이 어긋난다. 그 상태가 제일 찾기 어렵다.
/// </summary>
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;
/// <summary>지금 제공자에서 쓸 모델 ID.</summary>
public string ActiveModel => IsGemini ? geminiModel : model;
/// <summary>사람에게 보여줄 제공자 이름.</summary>
public string ProviderLabel => IsGemini ? "Gemini" : "Claude";
// ------------------------------------------------------------------ 키
/// <summary>환경 변수를 읽는다. 없거나 접근이 막혀 있으면 null.</summary>
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");
/// <summary>Anthropic 백엔드가 쓸 키. 없으면 null.</summary>
public string ResolveAnthropicKey() =>
!string.IsNullOrWhiteSpace(apiKey) ? apiKey.Trim() : EnvironmentKeyFor(ProviderAnthropic);
/// <summary>Gemini 백엔드가 쓸 키. 없으면 null.</summary>
public string ResolveGeminiKey() =>
!string.IsNullOrWhiteSpace(geminiApiKey) ? geminiApiKey.Trim() : EnvironmentKeyFor(ProviderGemini);
/// <summary>지금 제공자의 키. 없으면 null.</summary>
public string ResolveApiKey() => IsGemini ? ResolveGeminiKey() : ResolveAnthropicKey();
/// <summary>지금 제공자로 대화를 시작할 수 있는지.</summary>
public bool HasApiKey => !string.IsNullOrEmpty(ResolveApiKey());
/// <summary>설정 파일에는 키가 없고 환경 변수만 있는 상태인지. 설정 화면에서 안내한다.</summary>
public bool UsingEnvironmentKey
{
get
{
string typed = IsGemini ? geminiApiKey : apiKey;
return string.IsNullOrWhiteSpace(typed) && EnvironmentKeyFor(provider) != null;
}
}
}