1use std::fs;
7use std::io::{self, Write};
8use std::path::{Path, PathBuf};
9
10use anyhow::{anyhow, bail, Result};
11use clap::{Args, Subcommand, ValueEnum};
12
13use crate::config::CliConfig;
14
15#[derive(Debug, Clone, Copy, PartialEq, Eq)]
21pub enum EmbeddingModel {
22 BgeSmall,
24 BgeBase,
26 Nomic,
28 GteLarge,
30}
31
32impl EmbeddingModel {
33 fn all() -> impl Iterator<Item = EmbeddingModel> {
34 [
35 EmbeddingModel::BgeSmall,
36 EmbeddingModel::BgeBase,
37 EmbeddingModel::Nomic,
38 EmbeddingModel::GteLarge,
39 ]
40 .into_iter()
41 }
42
43 fn display_name(&self) -> &'static str {
44 match self {
45 EmbeddingModel::BgeSmall => "BGE-small-en-v1.5",
46 EmbeddingModel::BgeBase => "BGE-base-en-v1.5",
47 EmbeddingModel::Nomic => "Nomic-embed-text-v1.5",
48 EmbeddingModel::GteLarge => "GTE-large-en-v1.5",
49 }
50 }
51
52 fn cli_name(&self) -> &'static str {
53 match self {
54 EmbeddingModel::BgeSmall => "bge-small",
55 EmbeddingModel::BgeBase => "bge-base",
56 EmbeddingModel::Nomic => "nomic",
57 EmbeddingModel::GteLarge => "gte-large",
58 }
59 }
60
61 fn dimensions(&self) -> usize {
62 match self {
63 EmbeddingModel::BgeSmall => 384,
64 EmbeddingModel::BgeBase => 768,
65 EmbeddingModel::Nomic => 768,
66 EmbeddingModel::GteLarge => 1024,
67 }
68 }
69
70 fn size_mb(&self) -> usize {
71 match self {
72 EmbeddingModel::BgeSmall => 33,
73 EmbeddingModel::BgeBase => 110,
74 EmbeddingModel::Nomic => 137,
75 EmbeddingModel::GteLarge => 327,
76 }
77 }
78
79 fn hf_repo(&self) -> &'static str {
80 match self {
81 EmbeddingModel::BgeSmall => "BAAI/bge-small-en-v1.5",
82 EmbeddingModel::BgeBase => "BAAI/bge-base-en-v1.5",
83 EmbeddingModel::Nomic => "nomic-ai/nomic-embed-text-v1.5",
84 EmbeddingModel::GteLarge => "thenlper/gte-large",
85 }
86 }
87
88 fn is_default(&self) -> bool {
89 matches!(self, EmbeddingModel::BgeSmall)
90 }
91}
92
93#[derive(Debug, Clone, Copy, PartialEq, Eq)]
99pub enum RerankerModel {
100 JinaTurbo,
102 JinaMultilingual,
104 BgeRerankerBase,
106 BgeRerankerV2M3,
108}
109
110impl RerankerModel {
111 fn all() -> impl Iterator<Item = RerankerModel> {
112 [
113 RerankerModel::JinaTurbo,
114 RerankerModel::JinaMultilingual,
115 RerankerModel::BgeRerankerBase,
116 RerankerModel::BgeRerankerV2M3,
117 ]
118 .into_iter()
119 }
120
121 fn display_name(&self) -> &'static str {
122 match self {
123 RerankerModel::JinaTurbo => "Jina-reranker-v1-turbo-en",
124 RerankerModel::JinaMultilingual => "Jina-reranker-v2-base-multilingual",
125 RerankerModel::BgeRerankerBase => "BGE-reranker-base",
126 RerankerModel::BgeRerankerV2M3 => "BGE-reranker-v2-m3",
127 }
128 }
129
130 fn cli_name(&self) -> &'static str {
131 match self {
132 RerankerModel::JinaTurbo => "jina-turbo",
133 RerankerModel::JinaMultilingual => "jina-multilingual",
134 RerankerModel::BgeRerankerBase => "bge-reranker-base",
135 RerankerModel::BgeRerankerV2M3 => "bge-reranker-v2-m3",
136 }
137 }
138
139 fn size_mb(&self) -> usize {
140 match self {
141 RerankerModel::JinaTurbo => 86,
142 RerankerModel::JinaMultilingual => 200,
143 RerankerModel::BgeRerankerBase => 200,
144 RerankerModel::BgeRerankerV2M3 => 400,
145 }
146 }
147
148 fn hf_repo(&self) -> &'static str {
149 match self {
150 RerankerModel::JinaTurbo => "jinaai/jina-reranker-v1-turbo-en",
151 RerankerModel::JinaMultilingual => "jinaai/jina-reranker-v2-base-multilingual",
152 RerankerModel::BgeRerankerBase => "BAAI/bge-reranker-base",
153 RerankerModel::BgeRerankerV2M3 => "rozgo/bge-reranker-v2-m3",
154 }
155 }
156
157 fn language(&self) -> &'static str {
158 match self {
159 RerankerModel::JinaTurbo => "English",
160 RerankerModel::JinaMultilingual => "Multilingual",
161 RerankerModel::BgeRerankerBase => "English/Chinese",
162 RerankerModel::BgeRerankerV2M3 => "Multilingual",
163 }
164 }
165
166 fn is_default(&self) -> bool {
167 matches!(self, RerankerModel::JinaTurbo)
168 }
169}
170
171#[derive(Debug, Clone, Copy, PartialEq, Eq, ValueEnum)]
177pub enum LlmModel {
178 #[value(name = "phi-3.5-mini")]
180 Phi35Mini,
181 #[value(name = "phi-3.5-mini-q8")]
183 Phi35MiniQ8,
184}
185
186impl LlmModel {
187 fn hf_repo(&self) -> &'static str {
189 match self {
190 LlmModel::Phi35Mini | LlmModel::Phi35MiniQ8 => "bartowski/Phi-3.5-mini-instruct-GGUF",
191 }
192 }
193
194 fn hf_filename(&self) -> &'static str {
196 match self {
197 LlmModel::Phi35Mini => "Phi-3.5-mini-instruct-Q4_K_M.gguf",
198 LlmModel::Phi35MiniQ8 => "Phi-3.5-mini-instruct-Q8_0.gguf",
199 }
200 }
201
202 fn expected_size_bytes(&self) -> u64 {
204 match self {
205 LlmModel::Phi35Mini => 2_360_000_000, LlmModel::Phi35MiniQ8 => 3_860_000_000, }
208 }
209
210 fn local_dir_name(&self) -> &'static str {
212 match self {
213 LlmModel::Phi35Mini => "phi-3.5-mini-q4",
214 LlmModel::Phi35MiniQ8 => "phi-3.5-mini-q8",
215 }
216 }
217
218 fn display_name(&self) -> &'static str {
220 match self {
221 LlmModel::Phi35Mini => "Phi-3.5 Mini Instruct (Q4_K_M)",
222 LlmModel::Phi35MiniQ8 => "Phi-3.5 Mini Instruct (Q8_0)",
223 }
224 }
225
226 fn cli_name(&self) -> &'static str {
227 match self {
228 LlmModel::Phi35Mini => "phi-3.5-mini",
229 LlmModel::Phi35MiniQ8 => "phi-3.5-mini-q8",
230 }
231 }
232
233 fn all() -> impl Iterator<Item = LlmModel> {
235 [LlmModel::Phi35Mini, LlmModel::Phi35MiniQ8].into_iter()
236 }
237
238 fn is_default(&self) -> bool {
239 matches!(self, LlmModel::Phi35Mini)
240 }
241}
242
243#[derive(Debug, Clone, Copy, PartialEq, Eq, ValueEnum)]
249pub enum ClipModel {
250 #[value(name = "mobileclip-s2")]
252 MobileClipS2,
253 #[value(name = "mobileclip-s2-fp16")]
255 MobileClipS2Fp16,
256 #[value(name = "siglip-base")]
258 SigLipBase,
259}
260
261impl ClipModel {
262 fn all() -> impl Iterator<Item = ClipModel> {
263 [
264 ClipModel::MobileClipS2,
265 ClipModel::MobileClipS2Fp16,
266 ClipModel::SigLipBase,
267 ]
268 .into_iter()
269 }
270
271 fn display_name(&self) -> &'static str {
272 match self {
273 ClipModel::MobileClipS2 => "MobileCLIP-S2 (int8 quantized)",
274 ClipModel::MobileClipS2Fp16 => "MobileCLIP-S2 (fp16)",
275 ClipModel::SigLipBase => "SigLIP-base (quantized)",
276 }
277 }
278
279 fn cli_name(&self) -> &'static str {
280 match self {
281 ClipModel::MobileClipS2 => "mobileclip-s2",
282 ClipModel::MobileClipS2Fp16 => "mobileclip-s2-fp16",
283 ClipModel::SigLipBase => "siglip-base",
284 }
285 }
286
287 fn dimensions(&self) -> usize {
288 match self {
289 ClipModel::MobileClipS2 | ClipModel::MobileClipS2Fp16 => 512,
290 ClipModel::SigLipBase => 768,
291 }
292 }
293
294 fn total_size_mb(&self) -> f32 {
295 match self {
296 ClipModel::MobileClipS2 => 100.8, ClipModel::MobileClipS2Fp16 => 198.7, ClipModel::SigLipBase => 210.5, }
300 }
301
302 fn vision_url(&self) -> &'static str {
303 match self {
304 ClipModel::MobileClipS2 => "https://huggingface.co/Xenova/mobileclip_s2/resolve/main/onnx/vision_model_int8.onnx",
305 ClipModel::MobileClipS2Fp16 => "https://huggingface.co/Xenova/mobileclip_s2/resolve/main/onnx/vision_model_fp16.onnx",
306 ClipModel::SigLipBase => "https://huggingface.co/Xenova/siglip-base-patch16-224/resolve/main/onnx/vision_model_quantized.onnx",
307 }
308 }
309
310 fn text_url(&self) -> &'static str {
311 match self {
312 ClipModel::MobileClipS2 => "https://huggingface.co/Xenova/mobileclip_s2/resolve/main/onnx/text_model_int8.onnx",
313 ClipModel::MobileClipS2Fp16 => "https://huggingface.co/Xenova/mobileclip_s2/resolve/main/onnx/text_model_fp16.onnx",
314 ClipModel::SigLipBase => "https://huggingface.co/Xenova/siglip-base-patch16-224/resolve/main/onnx/text_model_quantized.onnx",
315 }
316 }
317
318 fn vision_filename(&self) -> &'static str {
319 match self {
320 ClipModel::MobileClipS2 => "mobileclip-s2_vision.onnx",
321 ClipModel::MobileClipS2Fp16 => "mobileclip-s2-fp16_vision.onnx",
322 ClipModel::SigLipBase => "siglip-base_vision.onnx",
323 }
324 }
325
326 fn text_filename(&self) -> &'static str {
327 match self {
328 ClipModel::MobileClipS2 => "mobileclip-s2_text.onnx",
329 ClipModel::MobileClipS2Fp16 => "mobileclip-s2-fp16_text.onnx",
330 ClipModel::SigLipBase => "siglip-base_text.onnx",
331 }
332 }
333
334 fn is_default(&self) -> bool {
335 matches!(self, ClipModel::MobileClipS2)
336 }
337}
338
339#[derive(Debug, Clone, Copy, PartialEq, Eq, ValueEnum)]
345pub enum NerModel {
346 #[value(name = "distilbert-ner")]
348 DistilbertNer,
349}
350
351impl NerModel {
352 fn all() -> impl Iterator<Item = NerModel> {
353 [NerModel::DistilbertNer].into_iter()
354 }
355
356 fn display_name(&self) -> &'static str {
357 match self {
358 NerModel::DistilbertNer => "DistilBERT-NER (dslim)",
359 }
360 }
361
362 fn cli_name(&self) -> &'static str {
363 match self {
364 NerModel::DistilbertNer => "distilbert-ner",
365 }
366 }
367
368 fn size_mb(&self) -> f32 {
369 match self {
370 NerModel::DistilbertNer => 261.0,
371 }
372 }
373
374 fn model_url(&self) -> &'static str {
375 match self {
376 NerModel::DistilbertNer => "https://huggingface.co/dslim/distilbert-NER/resolve/main/onnx/model.onnx",
377 }
378 }
379
380 fn tokenizer_url(&self) -> &'static str {
381 match self {
382 NerModel::DistilbertNer => "https://huggingface.co/dslim/distilbert-NER/resolve/main/tokenizer.json",
383 }
384 }
385
386 fn local_dir_name(&self) -> &'static str {
387 match self {
388 NerModel::DistilbertNer => "distilbert-ner",
389 }
390 }
391
392 fn is_default(&self) -> bool {
393 matches!(self, NerModel::DistilbertNer)
394 }
395}
396
397struct ExternalEmbeddingProvider {
403 name: &'static str,
404 models: &'static [(&'static str, usize, &'static str)], env_var: &'static str,
406}
407
408const EXTERNAL_EMBEDDING_PROVIDERS: &[ExternalEmbeddingProvider] = &[
409 ExternalEmbeddingProvider {
410 name: "OpenAI",
411 models: &[
412 ("text-embedding-3-large", 3072, "Highest quality"),
413 ("text-embedding-3-small", 1536, "Good balance"),
414 ("text-embedding-ada-002", 1536, "Legacy"),
415 ],
416 env_var: "OPENAI_API_KEY",
417 },
418 ExternalEmbeddingProvider {
419 name: "Cohere",
420 models: &[
421 ("embed-english-v3.0", 1024, "English"),
422 ("embed-multilingual-v3.0", 1024, "Multilingual"),
423 ],
424 env_var: "COHERE_API_KEY",
425 },
426 ExternalEmbeddingProvider {
427 name: "Voyage",
428 models: &[
429 ("voyage-3", 1024, "Code & technical docs"),
430 ("voyage-3-lite", 512, "Lightweight"),
431 ],
432 env_var: "VOYAGE_API_KEY",
433 },
434];
435
436#[derive(Args)]
442pub struct ModelsArgs {
443 #[command(subcommand)]
444 pub command: ModelsCommand,
445}
446
447#[derive(Subcommand)]
448pub enum ModelsCommand {
449 Install(ModelsInstallArgs),
451 List(ModelsListArgs),
453 Remove(ModelsRemoveArgs),
455 Verify(ModelsVerifyArgs),
457}
458
459#[derive(Args)]
460pub struct ModelsInstallArgs {
461 #[arg(value_enum, group = "model_choice")]
463 pub model: Option<LlmModel>,
464
465 #[arg(long, value_enum, group = "model_choice")]
467 pub clip: Option<ClipModel>,
468
469 #[arg(long, value_enum, group = "model_choice")]
471 pub ner: Option<NerModel>,
472
473 #[arg(long, short)]
475 pub force: bool,
476}
477
478#[derive(Args)]
479pub struct ModelsListArgs {
480 #[arg(long)]
482 pub json: bool,
483
484 #[arg(long, value_enum)]
486 pub model_type: Option<ModelType>,
487}
488
489#[derive(Debug, Clone, Copy, ValueEnum)]
490pub enum ModelType {
491 Embedding,
493 Reranker,
495 Llm,
497 Clip,
499 Ner,
501 External,
503}
504
505#[derive(Args)]
506pub struct ModelsRemoveArgs {
507 #[arg(value_enum)]
509 pub model: LlmModel,
510
511 #[arg(long, short)]
513 pub yes: bool,
514}
515
516#[derive(Args)]
517pub struct ModelsVerifyArgs {
518 #[arg(value_enum)]
520 pub model: Option<LlmModel>,
521}
522
523#[derive(Debug, Clone, serde::Serialize)]
525pub struct InstalledModel {
526 pub name: String,
527 pub model_type: String,
528 pub path: PathBuf,
529 pub size_bytes: u64,
530 pub verified: bool,
531}
532
533fn llm_models_dir(config: &CliConfig) -> PathBuf {
539 config.models_dir.join("llm")
540}
541
542fn fastembed_cache_dir(config: &CliConfig) -> PathBuf {
544 config.models_dir.clone()
545}
546
547fn llm_model_path(config: &CliConfig, model: LlmModel) -> PathBuf {
549 llm_models_dir(config)
550 .join(model.local_dir_name())
551 .join(model.hf_filename())
552}
553
554fn is_llm_model_installed(config: &CliConfig, model: LlmModel) -> bool {
556 let path = llm_model_path(config, model);
557 path.exists() && path.is_file()
558}
559
560fn clip_models_dir(config: &CliConfig) -> PathBuf {
562 config.models_dir.clone()
563}
564
565fn clip_vision_path(config: &CliConfig, model: ClipModel) -> PathBuf {
567 clip_models_dir(config).join(model.vision_filename())
568}
569
570fn clip_text_path(config: &CliConfig, model: ClipModel) -> PathBuf {
572 clip_models_dir(config).join(model.text_filename())
573}
574
575fn is_clip_model_installed(config: &CliConfig, model: ClipModel) -> bool {
577 let vision_path = clip_vision_path(config, model);
578 let text_path = clip_text_path(config, model);
579 vision_path.exists() && vision_path.is_file() && text_path.exists() && text_path.is_file()
580}
581
582fn clip_model_status(config: &CliConfig, model: ClipModel) -> (&'static str, bool, bool) {
584 let has_vision = clip_vision_path(config, model).exists();
585 let has_text = clip_text_path(config, model).exists();
586 let status = match (has_vision, has_text) {
587 (true, true) => "✓ installed",
588 (true, false) => "⚠ partial (missing text)",
589 (false, true) => "⚠ partial (missing vision)",
590 (false, false) => "○ available",
591 };
592 (status, has_vision, has_text)
593}
594
595fn ner_models_dir(config: &CliConfig) -> PathBuf {
597 config.models_dir.clone()
598}
599
600fn ner_model_path(config: &CliConfig, model: NerModel) -> PathBuf {
602 ner_models_dir(config)
603 .join(model.local_dir_name())
604 .join("model.onnx")
605}
606
607fn ner_tokenizer_path(config: &CliConfig, model: NerModel) -> PathBuf {
609 ner_models_dir(config)
610 .join(model.local_dir_name())
611 .join("tokenizer.json")
612}
613
614fn is_ner_model_installed(config: &CliConfig, model: NerModel) -> bool {
616 let model_path = ner_model_path(config, model);
617 let tokenizer_path = ner_tokenizer_path(config, model);
618 model_path.exists() && model_path.is_file() && tokenizer_path.exists() && tokenizer_path.is_file()
619}
620
621fn ner_model_status(config: &CliConfig, model: NerModel) -> (&'static str, bool, bool) {
623 let has_model = ner_model_path(config, model).exists();
624 let has_tokenizer = ner_tokenizer_path(config, model).exists();
625 let status = match (has_model, has_tokenizer) {
626 (true, true) => "✓ installed",
627 (true, false) => "⚠ partial (missing tokenizer)",
628 (false, true) => "⚠ partial (missing model)",
629 (false, false) => "○ available",
630 };
631 (status, has_model, has_tokenizer)
632}
633
634fn scan_fastembed_cache(config: &CliConfig) -> Vec<(String, PathBuf, u64)> {
636 let cache_dir = fastembed_cache_dir(config);
637 let mut installed = Vec::new();
638
639 if let Ok(entries) = fs::read_dir(&cache_dir) {
640 for entry in entries.flatten() {
641 let path = entry.path();
642 if path.is_dir() {
643 let name = path.file_name().unwrap_or_default().to_string_lossy();
644 if name.starts_with("models--") {
646 let size = dir_size(&path).unwrap_or(0);
647 let model_name = name.replace("models--", "").replace("--", "/");
648 installed.push((model_name, path, size));
649 }
650 }
651 }
652 }
653
654 installed
655}
656
657fn dir_size(path: &Path) -> io::Result<u64> {
659 let mut size = 0;
660 if path.is_dir() {
661 for entry in fs::read_dir(path)? {
662 let entry = entry?;
663 let path = entry.path();
664 if path.is_dir() {
665 size += dir_size(&path)?;
666 } else {
667 size += entry.metadata()?.len();
668 }
669 }
670 }
671 Ok(size)
672}
673
674pub fn handle_models(config: &CliConfig, args: ModelsArgs) -> Result<()> {
680 match args.command {
681 ModelsCommand::Install(install_args) => handle_models_install(config, install_args),
682 ModelsCommand::List(list_args) => handle_models_list(config, list_args),
683 ModelsCommand::Remove(remove_args) => handle_models_remove(config, remove_args),
684 ModelsCommand::Verify(verify_args) => handle_models_verify(config, verify_args),
685 }
686}
687
688pub fn handle_models_install(config: &CliConfig, args: ModelsInstallArgs) -> Result<()> {
690 if let Some(clip_model) = args.clip {
692 return handle_clip_install(config, clip_model, args.force);
693 }
694
695 if let Some(ner_model) = args.ner {
697 return handle_ner_install(config, ner_model, args.force);
698 }
699
700 if let Some(llm_model) = args.model {
702 return handle_llm_install(config, llm_model, args.force);
703 }
704
705 bail!(
707 "Please specify a model to install:\n\
708 \n\
709 LLM models (for local inference):\n\
710 \x20 memvid models install phi-3.5-mini\n\
711 \x20 memvid models install phi-3.5-mini-q8\n\
712 \n\
713 CLIP models (for visual search):\n\
714 \x20 memvid models install --clip mobileclip-s2\n\
715 \x20 memvid models install --clip mobileclip-s2-fp16\n\
716 \x20 memvid models install --clip siglip-base\n\
717 \n\
718 NER models (for Logic-Mesh entity extraction):\n\
719 \x20 memvid models install --ner distilbert-ner"
720 );
721}
722
723fn handle_clip_install(config: &CliConfig, model: ClipModel, force: bool) -> Result<()> {
725 let vision_path = clip_vision_path(config, model);
726 let text_path = clip_text_path(config, model);
727
728 if is_clip_model_installed(config, model) && !force {
729 println!(
730 "{} is already installed at {}",
731 model.display_name(),
732 clip_models_dir(config).display()
733 );
734 println!("Use --force to re-download.");
735 return Ok(());
736 }
737
738 if config.offline {
739 bail!(
740 "Cannot install models while offline (MEMVID_OFFLINE=1). \
741 Run without MEMVID_OFFLINE to download the model."
742 );
743 }
744
745 fs::create_dir_all(clip_models_dir(config))?;
747
748 println!("Installing {}...", model.display_name());
749 println!("Dimensions: {}", model.dimensions());
750 println!("Total size: {:.1} MB", model.total_size_mb());
751 println!();
752
753 println!("Downloading vision encoder...");
755 download_file(model.vision_url(), &vision_path)?;
756
757 println!();
759 println!("Downloading text encoder...");
760 download_file(model.text_url(), &text_path)?;
761
762 let vision_size = fs::metadata(&vision_path).map(|m| m.len()).unwrap_or(0);
764 let text_size = fs::metadata(&text_path).map(|m| m.len()).unwrap_or(0);
765 let total_size = vision_size + text_size;
766
767 println!();
768 println!(
769 "Successfully installed {} ({:.1} MB)",
770 model.display_name(),
771 total_size as f64 / 1_000_000.0
772 );
773 println!("Vision encoder: {}", vision_path.display());
774 println!("Text encoder: {}", text_path.display());
775 println!();
776 println!("Usage:");
777 println!(" memvid put photos.mv2 --input ./images/ --clip");
778 println!(" memvid find photos.mv2 --query \"sunset over ocean\" --mode clip");
779
780 Ok(())
781}
782
783fn handle_ner_install(config: &CliConfig, model: NerModel, force: bool) -> Result<()> {
785 let model_path = ner_model_path(config, model);
786 let tokenizer_path = ner_tokenizer_path(config, model);
787
788 if is_ner_model_installed(config, model) && !force {
789 println!(
790 "{} is already installed at {}",
791 model.display_name(),
792 model_path.parent().unwrap_or(&model_path).display()
793 );
794 println!("Use --force to re-download.");
795 return Ok(());
796 }
797
798 if config.offline {
799 bail!(
800 "Cannot install models while offline (MEMVID_OFFLINE=1). \
801 Run without MEMVID_OFFLINE to download the model."
802 );
803 }
804
805 if let Some(parent) = model_path.parent() {
807 fs::create_dir_all(parent)?;
808 }
809
810 println!("Installing {}...", model.display_name());
811 println!("Size: {:.1} MB", model.size_mb());
812 println!();
813
814 println!("Downloading model...");
816 download_file(model.model_url(), &model_path)?;
817
818 println!();
820 println!("Downloading tokenizer...");
821 download_file(model.tokenizer_url(), &tokenizer_path)?;
822
823 let model_size = fs::metadata(&model_path).map(|m| m.len()).unwrap_or(0);
825 let tokenizer_size = fs::metadata(&tokenizer_path).map(|m| m.len()).unwrap_or(0);
826 let total_size = model_size + tokenizer_size;
827
828 println!();
829 println!(
830 "Successfully installed {} ({:.1} MB)",
831 model.display_name(),
832 total_size as f64 / 1_000_000.0
833 );
834 println!("Model: {}", model_path.display());
835 println!("Tokenizer: {}", tokenizer_path.display());
836 println!();
837 println!("Usage:");
838 println!(" memvid enrich file.mv2 --logic-mesh");
839 println!(" memvid follow traverse file.mv2 --start \"John\" --link manager");
840
841 Ok(())
842}
843
844fn handle_llm_install(config: &CliConfig, model: LlmModel, force: bool) -> Result<()> {
846 let target_path = llm_model_path(config, model);
847
848 if is_llm_model_installed(config, model) && !force {
849 println!(
850 "{} is already installed at {}",
851 model.display_name(),
852 target_path.display()
853 );
854 println!("Use --force to re-download.");
855 return Ok(());
856 }
857
858 if config.offline {
859 bail!(
860 "Cannot install models while offline (MEMVID_OFFLINE=1). \
861 Run without MEMVID_OFFLINE to download the model."
862 );
863 }
864
865 if let Some(parent) = target_path.parent() {
867 fs::create_dir_all(parent)?;
868 }
869
870 println!("Installing {}...", model.display_name());
871 println!("Repository: {}", model.hf_repo());
872 println!("File: {}", model.hf_filename());
873 println!(
874 "Expected size: {:.1} GB",
875 model.expected_size_bytes() as f64 / 1_000_000_000.0
876 );
877 println!();
878
879 download_llm_model(model, &target_path)?;
881
882 let metadata = fs::metadata(&target_path)?;
884 let size = metadata.len();
885
886 let min_size = (model.expected_size_bytes() as f64 * 0.9) as u64;
888 let max_size = (model.expected_size_bytes() as f64 * 1.1) as u64;
889
890 if size < min_size || size > max_size {
891 eprintln!(
892 "Warning: Downloaded file size ({:.2} GB) differs significantly from expected ({:.2} GB)",
893 size as f64 / 1_000_000_000.0,
894 model.expected_size_bytes() as f64 / 1_000_000_000.0
895 );
896 }
897
898 println!();
899 println!(
900 "Successfully installed {} ({:.2} GB)",
901 model.display_name(),
902 size as f64 / 1_000_000_000.0
903 );
904 println!("Location: {}", target_path.display());
905
906 Ok(())
907}
908
909fn download_file(url: &str, target_path: &Path) -> Result<()> {
911 println!("URL: {}", url);
912
913 let status = std::process::Command::new("curl")
914 .args([
915 "-L", "--progress-bar", "-o",
918 target_path
919 .to_str()
920 .ok_or_else(|| anyhow!("Invalid target path"))?,
921 url,
922 ])
923 .status()?;
924
925 if !status.success() {
926 let _ = fs::remove_file(target_path);
928 bail!("Download failed. Please check your internet connection and try again.");
929 }
930
931 Ok(())
932}
933
934fn download_llm_model(model: LlmModel, target_path: &Path) -> Result<()> {
936 let url = format!(
937 "https://huggingface.co/{}/resolve/main/{}",
938 model.hf_repo(),
939 model.hf_filename()
940 );
941
942 println!("Downloading from Hugging Face...");
943 download_file(&url, target_path)
944}
945
946pub fn handle_models_list(config: &CliConfig, args: ModelsListArgs) -> Result<()> {
948 let fastembed_installed = scan_fastembed_cache(config);
949
950 if args.json {
951 return handle_models_list_json(config, &fastembed_installed);
952 }
953
954 let show_all = args.model_type.is_none();
956 let show_embedding = show_all || matches!(args.model_type, Some(ModelType::Embedding));
957 let show_reranker = show_all || matches!(args.model_type, Some(ModelType::Reranker));
958 let show_llm = show_all || matches!(args.model_type, Some(ModelType::Llm));
959 let show_clip = show_all || matches!(args.model_type, Some(ModelType::Clip));
960 let show_ner = show_all || matches!(args.model_type, Some(ModelType::Ner));
961 let show_external = show_all || matches!(args.model_type, Some(ModelType::External));
962
963 println!("╔══════════════════════════════════════════════════════════════════╗");
964 println!("║ MEMVID MODEL CATALOG ║");
965 println!("╚══════════════════════════════════════════════════════════════════╝");
966 println!();
967
968 println!("Models Directory: {}", config.models_dir.display());
970 println!();
971
972 if show_embedding {
976 println!("┌──────────────────────────────────────────────────────────────────┐");
977 println!("│ 📊 EMBEDDING MODELS (Semantic Search) │");
978 println!("├──────────────────────────────────────────────────────────────────┤");
979
980 for model in EmbeddingModel::all() {
981 let is_installed = fastembed_installed.iter().any(|(name, _, _)| {
982 name.contains(&model.hf_repo().replace("/", "--").replace("--", "/"))
983 });
984
985 let status = if is_installed {
986 "✓ installed"
987 } else {
988 "○ available"
989 };
990 let default_marker = if model.is_default() { " (default)" } else { "" };
991
992 println!(
993 "│ {:20} {:4}D {:>4} MB {:15}{}",
994 model.cli_name(),
995 model.dimensions(),
996 model.size_mb(),
997 status,
998 default_marker
999 );
1000 }
1001
1002 println!("│ │");
1003 println!("│ Usage: memvid put file.mv2 --input doc.pdf --embedding │");
1004 println!("│ memvid put file.mv2 --input doc.pdf --embedding-model nomic│");
1005 println!("└──────────────────────────────────────────────────────────────────┘");
1006 println!();
1007 }
1008
1009 if show_reranker {
1013 println!("┌──────────────────────────────────────────────────────────────────┐");
1014 println!("│ 🔄 RERANKER MODELS (Result Reranking) │");
1015 println!("├──────────────────────────────────────────────────────────────────┤");
1016
1017 for model in RerankerModel::all() {
1018 let is_installed = fastembed_installed.iter().any(|(name, _, _)| {
1019 let repo = model.hf_repo();
1020 name.to_lowercase()
1021 .contains(&repo.to_lowercase().replace("/", "--").replace("--", "/"))
1022 || name
1023 .to_lowercase()
1024 .contains(&repo.split('/').last().unwrap_or("").to_lowercase())
1025 });
1026
1027 let status = if is_installed {
1028 "✓ installed"
1029 } else {
1030 "○ available"
1031 };
1032 let default_marker = if model.is_default() { " (default)" } else { "" };
1033
1034 println!(
1035 "│ {:25} {:>4} MB {:12} {:12}{}",
1036 model.cli_name(),
1037 model.size_mb(),
1038 model.language(),
1039 status,
1040 default_marker
1041 );
1042 }
1043
1044 println!("│ │");
1045 println!("│ Reranking is automatic in hybrid search mode (--mode auto) │");
1046 println!("└──────────────────────────────────────────────────────────────────┘");
1047 println!();
1048 }
1049
1050 if show_llm {
1054 println!("┌──────────────────────────────────────────────────────────────────┐");
1055 println!("│ 🤖 LLM MODELS (Local Inference) │");
1056 println!("├──────────────────────────────────────────────────────────────────┤");
1057
1058 for model in LlmModel::all() {
1059 let is_installed = is_llm_model_installed(config, model);
1060 let status = if is_installed {
1061 "✓ installed"
1062 } else {
1063 "○ available"
1064 };
1065 let default_marker = if model.is_default() { " (default)" } else { "" };
1066
1067 println!(
1068 "│ {:20} {:>5.1} GB {:15}{}",
1069 model.cli_name(),
1070 model.expected_size_bytes() as f64 / 1_000_000_000.0,
1071 status,
1072 default_marker
1073 );
1074
1075 if is_installed {
1076 println!("│ Path: {}", llm_model_path(config, model).display());
1077 }
1078 }
1079
1080 println!("│ │");
1081 println!("│ Install: memvid models install phi-3.5-mini │");
1082 println!("│ Usage: memvid ask file.mv2 --question \"...\" --model candle:phi │");
1083 println!("└──────────────────────────────────────────────────────────────────┘");
1084 println!();
1085 }
1086
1087 if show_clip {
1091 println!("┌──────────────────────────────────────────────────────────────────┐");
1092 println!("│ 🖼️ CLIP MODELS (Visual Search) │");
1093 println!("├──────────────────────────────────────────────────────────────────┤");
1094
1095 for model in ClipModel::all() {
1096 let (status, _, _) = clip_model_status(config, model);
1097 let default_marker = if model.is_default() { " (default)" } else { "" };
1098
1099 println!(
1100 "│ {:20} {:4}D {:>6.1} MB {:15}{}",
1101 model.cli_name(),
1102 model.dimensions(),
1103 model.total_size_mb(),
1104 status,
1105 default_marker
1106 );
1107 }
1108
1109 println!("│ │");
1110 println!("│ Install: memvid models install --clip mobileclip-s2 │");
1111 println!("│ Usage: memvid put photos.mv2 --input ./images/ --clip │");
1112 println!("│ memvid find photos.mv2 --query \"sunset\" --mode clip │");
1113 println!("└──────────────────────────────────────────────────────────────────┘");
1114 println!();
1115 }
1116
1117 if show_ner {
1121 println!("┌──────────────────────────────────────────────────────────────────┐");
1122 println!("│ 🔗 NER MODELS (Logic-Mesh Entity Extraction) │");
1123 println!("├──────────────────────────────────────────────────────────────────┤");
1124
1125 for model in NerModel::all() {
1126 let (status, _, _) = ner_model_status(config, model);
1127 let default_marker = if model.is_default() { " (default)" } else { "" };
1128
1129 println!(
1130 "│ {:20} {:>6.1} MB {:15}{}",
1131 model.cli_name(),
1132 model.size_mb(),
1133 status,
1134 default_marker
1135 );
1136 }
1137
1138 println!("│ │");
1139 println!("│ Install: memvid models install --ner distilbert-ner │");
1140 println!("│ Usage: memvid put file.mv2 --input doc.txt --logic-mesh │");
1141 println!("│ memvid follow traverse file.mv2 --start \"John\" │");
1142 println!("└──────────────────────────────────────────────────────────────────┘");
1143 println!();
1144 }
1145
1146 if show_external {
1150 println!("┌──────────────────────────────────────────────────────────────────┐");
1151 println!("│ ☁️ EXTERNAL MODELS (API-based, no download required) │");
1152 println!("├──────────────────────────────────────────────────────────────────┤");
1153
1154 for provider in EXTERNAL_EMBEDDING_PROVIDERS {
1155 let api_key_set = std::env::var(provider.env_var).is_ok();
1156 let key_status = if api_key_set {
1157 format!("{} ✓", provider.env_var)
1158 } else {
1159 format!("{} ○", provider.env_var)
1160 };
1161
1162 println!("│ {} ({}):", provider.name, key_status);
1163
1164 for (model_name, dim, desc) in provider.models.iter() {
1165 println!("│ {:30} {:4}D {}", model_name, dim, desc);
1166 }
1167 println!("│");
1168 }
1169
1170 println!("│ Usage: export OPENAI_API_KEY=sk-... │");
1171 println!("│ memvid put file.mv2 --input doc.pdf --embedding-model openai│");
1172 println!("└──────────────────────────────────────────────────────────────────┘");
1173 println!();
1174 }
1175
1176 if !fastembed_installed.is_empty() {
1180 println!("┌──────────────────────────────────────────────────────────────────┐");
1181 println!(
1182 "│ 📦 INSTALLED MODELS (cached in {}) │",
1183 config.models_dir.display()
1184 );
1185 println!("├──────────────────────────────────────────────────────────────────┤");
1186
1187 let mut total_size: u64 = 0;
1188
1189 for (name, _path, size) in &fastembed_installed {
1190 total_size += size;
1191 println!(
1192 "│ {:40} {:>8.1} MB",
1193 if name.len() > 40 {
1194 format!("{}...", &name[..37])
1195 } else {
1196 name.clone()
1197 },
1198 *size as f64 / 1_000_000.0
1199 );
1200 }
1201
1202 for model in LlmModel::all() {
1204 if is_llm_model_installed(config, model) {
1205 let path = llm_model_path(config, model);
1206 if let Ok(meta) = fs::metadata(&path) {
1207 total_size += meta.len();
1208 println!(
1209 "│ {:40} {:>8.1} MB",
1210 model.display_name(),
1211 meta.len() as f64 / 1_000_000.0
1212 );
1213 }
1214 }
1215 }
1216
1217 println!("├──────────────────────────────────────────────────────────────────┤");
1218 println!("│ Total: {:>55.1} MB │", total_size as f64 / 1_000_000.0);
1219 println!("└──────────────────────────────────────────────────────────────────┘");
1220 println!();
1221 }
1222
1223 println!("╔══════════════════════════════════════════════════════════════════╗");
1227 println!("║ QUICK REFERENCE ║");
1228 println!("╟──────────────────────────────────────────────────────────────────╢");
1229 println!("║ memvid models list List all models ║");
1230 println!("║ memvid models list --model-type llm List only LLM models ║");
1231 println!("║ memvid models install phi-3.5-mini Install LLM model ║");
1232 println!("║ memvid models remove phi-3.5-mini Remove LLM model ║");
1233 println!("║ memvid models verify Verify installed models ║");
1234 println!("╚══════════════════════════════════════════════════════════════════╝");
1235
1236 Ok(())
1237}
1238
1239fn handle_models_list_json(
1241 config: &CliConfig,
1242 fastembed_installed: &[(String, PathBuf, u64)],
1243) -> Result<()> {
1244 let output = serde_json::json!({
1245 "models_dir": config.models_dir,
1246 "embedding_models": EmbeddingModel::all().map(|m| {
1247 let is_installed = fastembed_installed
1248 .iter()
1249 .any(|(name, _, _)| name.contains(m.hf_repo()));
1250 serde_json::json!({
1251 "name": m.cli_name(),
1252 "display_name": m.display_name(),
1253 "dimensions": m.dimensions(),
1254 "size_mb": m.size_mb(),
1255 "hf_repo": m.hf_repo(),
1256 "installed": is_installed,
1257 "is_default": m.is_default(),
1258 })
1259 }).collect::<Vec<_>>(),
1260 "reranker_models": RerankerModel::all().map(|m| {
1261 serde_json::json!({
1262 "name": m.cli_name(),
1263 "display_name": m.display_name(),
1264 "size_mb": m.size_mb(),
1265 "hf_repo": m.hf_repo(),
1266 "language": m.language(),
1267 "is_default": m.is_default(),
1268 })
1269 }).collect::<Vec<_>>(),
1270 "llm_models": LlmModel::all().map(|m| {
1271 serde_json::json!({
1272 "name": m.cli_name(),
1273 "display_name": m.display_name(),
1274 "size_gb": m.expected_size_bytes() as f64 / 1_000_000_000.0,
1275 "hf_repo": m.hf_repo(),
1276 "installed": is_llm_model_installed(config, m),
1277 "path": if is_llm_model_installed(config, m) {
1278 Some(llm_model_path(config, m))
1279 } else {
1280 None
1281 },
1282 "is_default": m.is_default(),
1283 })
1284 }).collect::<Vec<_>>(),
1285 "external_providers": EXTERNAL_EMBEDDING_PROVIDERS.iter().map(|p| {
1286 serde_json::json!({
1287 "name": p.name,
1288 "env_var": p.env_var,
1289 "configured": std::env::var(p.env_var).is_ok(),
1290 "models": p.models.iter().map(|(name, dim, desc)| {
1291 serde_json::json!({
1292 "name": name,
1293 "dimensions": dim,
1294 "description": desc,
1295 })
1296 }).collect::<Vec<_>>(),
1297 })
1298 }).collect::<Vec<_>>(),
1299 "installed_cache": fastembed_installed.iter().map(|(name, path, size)| {
1300 serde_json::json!({
1301 "name": name,
1302 "path": path,
1303 "size_bytes": size,
1304 })
1305 }).collect::<Vec<_>>(),
1306 });
1307
1308 println!("{}", serde_json::to_string_pretty(&output)?);
1309 Ok(())
1310}
1311
1312pub fn handle_models_remove(config: &CliConfig, args: ModelsRemoveArgs) -> Result<()> {
1314 let model = args.model;
1315 let path = llm_model_path(config, model);
1316
1317 if !path.exists() {
1318 println!("{} is not installed.", model.display_name());
1319 return Ok(());
1320 }
1321
1322 if !args.yes {
1323 print!(
1324 "Remove {} ({})? [y/N] ",
1325 model.display_name(),
1326 path.display()
1327 );
1328 io::stdout().flush()?;
1329
1330 let mut input = String::new();
1331 io::stdin().read_line(&mut input)?;
1332
1333 if !input.trim().eq_ignore_ascii_case("y") {
1334 println!("Aborted.");
1335 return Ok(());
1336 }
1337 }
1338
1339 fs::remove_file(&path)?;
1340
1341 if let Some(parent) = path.parent() {
1343 let _ = fs::remove_dir(parent);
1344 }
1345
1346 println!("Removed {}.", model.display_name());
1347 Ok(())
1348}
1349
1350pub fn handle_models_verify(config: &CliConfig, args: ModelsVerifyArgs) -> Result<()> {
1352 let models_to_verify: Vec<LlmModel> = match args.model {
1353 Some(m) => vec![m],
1354 None => LlmModel::all()
1355 .filter(|m| is_llm_model_installed(config, *m))
1356 .collect(),
1357 };
1358
1359 if models_to_verify.is_empty() {
1360 println!("No LLM models installed to verify.");
1361 return Ok(());
1362 }
1363
1364 let mut all_ok = true;
1365
1366 for model in models_to_verify {
1367 let path = llm_model_path(config, model);
1368 print!("Verifying {}... ", model.display_name());
1369 io::stdout().flush()?;
1370
1371 match verify_model_file(&path, model) {
1372 Ok(()) => println!("OK"),
1373 Err(err) => {
1374 println!("FAILED");
1375 eprintln!(" Error: {}", err);
1376 all_ok = false;
1377 }
1378 }
1379 }
1380
1381 if !all_ok {
1382 bail!("Some models failed verification.");
1383 }
1384
1385 Ok(())
1386}
1387
1388fn verify_model_file(path: &Path, model: LlmModel) -> Result<()> {
1390 if !path.exists() {
1391 bail!("Model file does not exist");
1392 }
1393
1394 let metadata = fs::metadata(path)?;
1395 let size = metadata.len();
1396
1397 let min_size = model.expected_size_bytes() / 2;
1399 if size < min_size {
1400 bail!(
1401 "Model file too small ({:.2} GB, expected at least {:.2} GB)",
1402 size as f64 / 1_000_000_000.0,
1403 min_size as f64 / 1_000_000_000.0
1404 );
1405 }
1406
1407 let mut file = fs::File::open(path)?;
1409 let mut magic = [0u8; 4];
1410 io::Read::read_exact(&mut file, &mut magic)?;
1411
1412 if &magic != b"GGUF" {
1414 bail!("Invalid GGUF file (bad magic bytes)");
1415 }
1416
1417 Ok(())
1418}
1419
1420pub fn get_installed_model_path(config: &CliConfig, model: LlmModel) -> Option<PathBuf> {
1422 let path = llm_model_path(config, model);
1423 if path.exists() && path.is_file() {
1424 Some(path)
1425 } else {
1426 None
1427 }
1428}
1429
1430pub fn default_enrichment_model() -> LlmModel {
1432 LlmModel::Phi35Mini
1433}