220 lines
8.7 KiB
C#
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;
|
|
}
|
|
}
|
|
}
|