1use std::path::{Path, PathBuf};
4
5use qualia_core_db::resource_catalog::ResourceCatalog;
6use serde::{Deserialize, Serialize};
7use sysinfo::System;
8
9use crate::model_lifecycle::{self, load_install_manifest, models_dir, ActiveModelRecord};
10
11const PREFS_FILE: &str = "model_preferences.json";
12
13#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Default)]
14#[serde(rename_all = "snake_case")]
15pub enum ModelTask {
16 #[default]
17 Always,
18 Chat,
19 Coding,
20 Vision,
21 LowRam,
22}
23
24impl ModelTask {
25 pub fn label(self) -> &'static str {
26 match self {
27 ModelTask::Always => "Whenever installed",
28 ModelTask::Chat => "General chat",
29 ModelTask::Coding => "Coding / reasoning",
30 ModelTask::Vision => "Vision / multimodal",
31 ModelTask::LowRam => "Low RAM only",
32 }
33 }
34
35 pub fn from_str_lossy(s: &str) -> Self {
36 match s.trim().to_lowercase().as_str() {
37 "chat" => ModelTask::Chat,
38 "coding" => ModelTask::Coding,
39 "vision" => ModelTask::Vision,
40 "low_ram" | "lowram" => ModelTask::LowRam,
41 _ => ModelTask::Always,
42 }
43 }
44
45 pub fn as_str(self) -> &'static str {
46 match self {
47 ModelTask::Always => "always",
48 ModelTask::Chat => "chat",
49 ModelTask::Coding => "coding",
50 ModelTask::Vision => "vision",
51 ModelTask::LowRam => "low_ram",
52 }
53 }
54}
55
56#[derive(Debug, Clone, Serialize, Deserialize)]
57pub struct ModelLoadCondition {
58 #[serde(default = "default_true")]
59 pub require_installed: bool,
60 #[serde(default)]
61 pub task: ModelTask,
62 #[serde(default)]
63 pub min_ram_gb: Option<f64>,
64 #[serde(default = "default_true")]
65 pub respect_ram_estimate: bool,
66 #[serde(default)]
67 pub require_multimodal: bool,
68}
69
70impl Default for ModelLoadCondition {
71 fn default() -> Self {
72 Self {
73 require_installed: true,
74 task: ModelTask::Always,
75 min_ram_gb: None,
76 respect_ram_estimate: true,
77 require_multimodal: false,
78 }
79 }
80}
81
82fn default_true() -> bool {
83 true
84}
85
86#[derive(Debug, Clone, Serialize, Deserialize)]
87pub struct ModelPreferenceEntry {
88 pub model_id: String,
89 pub label: String,
90 pub priority: u32,
91 #[serde(default)]
92 pub when: ModelLoadCondition,
93}
94
95#[derive(Debug, Clone, Serialize, Deserialize)]
96pub struct ModelPreferences {
97 #[serde(default)]
98 pub auto_select: bool,
99 #[serde(default)]
100 pub entries: Vec<ModelPreferenceEntry>,
101}
102
103impl Default for ModelPreferences {
104 fn default() -> Self {
105 Self {
106 auto_select: true,
107 entries: vec![],
108 }
109 }
110}
111
112#[derive(Debug, Clone, Serialize)]
113pub struct ResolvedModelPreference {
114 pub model_id: String,
115 pub label: String,
116 pub reason: String,
117 pub gguf_path: String,
118 pub priority: u32,
119 pub task: String,
120}
121
122pub fn preferences_path(storage_root: &Path) -> PathBuf {
123 storage_root.join("Meta").join(PREFS_FILE)
124}
125
126pub fn load_preferences(storage_root: &Path) -> ModelPreferences {
127 let path = preferences_path(storage_root);
128 let Ok(text) = std::fs::read_to_string(&path) else {
129 return ModelPreferences::default();
130 };
131 serde_json::from_str(&text).unwrap_or_default()
132}
133
134pub fn save_preferences(storage_root: &Path, prefs: &ModelPreferences) -> Result<(), String> {
135 let path = preferences_path(storage_root);
136 if let Some(parent) = path.parent() {
137 std::fs::create_dir_all(parent).map_err(|e| e.to_string())?;
138 }
139 let json = serde_json::to_string_pretty(prefs).map_err(|e| e.to_string())?;
140 std::fs::write(path, json).map_err(|e| e.to_string())
141}
142
143pub fn list_installed_model_ids(storage_root: &Path) -> Vec<String> {
144 let models = models_dir(storage_root);
145 let Ok(entries) = std::fs::read_dir(&models) else {
146 return vec![];
147 };
148 let mut ids = Vec::new();
149 for entry in entries.filter_map(Result::ok) {
150 let path = entry.path();
151 let Some(name) = path.file_name().and_then(|n| n.to_str()) else {
152 continue;
153 };
154 if !name.ends_with(".install.json") {
155 continue;
156 }
157 let model_id = name.trim_end_matches(".install.json");
158 if load_install_manifest(storage_root, model_id).is_some() {
159 ids.push(model_id.to_string());
160 }
161 }
162 ids.sort();
163 ids
164}
165
166fn system_ram_gb() -> f64 {
167 let mut sys = System::new_all();
168 sys.refresh_memory();
169 sys.total_memory() as f64 / 1024.0 / 1024.0 / 1024.0
170}
171
172fn catalog_ram_mb(catalog: &ResourceCatalog, model_id: &str) -> Option<u32> {
173 catalog
174 .find_llm(model_id)
175 .and_then(|m| m.ram_estimate_mb.or(m.size_mb))
176}
177
178fn catalog_is_multimodal(catalog: &ResourceCatalog, model_id: &str) -> bool {
179 catalog
180 .find_llm(model_id)
181 .map(|m| m.is_multimodal())
182 .unwrap_or(false)
183}
184
185fn task_matches(
186 condition_task: ModelTask,
187 request_task: ModelTask,
188 catalog: &ResourceCatalog,
189 model_id: &str,
190) -> bool {
191 if condition_task == ModelTask::Always {
192 return true;
193 }
194 if condition_task != request_task && request_task != ModelTask::Always {
195 return false;
196 }
197 let Some(llm) = catalog.find_llm(model_id) else {
198 return condition_task == request_task;
199 };
200 match condition_task {
201 ModelTask::Coding => {
202 llm.tags
203 .as_ref()
204 .map(|t| t.iter().any(|tag| tag == "coding" || tag == "reasoning"))
205 .unwrap_or(false)
206 || llm
207 .recommended_for
208 .as_ref()
209 .map(|r| r.iter().any(|x| x == "coding"))
210 .unwrap_or(false)
211 }
212 ModelTask::Vision => catalog_is_multimodal(catalog, model_id),
213 ModelTask::LowRam => {
214 let ram = catalog_ram_mb(catalog, model_id).unwrap_or(u32::MAX);
215 ram <= 2000
216 || llm
217 .recommended_for
218 .as_ref()
219 .map(|r| {
220 r.iter()
221 .any(|x| x == "low_ram" || x == "very_low_ram" || x == "edge")
222 })
223 .unwrap_or(false)
224 }
225 ModelTask::Chat => true,
226 ModelTask::Always => true,
227 }
228}
229
230fn condition_passes(
231 entry: &ModelPreferenceEntry,
232 request_task: ModelTask,
233 ram_gb: f64,
234 catalog: &ResourceCatalog,
235 storage_root: &Path,
236) -> Option<String> {
237 let manifest = load_install_manifest(storage_root, &entry.model_id)?;
238 if entry.when.require_installed && !Path::new(&manifest.gguf_path).is_file() {
239 return None;
240 }
241 if entry.when.require_multimodal && manifest.modality != "multimodal" {
242 return None;
243 }
244 if let Some(min) = entry.when.min_ram_gb {
245 if ram_gb < min {
246 return None;
247 }
248 }
249 if entry.when.respect_ram_estimate {
250 if let Some(need_mb) = catalog_ram_mb(catalog, &entry.model_id) {
251 let need_gb = need_mb as f64 / 1024.0;
252 if ram_gb < need_gb * 0.85 {
253 return None;
254 }
255 }
256 }
257 if !task_matches(entry.when.task, request_task, catalog, &entry.model_id) {
258 return None;
259 }
260 let reason = format!(
261 "Priority #{} — {} ({})",
262 entry.priority,
263 entry.label,
264 entry.when.task.label()
265 );
266 Some(reason)
267}
268
269pub fn resolve_preference(
270 storage_root: &Path,
271 catalog: &ResourceCatalog,
272 prefs: &ModelPreferences,
273 request_task: ModelTask,
274) -> Option<ResolvedModelPreference> {
275 if prefs.entries.is_empty() {
276 return None;
277 }
278 let ram_gb = system_ram_gb();
279 let mut entries: Vec<_> = prefs.entries.iter().collect();
280 entries.sort_by_key(|e| e.priority);
281
282 for entry in entries {
283 let Some(reason) = condition_passes(entry, request_task, ram_gb, catalog, storage_root)
284 else {
285 continue;
286 };
287 let manifest = load_install_manifest(storage_root, &entry.model_id)?;
288 return Some(ResolvedModelPreference {
289 model_id: entry.model_id.clone(),
290 label: entry.label.clone(),
291 reason,
292 gguf_path: manifest.gguf_path,
293 priority: entry.priority,
294 task: entry.when.task.as_str().to_string(),
295 });
296 }
297 None
298}
299
300pub fn apply_preference(
301 storage_root: &Path,
302 catalog: &ResourceCatalog,
303 prefs: &ModelPreferences,
304 request_task: ModelTask,
305) -> Result<ActiveModelRecord, String> {
306 let resolved = resolve_preference(storage_root, catalog, prefs, request_task)
307 .ok_or_else(|| "No installed model matches your priority rules".to_string())?;
308 model_lifecycle::activate_model_for_id(&resolved.model_id, storage_root)
309 .map_err(|e| e.to_string())
310}
311
312pub fn default_preferences_from_catalog(catalog: &ResourceCatalog) -> ModelPreferences {
313 let mut llms: Vec<_> = catalog.llms.iter().collect();
314 llms.sort_by_key(|m| m.ram_estimate_mb.or(m.size_mb).unwrap_or(u32::MAX));
315
316 let entries: Vec<ModelPreferenceEntry> = llms
317 .iter()
318 .take(6)
319 .enumerate()
320 .map(|(i, m)| {
321 let task = if m.is_multimodal() {
322 ModelTask::Vision
323 } else if m
324 .tags
325 .as_ref()
326 .map(|t| t.iter().any(|tag| tag == "coding"))
327 .unwrap_or(false)
328 {
329 ModelTask::Coding
330 } else if m
331 .recommended_for
332 .as_ref()
333 .map(|r| r.iter().any(|x| x == "edge" || x == "low_ram"))
334 .unwrap_or(false)
335 {
336 ModelTask::LowRam
337 } else {
338 ModelTask::Chat
339 };
340 ModelPreferenceEntry {
341 model_id: m.id.clone(),
342 label: m.name.clone(),
343 priority: (i as u32) + 1,
344 when: ModelLoadCondition {
345 task,
346 ..ModelLoadCondition::default()
347 },
348 }
349 })
350 .collect();
351
352 ModelPreferences {
353 auto_select: true,
354 entries,
355 }
356}
357
358pub fn ensure_preferences(storage_root: &Path, catalog: &ResourceCatalog) -> ModelPreferences {
359 let mut prefs = load_preferences(storage_root);
360 if prefs.entries.is_empty() && !catalog.llms.is_empty() {
361 prefs = default_preferences_from_catalog(catalog);
362 let _ = save_preferences(storage_root, &prefs);
363 }
364 prefs
365}
366
367#[cfg(test)]
368mod tests {
369 use super::*;
370 use qualia_core_db::resource_catalog::ResourceCatalog;
371
372 #[test]
373 fn default_preferences_sorted_by_ram() {
374 let catalog = qualia_core_db::resource_catalog::load_default()
375 .unwrap_or_else(|_| ResourceCatalog::empty());
376 let prefs = default_preferences_from_catalog(&catalog);
377 assert!(!prefs.entries.is_empty());
378 assert!(prefs.auto_select);
379 for (i, entry) in prefs.entries.iter().enumerate() {
380 assert_eq!(entry.priority, (i as u32) + 1);
381 }
382 }
383
384 #[test]
385 fn task_from_str_maps_coding() {
386 assert_eq!(ModelTask::from_str_lossy("coding"), ModelTask::Coding);
387 assert_eq!(ModelTask::from_str_lossy("always"), ModelTask::Always);
388 }
389}