Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
33 changes: 3 additions & 30 deletions src-tauri/src/models.rs
Original file line number Diff line number Diff line change
Expand Up @@ -405,54 +405,27 @@ fn scan_gguf_recursive(
/// the main model.
fn find_mmproj(model_path: &Path, model_filename: &str, cache: &GgufCache) -> Option<PathBuf> {
let dir = model_path.parent()?;
let stem = model_filename.trim_end_matches(".gguf");

// Extract the "model name + params" prefix, e.g. "Qwen3.5-4B" from "Qwen3.5-4B-Q4_K_M"
// Strip trailing quant pattern to get the base name
let re = regex::Regex::new(r"[-_](?:MXFP\d|IQ\d[_A-Z]*|Q\d[_KM0-9A-Z]+|F16|F32|BF16)$").unwrap();
let base = re.replace(stem, "").to_string();

// Split base into segments for matching
// e.g. "Qwen3.5-4B" → ["qwen3.5", "4b"]
let base_lower = base.to_lowercase();
let segments: Vec<&str> = base_lower.split(&['-', '_', '.'][..])
.filter(|s| !s.is_empty())
.collect();

let entries = std::fs::read_dir(dir).ok()?;
let mut best: Option<(PathBuf, usize)> = None;

for entry in entries.flatten() {
let path = entry.path();
if !path.is_file() { continue; }
let fname = path.file_name()?.to_string_lossy().to_string();
let fname_lower = fname.to_lowercase();
if !fname_lower.ends_with(".gguf") { continue; }
// Must not be the model itself
if fname == model_filename { continue; }

// Check if this file is an mmproj: by filename OR by cached GGUF metadata
let is_mmproj_by_name = fname_lower.contains("mmproj");
let cache_key = path.to_string_lossy().to_string();
let is_mmproj_by_cache = cache.get(&cache_key).map_or(false, |e| e.is_mmproj);
if !is_mmproj_by_name && !is_mmproj_by_cache {
continue;
}

// Count how many base segments appear in the mmproj filename
let matches = segments.iter()
.filter(|seg| fname_lower.contains(*seg))
.count();

// Require at least 2 matching segments (name + params typically)
if matches >= 2 {
if best.as_ref().map_or(true, |(_, best_m)| matches > *best_m) {
best = Some((path, matches));
}
if is_mmproj_by_name || is_mmproj_by_cache {
return Some(path);
}
}

best.map(|(p, _)| p)
None
}

struct CachedMeta {
Expand Down
18 changes: 18 additions & 0 deletions src-tauri/src/tui/params.rs
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,24 @@ pub struct ParamDef {

pub static PARAMS: &[ParamDef] = &[
// ── Context ──────────────────────────────────────────────────────────────
ParamDef {
key: "model_path",
label: "Model Path",
description: "Path to model file (GGUF)",
param_type: ParamType::String,
default_display: "",
category: "Context",
is_typed_field: true,
},
ParamDef {
key: "mmproj_path",
label: "Multimodal Projector Path",
description: "Path to multimodal projector file (mmproj.gguf) for vision models",
param_type: ParamType::String,
default_display: "",
category: "Context",
is_typed_field: true,
},
ParamDef {
key: "n_ctx",
label: "Context Size",
Expand Down
49 changes: 44 additions & 5 deletions src-tauri/src/tui/tabs/server.rs
Original file line number Diff line number Diff line change
Expand Up @@ -215,6 +215,18 @@ fn collect_overrides(config: &ServerConfig) -> Vec<OverrideEntry> {
});
}

if let Some(mmproj) = &config.mmproj_path {
let name = std::path::Path::new(mmproj)
.file_name()
.and_then(|n| n.to_str())
.unwrap_or(mmproj);
overrides.push(OverrideEntry {
key: "mmproj_path".to_string(),
value: name.to_string(),
description: "Multimodal projector".to_string(),
});
}

check_field!(n_gpu_layers, "n_gpu_layers", "GPU layers (-1=all)");
check_field!(n_ctx, "n_ctx", "Context size (0=auto)");
check_field!(flash_attn, "flash_attn", "Flash attention");
Expand Down Expand Up @@ -423,6 +435,7 @@ fn handle_value_edit_key(app: &mut TuiApp, key: KeyEvent) -> Action {
KeyCode::Esc => {
app.server_tab.focus = ServerFocus::Search;
app.server_tab.editing_param = None;
app.input_focused = true;
}
KeyCode::Enter => {
if let Some(ref param_key) = app.server_tab.editing_param.clone() {
Expand All @@ -432,6 +445,7 @@ fn handle_value_edit_key(app: &mut TuiApp, key: KeyEvent) -> Action {
app.server_tab.focus = ServerFocus::Search;
app.server_tab.editing_param = None;
app.server_tab.autocomplete.reset();
app.input_focused = true;
}
}
KeyCode::Backspace => {
Expand Down Expand Up @@ -550,6 +564,14 @@ fn handle_preset_name(app: &mut TuiApp, key: KeyEvent) -> Action {

fn apply_override(config: &mut ServerConfig, key: &str, value: &str) {
match key {
"model_path" => config.model_path = value.to_string(),
"mmproj_path" => {
if value.is_empty() {
config.mmproj_path = None;
} else {
config.mmproj_path = Some(value.to_string());
}
}
"n_gpu_layers" => {
if let Ok(v) = value.parse() {
config.n_gpu_layers = v;
Expand Down Expand Up @@ -644,6 +666,7 @@ fn remove_override(config: &mut ServerConfig, key: &str) {
let defaults = ServerConfig::default();
match key {
"model_path" => config.model_path = String::new(),
"mmproj_path" => config.mmproj_path = None,
"n_gpu_layers" => config.n_gpu_layers = defaults.n_gpu_layers,
"n_ctx" => config.n_ctx = defaults.n_ctx,
"flash_attn" => config.flash_attn = defaults.flash_attn.clone(),
Expand Down Expand Up @@ -793,6 +816,13 @@ fn render_model_and_status(app: &TuiApp, area: Rect, frame: &mut Frame) {
.unwrap_or(&app.server_tab.config.model_path)
};

let mmproj_name = app.server_tab.config.mmproj_path.as_ref().map(|mmproj| {
std::path::Path::new(mmproj)
.file_name()
.and_then(|n| n.to_str())
.unwrap_or(mmproj)
});

let server_status = match &app.server {
Some(ds) => Span::styled(
format!(" Running :{} (PID {})", ds.port, ds.pid),
Expand All @@ -801,16 +831,25 @@ fn render_model_and_status(app: &TuiApp, area: Rect, frame: &mut Frame) {
None => Span::styled(" Stopped", Style::default().fg(Color::Red)),
};

let lines = vec![
let mut lines = vec![
Line::from(vec![
Span::styled(" Model ", Style::default().fg(Color::Blue)),
Span::styled(model_name, Style::default().fg(Color::White)),
]),
Line::from(vec![
Span::styled(" Server ", Style::default().fg(Color::Blue)),
server_status,
]),
];

if let Some(mmproj) = mmproj_name {
lines.push(Line::from(vec![
Span::styled(" MMproj ", Style::default().fg(Color::Blue)),
Span::styled(mmproj, Style::default().fg(Color::White)),
]));
}

lines.push(Line::from(vec![
Span::styled(" Server ", Style::default().fg(Color::Blue)),
server_status,
]));

frame.render_widget(Paragraph::new(lines), area);
}

Expand Down
22 changes: 22 additions & 0 deletions src/pages/Models.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -60,6 +60,7 @@ export default function Models() {
const [nameFilter, setNameFilter] = useState("");
const [quantFilter, setQuantFilter] = useState("");
const [favorites, setFavorites] = useState<string[]>([]);
const [selectedModelPath, setSelectedModelPath] = useState<string | null>(null);
const [preferredOwners, setPreferredOwners] = useState<string[]>([]);
const [mmProjPicker, setMmProjPicker] = useState<{
repoId: string;
Expand Down Expand Up @@ -98,10 +99,25 @@ export default function Models() {
} catch {}
};

const reloadSelectedModel = useCallback(async () => {
try {
const cfg = await invoke<{ selected_model: string | null }>("get_config");
setSelectedModelPath(cfg.selected_model);
} catch {}
}, []);

const selectForServer = async (modelPath: string) => {
try {
await invoke("set_selected_model", { modelPath });
await reloadSelectedModel();
} catch {}
};

useEffect(() => {
reload();
reloadDirs();
reloadFavorites();
reloadSelectedModel();
invoke<KnownOwner[]>("get_known_owners").then(setOwners).catch(() => {});
invoke<string[]>("get_preferred_owners").then(setPreferredOwners).catch(() => {});

Expand Down Expand Up @@ -447,6 +463,7 @@ export default function Models() {
<div>
{sorted.map((m) => {
const isFav = favorites.includes(m.id);
const isSelected = selectedModelPath === m.path;
return (
<div key={m.id}
className="flex items-center gap-2 px-3 py-2 border-b border-border/50 hover:bg-surface-3 transition-colors">
Expand All @@ -471,6 +488,11 @@ export default function Models() {
<span className="w-20 text-right text-xs text-gray-400 font-mono">
{formatSize(m.size_bytes)}
</span>
<button className="w-8 flex justify-center"
onClick={() => selectForServer(m.path)}
title={isSelected ? "Selected for server" : "Use for server"}>
<CheckCircle size={13} className={isSelected ? "text-primary" : "text-gray-600 hover:text-gray-400"} />
</button>
<button className="w-8 flex justify-center text-gray-600 hover:text-accent-red"
onClick={() => deleteModel(m)} disabled={deletingId === m.id} title="Delete model">
<Trash2 size={13} />
Expand Down