@sigit / siti / commits / 07df319

WIP

paydii committed Jul 25, 2026 at 20:17 UTC 07df31904632bcbeddb9e87946f6f9d8ab65bafe
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