8 files changed
+313
-3
src-tauri/src/chat/command_list_models.rs
+5
-2
@@ -38,7 +38,7 @@ fn sanitize_description(desc: &str) -> String {
38
#[cfg(any(target_os = "macos", target_os = "ios", target_os = "android"))]
39
#[tauri::command]
40
pub async fn chat_list_models() -> Vec<ModelInfo> {
41
- use super::{config_for_model_id, SELECTED_MODEL};
41
+ use super::{config_for_model_id, is_model_downloaded, SELECTED_MODEL};
42
43
let selected = SELECTED_MODEL.lock().map(|g| g.clone()).unwrap_or_default();
44
@@ -53,6 +53,7 @@ pub async fn chat_list_models() -> Vec<ModelInfo> {
53
description: sanitize_description(info.description),
54
approx_memory: cfg.approx_memory,
55
size_bytes: Some(info.expected_size_bytes),
56
+ is_downloaded: is_model_downloaded(info.id, info.expected_size_bytes),
57
is_selected: info.id == selected,
58
})
59
})
@@ -64,6 +65,7 @@ pub async fn chat_list_models() -> Vec<ModelInfo> {
65
#[cfg(target_os = "macos")]
66
{
67
use super::{gemma2_2b_isq_config, GEMMA2_2B_IT_ISQ_ID};
68
+ const GEMMA2_2B_EXPECTED_BYTES: u64 = 5_228_717_512;
69
let cfg = gemma2_2b_isq_config();
70
models.push(ModelInfo {
71
id: GEMMA2_2B_IT_ISQ_ID.to_string(),
@@ -75,7 +77,8 @@ pub async fn chat_list_models() -> Vec<ModelInfo> {
77
),
78
approx_memory: cfg.approx_memory,
79
// Full bf16 safetensors download before in-situ quantisation (~5.2 GB).
78
- size_bytes: Some(5_228_717_512),
80
+ size_bytes: Some(GEMMA2_2B_EXPECTED_BYTES),
81
+ is_downloaded: is_model_downloaded(GEMMA2_2B_IT_ISQ_ID, GEMMA2_2B_EXPECTED_BYTES),
82
is_selected: GEMMA2_2B_IT_ISQ_ID == selected,
83
});
84
}
src-tauri/src/chat/command_remove_model.rs
new
+51
@@ -0,0 +1,51 @@
1
+//! Tauri command `chat_remove_model`: deletes a downloaded model's weights.
2
+//!
3
+//! Removes the model's directory from the local HuggingFace cache to free disk
4
+//! space. If the model being removed is the one currently loaded in memory, it
5
+//! is unloaded first so the engine doesn't keep a now-orphaned model resident.
6
+
7
+use tauri::AppHandle;
8
+
9
+#[cfg(any(target_os = "macos", target_os = "ios", target_os = "android"))]
10
+use {
11
+ super::{emit_chat_status, resolved_model_config, ENGINE, SELECTED_MODEL},
12
+ crate::constants::ChatStatus,
13
+ log::{error, info},
14
+};
15
+
16
+/// Delete the locally cached weights for `model_id` (a HuggingFace repo id from
17
+/// `chat_list_models`). Unloads the model first if it is currently loaded.
18
+#[cfg(any(target_os = "macos", target_os = "ios", target_os = "android"))]
19
+#[tauri::command]
20
+pub async fn chat_remove_model(app: AppHandle, model_id: String) -> Result<String, String> {
21
+ // If we're deleting the model that's currently loaded/selected, unload it
22
+ // first so the engine isn't left holding weights we're about to remove.
23
+ let is_selected = SELECTED_MODEL
24
+ .lock()
25
+ .map(|g| *g == model_id)
26
+ .unwrap_or(false);
27
+
28
+ if is_selected && ENGINE.is_loaded().await {
29
+ let display_name = resolved_model_config().display_name();
30
+ ENGINE.unload_model().await;
31
+ info!("Unloaded {display_name} before removing its weights.");
32
+ emit_chat_status(&app, ChatStatus::Unloaded, None, None);
33
+ }
34
+
35
+ onde::hf_cache::delete_local_hf_model(model_id.clone()).map_err(|e| {
36
+ let msg = format!("Failed to remove model {model_id}: {e}");
37
+ error!("{msg}");
38
+ msg
39
+ })?;
40
+
41
+ let msg = format!("Removed downloaded weights for {model_id}.");
42
+ info!("{msg}");
43
+ Ok(msg)
44
+}
45
+
46
+#[cfg(not(any(target_os = "macos", target_os = "ios", target_os = "android")))]
47
+#[tauri::command]
48
+pub async fn chat_remove_model(_app: AppHandle, _model_id: String) -> Result<String, String> {
49
+ log::debug!("Model management is only supported on macOS, iOS, and Android.");
50
+ Err("Model management is only supported on macOS, iOS, and Android for now.".to_string())
51
+}
src-tauri/src/chat/mod.rs
+52
@@ -17,6 +17,7 @@ pub mod command_get_history;
17
pub mod command_get_status;
18
pub mod command_list_models;
19
pub mod command_load_model;
20
+pub mod command_remove_model;
21
pub mod command_send_message;
22
pub mod command_set_model;
23
pub mod command_unload_model;
@@ -28,6 +29,7 @@ pub use command_get_history::chat_get_history;
29
pub use command_get_status::chat_get_status;
30
pub use command_list_models::chat_list_models;
31
pub use command_load_model::chat_load_model;
32
+pub use command_remove_model::chat_remove_model;
33
pub use command_send_message::chat_send_message;
34
pub use command_set_model::chat_set_model;
35
pub use command_unload_model::chat_unload_model;
@@ -110,6 +112,8 @@ pub struct ModelInfo {
112
pub approx_memory: String,
113
/// Approximate on-disk download size in bytes (for display).
114
pub size_bytes: Option<u64>,
115
+ /// Whether the model's weights are already downloaded to the local cache.
116
+ pub is_downloaded: bool,
117
/// Whether this model is the one currently selected for Siti.
118
pub is_selected: bool,
119
}
@@ -281,6 +285,54 @@ pub(crate) fn resolved_model_config() -> ResolvedModel {
285
resolve_model_id(&id).unwrap_or_else(|| ResolvedModel::Gguf(siti_default_config()))
286
}
287
288
+// ── Download-status detection ────────────────────────────────────────────────
289
+
290
+/// Fraction of `expected_size_bytes` that must be present on disk for a model
291
+/// to count as fully downloaded (mirrors onde's own completeness threshold).
292
+#[cfg(any(target_os = "macos", target_os = "ios", target_os = "android"))]
293
+const DOWNLOAD_COMPLETE_THRESHOLD: f64 = 0.99;
294
+
295
+/// Sum the byte size of every real file directly and recursively under `path`.
296
+///
297
+/// Only used against the HF cache's `blobs/` directory, which holds the actual
298
+/// downloaded files (not the `snapshots/` symlink/hard-link views), so nothing
299
+/// is double-counted.
300
+#[cfg(any(target_os = "macos", target_os = "ios", target_os = "android"))]
301
+fn blobs_dir_size(path: &std::path::Path) -> u64 {
302
+ let mut total = 0;
303
+ if let Ok(entries) = std::fs::read_dir(path) {
304
+ for entry in entries.flatten() {
305
+ let p = entry.path();
306
+ match entry.metadata() {
307
+ Ok(md) if md.is_dir() => total += blobs_dir_size(&p),
308
+ Ok(md) => total += md.len(),
309
+ Err(_) => {}
310
+ }
311
+ }
312
+ }
313
+ total
314
+}
315
+
316
+/// Whether `id`'s weights are already downloaded to the local HF cache.
317
+///
318
+/// A model counts as downloaded when its cache `blobs/` directory holds at
319
+/// least [`DOWNLOAD_COMPLETE_THRESHOLD`] of the model's expected size, which
320
+/// excludes partial/interrupted downloads.
321
+#[cfg(any(target_os = "macos", target_os = "ios", target_os = "android"))]
322
+pub(crate) fn is_model_downloaded(id: &str, expected_size_bytes: u64) -> bool {
323
+ if expected_size_bytes == 0 {
324
+ return false;
325
+ }
326
+ match onde::hf_cache::model_cache_path(id) {
327
+ Some(root) => {
328
+ let blobs = root.join("blobs");
329
+ blobs_dir_size(&blobs) as f64
330
+ >= expected_size_bytes as f64 * DOWNLOAD_COMPLETE_THRESHOLD
331
+ }
332
+ None => false,
333
+ }
334
+}
335
+
336
/// Return the sampling config for the currently selected model.
337
///
338
/// iOS and Android use conservative settings to bound KV cache memory and
src-tauri/src/lib.rs
+2
-1
@@ -12,7 +12,7 @@ pub mod inference {
12
use app_info::app_build_version;
13
use chat::{
14
chat_clear_history, chat_get_history, chat_get_status, chat_list_models, chat_load_model,
15
- chat_send_message, chat_set_model, chat_unload_model,
15
+ chat_remove_model, chat_send_message, chat_set_model, chat_unload_model,
16
};
17
18
#[cfg_attr(mobile, tauri::mobile_entry_point)]
@@ -50,6 +50,7 @@ pub fn run() {
50
chat_get_history,
51
chat_list_models,
52
chat_set_model,
53
+ chat_remove_model,
54
// App metadata
55
app_build_version,
56
])
src/App.css
+82
@@ -558,6 +558,88 @@ html, body {
558
background: #e5484d;
559
}
560
561
+/* ── Supported Models list ─────────────────────────────────────────────── */
562
+
563
+.model-list-row {
564
+ display: flex;
565
+ align-items: center;
566
+ justify-content: space-between;
567
+ gap: 12px;
568
+ padding: 12px 16px;
569
+ min-height: 56px;
570
+}
571
+
572
+.model-list-row + .model-list-row {
573
+ border-top: 1px solid var(--border);
574
+}
575
+
576
+.model-list-info {
577
+ display: flex;
578
+ flex-direction: column;
579
+ gap: 3px;
580
+ min-width: 0;
581
+}
582
+
583
+.model-list-name {
584
+ font-size: 15px;
585
+ font-weight: 500;
586
+ color: var(--text-primary);
587
+}
588
+
589
+.model-list-meta {
590
+ font-size: 12px;
591
+ color: var(--text-secondary);
592
+}
593
+
594
+.model-list-actions {
595
+ display: flex;
596
+ align-items: center;
597
+ gap: 6px;
598
+ flex-shrink: 0;
599
+}
600
+
601
+.model-downloaded {
602
+ display: flex;
603
+ align-items: center;
604
+ color: #34c759;
605
+}
606
+
607
+.model-action {
608
+ display: flex;
609
+ align-items: center;
610
+ justify-content: center;
611
+ width: 34px;
612
+ height: 34px;
613
+ border: none;
614
+ border-radius: 50%;
615
+ background: transparent;
616
+ cursor: pointer;
617
+ padding: 0;
618
+ transition: background 0.15s ease, color 0.15s ease;
619
+}
620
+
621
+.model-action.download {
622
+ color: #0a84ff;
623
+}
624
+
625
+.model-action.download:hover:not(:disabled) {
626
+ background: rgba(10, 132, 255, 0.15);
627
+}
628
+
629
+.model-action.remove {
630
+ color: var(--text-secondary);
631
+}
632
+
633
+.model-action.remove:hover:not(:disabled) {
634
+ background: rgba(229, 72, 77, 0.15);
635
+ color: #e5484d;
636
+}
637
+
638
+.model-action:disabled {
639
+ opacity: 0.4;
640
+ cursor: default;
641
+}
642
+
643
.settings-note {
644
font-size: 12px;
645
line-height: 1.5;
src/App.tsx
+12
@@ -6,6 +6,7 @@ import {
6
type ModelInfo,
7
listModels,
8
setModel,
9
+ removeModel,
10
sendMessage as sendMessageCmd,
11
getStatus,
12
getHistory,
@@ -207,6 +208,16 @@ export default function App() {
208
}
209
}
210
211
+ async function handleRemoveModel(id: string) {
212
+ try {
213
+ await removeModel(id);
214
+ } catch (e) {
215
+ setStatusError(String(e));
216
+ } finally {
217
+ await refreshModels();
218
+ }
219
+ }
220
+
221
function handleKeyDown(e: KeyboardEvent<HTMLTextAreaElement>) {
222
if (e.key === "Enter" && !e.shiftKey) {
223
e.preventDefault();
@@ -297,6 +308,7 @@ export default function App() {
308
status={status}
309
statusError={statusError}
310
onSelectModel={handleSelectModel}
311
+ onRemoveModel={handleRemoveModel}
312
onClose={() => setSettingsOpen(false)}
313
/>
314
</div>
src/Settings.tsx
+105
@@ -9,9 +9,59 @@ interface SettingsProps {
9
status: ChatStatus;
10
statusError: string | null;
11
onSelectModel: (id: string) => void;
12
+ onRemoveModel: (id: string) => void;
13
onClose: () => void;
14
}
15
16
+// Down-arrow-into-tray: download action for a not-yet-downloaded model.
17
+function DownloadIcon() {
18
+ return (
19
+ <svg viewBox="0 0 20 20" width="18" height="18" aria-hidden>
20
+ <path
21
+ d="M10 3v9m0 0 3.5-3.5M10 12 6.5 8.5M4 14.5V16a1 1 0 0 0 1 1h10a1 1 0 0 0 1-1v-1.5"
22
+ fill="none"
23
+ stroke="currentColor"
24
+ strokeWidth="1.6"
25
+ strokeLinecap="round"
26
+ strokeLinejoin="round"
27
+ />
28
+ </svg>
29
+ );
30
+}
31
+
32
+// Trash: remove a downloaded model's weights from disk.
33
+function TrashIcon() {
34
+ return (
35
+ <svg viewBox="0 0 20 20" width="18" height="18" aria-hidden>
36
+ <path
37
+ d="M4 6h12M8 6V4.5a1 1 0 0 1 1-1h2a1 1 0 0 1 1 1V6m1.5 0-.6 9a1.5 1.5 0 0 1-1.5 1.4H8.1A1.5 1.5 0 0 1 6.6 15L6 6m2.5 2.5v5m3-5v5"
38
+ fill="none"
39
+ stroke="currentColor"
40
+ strokeWidth="1.6"
41
+ strokeLinecap="round"
42
+ strokeLinejoin="round"
43
+ />
44
+ </svg>
45
+ );
46
+}
47
+
48
+// Filled check badge: model is downloaded and available offline.
49
+function DownloadedIcon() {
50
+ return (
51
+ <svg viewBox="0 0 20 20" width="18" height="18" aria-hidden>
52
+ <circle cx="10" cy="10" r="8" fill="currentColor" />
53
+ <path
54
+ d="m6.5 10.2 2.4 2.4 4.6-4.8"
55
+ fill="none"
56
+ stroke="var(--surface, #1c1c1e)"
57
+ strokeWidth="1.8"
58
+ strokeLinecap="round"
59
+ strokeLinejoin="round"
60
+ />
61
+ </svg>
62
+ );
63
+}
64
+
65
function statusLine(status: ChatStatus, error: string | null) {
66
switch (status) {
67
case "loading":
@@ -33,6 +83,7 @@ export default function Settings({
83
status,
84
statusError,
85
onSelectModel,
86
+ onRemoveModel,
87
onClose,
88
}: SettingsProps) {
89
const selected = useMemo(
@@ -128,6 +179,60 @@ export default function Settings({
179
</p>
180
</section>
181
182
+ <section className="settings-section">
183
+ <p className="section-label">Supported Models</p>
184
+ <div className="settings-card model-list">
185
+ {models.length === 0 && (
186
+ <div className="settings-row">
187
+ <span className="row-value">No models available.</span>
188
+ </div>
189
+ )}
190
+ {models.map((m) => (
191
+ <div className="model-list-row" key={m.id}>
192
+ <div className="model-list-info">
193
+ <span className="model-list-name">{m.name}</span>
194
+ <span className="model-list-meta">
195
+ {m.org}
196
+ {m.size_bytes ? ` · ${formatSize(m.size_bytes)}` : ""}
197
+ </span>
198
+ </div>
199
+ <div className="model-list-actions">
200
+ {m.is_downloaded ? (
201
+ <>
202
+ <span
203
+ className="model-downloaded"
204
+ title="Downloaded"
205
+ aria-label="Downloaded"
206
+ >
207
+ <DownloadedIcon />
208
+ </span>
209
+ <button
210
+ className="model-action remove"
211
+ onClick={() => onRemoveModel(m.id)}
212
+ disabled={busy}
213
+ title="Remove download"
214
+ aria-label={`Remove ${m.name}`}
215
+ >
216
+ <TrashIcon />
217
+ </button>
218
+ </>
219
+ ) : (
220
+ <button
221
+ className="model-action download"
222
+ onClick={() => onSelectModel(m.id)}
223
+ disabled={busy}
224
+ title="Download"
225
+ aria-label={`Download ${m.name}`}
226
+ >
227
+ <DownloadIcon />
228
+ </button>
229
+ )}
230
+ </div>
231
+ </div>
232
+ ))}
233
+ </div>
234
+ </section>
235
+
236
<section className="settings-section">
237
<p className="section-label">About</p>
238
<div className="settings-card about">
src/api.ts
+4
@@ -20,6 +20,7 @@ export interface ModelInfo {
20
description: string;
21
approx_memory: string;
22
size_bytes: number | null;
23
+ is_downloaded: boolean;
24
is_selected: boolean;
25
}
26
@@ -54,6 +55,9 @@ export const listModels = () => invoke<ModelInfo[]>("chat_list_models");
55
export const setModel = (modelId: string) =>
56
invoke<void>("chat_set_model", { modelId });
57
58
+export const removeModel = (modelId: string) =>
59
+ invoke<string>("chat_remove_model", { modelId });
60
+
61
export const sendMessage = (message: string) =>
62
invoke<void>("chat_send_message", { message });
63