Skip to main content

kotonoha_core/
config.rs

1use std::collections::BTreeMap;
2use std::path::{Path, PathBuf};
3
4use serde::Deserialize;
5use teravars::{Context, Engine, extract_vars, resolve, system_context};
6
7use crate::lesson::Lesson;
8
9#[derive(Debug, Clone, Deserialize)]
10pub struct Config {
11    #[serde(default)]
12    pub vars: BTreeMap<String, toml::Value>,
13    pub server: ServerConfig,
14    #[serde(default)]
15    pub avatars: AvatarsConfig,
16    #[serde(default)]
17    pub voice: VoiceConfig,
18    #[serde(default)]
19    pub backend: BTreeMap<String, BackendConfig>,
20    #[serde(default)]
21    pub lesson: BTreeMap<String, LessonRef>,
22
23    #[serde(skip)]
24    pub root_dir: PathBuf,
25}
26
27#[derive(Debug, Clone, Deserialize)]
28pub struct ServerConfig {
29    pub bind: String,
30    #[serde(default = "default_cors")]
31    pub cors_allow_origins: Vec<String>,
32}
33
34fn default_cors() -> Vec<String> {
35    vec!["*".into()]
36}
37
38#[derive(Debug, Clone, Default, Deserialize)]
39pub struct AvatarsConfig {
40    #[serde(default = "default_avatars_dir")]
41    pub dir: String,
42    #[serde(default)]
43    pub default: String,
44}
45
46fn default_avatars_dir() -> String {
47    "./avatars".into()
48}
49
50#[derive(Debug, Clone, Default, Deserialize)]
51pub struct VoiceConfig {
52    #[serde(default = "default_browser")]
53    pub stt: String,
54    #[serde(default = "default_browser")]
55    pub tts: String,
56    /// Optional kokoro section — only required when `tts = "kokoro"`.
57    pub kokoro: Option<KokoroConfig>,
58    /// Optional voicevox section — used when a request asks for
59    /// Japanese synthesis (lang=ja or auto-detected).
60    pub voicevox: Option<VoicevoxConfig>,
61}
62
63#[derive(Debug, Clone, Deserialize)]
64pub struct KokoroConfig {
65    pub model_path: String,
66    pub voices_dir: String,
67    #[serde(default = "default_kokoro_voice")]
68    pub default_voice: String,
69    #[serde(default = "default_kokoro_speed")]
70    pub speed: f32,
71}
72
73fn default_browser() -> String {
74    "browser".into()
75}
76
77fn default_kokoro_voice() -> String {
78    "af_heart".into()
79}
80
81fn default_kokoro_speed() -> f32 {
82    1.0
83}
84
85#[derive(Debug, Clone, Deserialize)]
86pub struct VoicevoxConfig {
87    /// Numeric VOICEVOX speaker id (e.g. 8 = 春日部つむぎ ノーマル).
88    /// See `kotonoha-tts::voicevox` docs for the curated subset and
89    /// each character's license requirements.
90    #[serde(default = "default_voicevox_speaker")]
91    pub default_speaker_id: u32,
92    /// Speaker ids to pre-load on engine init. On-demand loading
93    /// still works for everything else; this just hides the
94    /// first-call latency for the speakers you expect to use.
95    #[serde(default = "default_voicevox_preload")]
96    pub preload_speakers: Vec<u32>,
97}
98
99fn default_voicevox_speaker() -> u32 {
100    8
101}
102
103fn default_voicevox_preload() -> Vec<u32> {
104    vec![2, 3, 8] // 四国めたん, ずんだもん, 春日部つむぎ
105}
106
107/// Backend configuration — either a local CLI subprocess (claude /
108/// gemini / codex shipped binaries) or a direct HTTP API call.
109///
110/// Discriminated by which fields are present (untagged), so existing
111/// CLI entries like `[backend.claude] cmd = "..." args = [...]` still
112/// parse without adding a `type = "cli"` line.
113#[derive(Debug, Clone, Deserialize)]
114#[serde(untagged)]
115pub enum BackendConfig {
116    Api(ApiBackendConfig),
117    Cli(CliBackendConfig),
118}
119
120#[derive(Debug, Clone, Deserialize)]
121pub struct CliBackendConfig {
122    pub cmd: String,
123    #[serde(default)]
124    pub args: Vec<String>,
125}
126
127#[derive(Debug, Clone, Deserialize)]
128pub struct ApiBackendConfig {
129    /// Provider key — currently `"google"` (Gemini).  Future:
130    /// `"anthropic"` / `"openai"`.
131    pub provider: String,
132    /// Model identifier passed to the provider.  Examples:
133    ///   - google:    `"gemini-2.5-flash"`, `"gemini-2.5-pro"`
134    ///   - anthropic: `"claude-sonnet-4-6"`
135    ///   - openai:    `"gpt-4o-mini"`
136    pub model: String,
137    /// Name of the env var holding the API key (e.g. `"GEMINI_API_KEY"`).
138    /// Resolving at request time means a missing key only breaks API
139    /// backends — CLI backends keep working zero-config.
140    pub api_key_env: String,
141    /// Optional per-call temperature (provider default if omitted).
142    #[serde(default)]
143    pub temperature: Option<f32>,
144}
145
146#[derive(Debug, Clone, Deserialize)]
147pub struct LessonRef {
148    pub extends: String,
149}
150
151impl Config {
152    pub fn load(path: &Path) -> anyhow::Result<Self> {
153        let rendered = render_toml(path)?;
154        let mut cfg: Config = toml::from_str(&rendered)?;
155        cfg.root_dir = path
156            .parent()
157            .map(|p| p.to_path_buf())
158            .unwrap_or_else(|| PathBuf::from("."));
159        Ok(cfg)
160    }
161
162    pub fn load_lesson(&self, name: &str) -> anyhow::Result<Lesson> {
163        let lesson_ref = self
164            .lesson
165            .get(name)
166            .ok_or_else(|| anyhow::anyhow!("unknown lesson: {name}"))?;
167        let lesson_path = self.root_dir.join(&lesson_ref.extends);
168        Lesson::load(&lesson_path)
169    }
170
171    pub fn default_lesson_name(&self) -> &str {
172        self.vars
173            .get("default_lesson")
174            .and_then(|v| v.as_str())
175            .unwrap_or("elementary-low")
176    }
177
178    pub fn default_backend_name(&self) -> &str {
179        self.vars
180            .get("default_backend")
181            .and_then(|v| v.as_str())
182            .unwrap_or("claude")
183    }
184
185    /// The avatars directory.  Resolved relative to the current
186    /// working directory (where the server was launched), or used
187    /// as-is if absolute.  Intentionally NOT relative to the config
188    /// file — users expect `./avatars` in the TOML to mean
189    /// `<project_root>/avatars` when running from the project root.
190    pub fn avatars_dir(&self) -> PathBuf {
191        PathBuf::from(&self.avatars.dir)
192    }
193}
194
195/// Read a TOML file, run teravars's `[vars]` self-resolve, then
196/// render Tera placeholders against the resolved vars + system
197/// context. The result is plain TOML ready for `toml::from_str`.
198pub fn render_toml(path: &Path) -> anyhow::Result<String> {
199    let raw = std::fs::read_to_string(path)
200        .map_err(|e| anyhow::anyhow!("read {}: {e}", path.display()))?;
201    let mut engine = Engine::new();
202    let mut vars = extract_vars(&raw)?;
203    // Best-effort: if cross-refs don't converge, keep going with
204    // partially-resolved values rather than failing the whole load.
205    let _ = resolve(&mut vars, &mut engine);
206
207    let mut ctx: Context = system_context();
208    ctx.insert("vars", &vars);
209
210    let rendered = engine.render(&raw, &ctx)?;
211    Ok(rendered)
212}