Skip to content

Commit 1a5e6db

Browse files
Desktop release: full bundle in CI, SKIP_MODEL for 2GB limit, first-run auto-download
1 parent 31990be commit 1a5e6db

File tree

9 files changed

+253
-15
lines changed

9 files changed

+253
-15
lines changed

.github/workflows/build-release.yml

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -47,10 +47,12 @@ jobs:
4747
cd desktop
4848
npm ci
4949
50+
# SKIP_MODEL=1 keeps DMG/MSI under GitHub's 2 GB release asset limit; users download model in Settings.
5051
- name: Setup full bundle (Python + default model + KB)
5152
env:
5253
BUNDLE_PYTHON: 1
5354
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
55+
SKIP_MODEL: 1
5456
run: |
5557
cd desktop
5658
bash scripts/setup-full-bundle.sh

desktop/scripts/setup-full-bundle.sh

Lines changed: 19 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -22,18 +22,23 @@ bash "$DESKTOP_DIR/scripts/setup-python-bundle.sh"
2222
echo ""
2323

2424
# --- 2. Default model into resources/models/ ---
25+
# Set SKIP_MODEL=1 in CI to keep release assets under GitHub's 2 GB limit; users download model in Settings.
2526
echo "Step 2/3: Default model (for bundled DMG) ..."
26-
mkdir -p "$MODELS_DIR"
27-
if [ -f "$DEFAULT_MODEL_PATH" ]; then
28-
echo " Model already present at $DEFAULT_MODEL_PATH"
27+
if [ -n "${SKIP_MODEL:-}" ]; then
28+
echo " SKIP_MODEL is set; skipping model download (installer will stay under 2 GB; users can download in Settings)."
2929
else
30-
echo " Downloading default model (~2.5 GB) to $DEFAULT_MODEL_PATH"
31-
echo " This may take a while."
32-
if curl -# -L -o "$DEFAULT_MODEL_PATH" "$DEFAULT_MODEL_URL"; then
33-
echo " Model downloaded."
30+
mkdir -p "$MODELS_DIR"
31+
if [ -f "$DEFAULT_MODEL_PATH" ]; then
32+
echo " Model already present at $DEFAULT_MODEL_PATH"
3433
else
35-
echo " Download failed. Delete any partial file and re-run, or run without model (first launch will open Settings)."
36-
exit 1
34+
echo " Downloading default model (~2.5 GB) to $DEFAULT_MODEL_PATH"
35+
echo " This may take a while."
36+
if curl -# -L -o "$DEFAULT_MODEL_PATH" "$DEFAULT_MODEL_URL"; then
37+
echo " Model downloaded."
38+
else
39+
echo " Download failed. Delete any partial file and re-run, or run without model (first launch will open Settings)."
40+
exit 1
41+
fi
3742
fi
3843
fi
3944
echo ""
@@ -53,4 +58,8 @@ fi
5358
echo ""
5459

5560
echo "Full bundle ready. Run: npm run build"
56-
echo "Installers (DMG etc.) will include the model and KB; first launch will not show Settings."
61+
if [ -n "${SKIP_MODEL:-}" ]; then
62+
echo "Installers will include Python and KB; download the model once in Settings."
63+
else
64+
echo "Installers (DMG etc.) will include the model and KB; first launch will not show Settings."
65+
fi

desktop/src-tauri/src/bundled_defaults.rs

Lines changed: 58 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -25,13 +25,22 @@ const DEFAULT_MODEL_ID: &str = "llama-3.2-3b-instruct-q4_k_m";
2525
/// Filename used when downloading default model from URL (last segment of HuggingFace URL).
2626
const DEFAULT_MODEL_DOWNLOAD_FILENAME: &str = "Llama-3.2-3B-Instruct-Q4_K_M.gguf";
2727

28+
/// URL for the default model (same as in setup-full-bundle.sh). Used for first-run auto-download.
29+
const DEFAULT_MODEL_URL: &str = "https://huggingface.co/bartowski/Llama-3.2-3B-Instruct-GGUF/resolve/main/Llama-3.2-3B-Instruct-Q4_K_M.gguf";
30+
2831
/// Relative path to bundled KB JSON in resources (same format as URL-loaded package: manifest, documents, embeddings).
2932
const BUNDLED_KB_FILENAME: &str = "default_kb.json";
3033

3134
#[derive(Debug, Serialize)]
3235
pub struct BundledDefaultsStatus {
3336
pub model_ready: bool,
3437
pub kb_ready: bool,
38+
/// When packaged and no model found: URL to download the default model on first run.
39+
#[serde(skip_serializing_if = "Option::is_none")]
40+
pub default_model_download_url: Option<String>,
41+
/// When packaged and no model found: path where the default model should be saved (app data).
42+
#[serde(skip_serializing_if = "Option::is_none")]
43+
pub default_model_output_path: Option<String>,
3544
}
3645

3746
/// Find project root (directory that contains "data" and ideally "desktop") for dev fallback.
@@ -80,6 +89,26 @@ fn resolve_bundled_model_path(app: &AppHandle) -> Option<PathBuf> {
8089
}
8190
}
8291
}
92+
// App data (e.g. after first-run auto-download or Settings download)
93+
if let Some(base) = app.path().app_data_dir().ok() {
94+
let models_dir = base.join("data").join("models");
95+
let id_gguf = format!("{}.gguf", DEFAULT_MODEL_ID);
96+
for name in [
97+
DEFAULT_MODEL_DOWNLOAD_FILENAME,
98+
BUNDLED_MODEL_FILENAME,
99+
id_gguf.as_str(),
100+
] {
101+
let p = models_dir.join(name);
102+
if p.exists() {
103+
if let Ok(metadata) = fs::metadata(&p) {
104+
let size_gb = metadata.len() as f64 / (1024.0 * 1024.0 * 1024.0);
105+
if size_gb >= 1.0 {
106+
return Some(p);
107+
}
108+
}
109+
}
110+
}
111+
}
83112
// Dev fallback: use same location as Settings download (project data/models/)
84113
if let Some(data_dir) = find_dev_data_dir() {
85114
let models_dir = data_dir.join("models");
@@ -215,8 +244,25 @@ async fn ingest_kb_from_path(app: &AppHandle, path: &Path) -> Result<(), String>
215244
Ok(())
216245
}
217246

247+
/// When packaged (resource dir present) and no model exists, return (url, output_path) for first-run auto-download.
248+
fn default_model_download_info(app: &AppHandle) -> Option<(String, PathBuf)> {
249+
if resolve_bundled_model_path(app).is_some() {
250+
return None;
251+
}
252+
let resource_dir = app.path().resource_dir().ok()?;
253+
if !resource_dir.exists() {
254+
return None;
255+
}
256+
let base = app.path().app_data_dir().ok()?;
257+
let models_dir = base.join("data").join("models");
258+
let _ = fs::create_dir_all(&models_dir);
259+
let output_path = models_dir.join(DEFAULT_MODEL_DOWNLOAD_FILENAME);
260+
Some((DEFAULT_MODEL_URL.to_string(), output_path))
261+
}
262+
218263
/// Ensure the default model and global KB are initialized from bundled resources when possible.
219264
/// Call this once after loading; then check setup status (model_ready, kb_ready).
265+
/// When no model is found in a packaged build, returns default_model_download_url/output_path for first-run auto-download.
220266
#[tauri::command]
221267
pub async fn ensure_bundled_defaults_initialized(app: AppHandle) -> Result<BundledDefaultsStatus, String> {
222268
// 1. Model: if not loaded, try bundled model path
@@ -231,10 +277,11 @@ pub async fn ensure_bundled_defaults_initialized(app: AppHandle) -> Result<Bundl
231277
}
232278
}
233279
None => {
280+
#[cfg(debug_assertions)]
234281
if let Ok(rd) = app.path().resource_dir() {
235282
let expected = rd.join("models").join(BUNDLED_MODEL_FILENAME);
236283
eprintln!(
237-
"[Confidant] No bundled model found. Expected at: {} (add resources/models/default_model.gguf and rebuild)",
284+
"[Confidant] No bundled model. Auto-download on first run or add resources/models/default_model.gguf. Expected: {}",
238285
expected.display()
239286
);
240287
}
@@ -287,8 +334,18 @@ pub async fn ensure_bundled_defaults_initialized(app: AppHandle) -> Result<Bundl
287334
.map(|n| n > 0)
288335
.unwrap_or(false);
289336

337+
let (default_model_download_url, default_model_output_path) = if !model_ready {
338+
default_model_download_info(&app)
339+
.map(|(url, path)| (Some(url), Some(path.to_string_lossy().to_string())))
340+
.unwrap_or((None, None))
341+
} else {
342+
(None, None)
343+
};
344+
290345
Ok(BundledDefaultsStatus {
291346
model_ready,
292347
kb_ready,
348+
default_model_download_url,
349+
default_model_output_path,
293350
})
294351
}

desktop/src/App.tsx

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@ import { useState, useEffect, useRef, useCallback } from 'react';
22
import SetupModal from './components/SetupModal';
33
import ChatInterface from './components/ChatInterface';
44
import LoadingScreen from './components/LoadingScreen';
5+
import DownloadingModelScreen from './components/DownloadingModelScreen';
56
import UserProfileSelector from './components/UserProfileSelector';
67
import ErrorScreen from './components/ErrorScreen';
78
import { useAppState } from './hooks/useAppState';
@@ -27,6 +28,8 @@ function App() {
2728
setupStatus,
2829
showSettingsModal,
2930
transitionToChat,
31+
transitionToUserSelection,
32+
transitionToError,
3033
closeSettings,
3134
switchProfile,
3235
handleModelReady,
@@ -113,6 +116,19 @@ function App() {
113116

114117
// Render based on current view state
115118
switch (view.type) {
119+
case 'downloading-model':
120+
return (
121+
<DownloadingModelScreen
122+
url={view.url}
123+
outputPath={view.outputPath}
124+
onComplete={async () => {
125+
handleModelReady(view.outputPath);
126+
await transitionToUserSelection();
127+
}}
128+
onError={(message) => transitionToError(message)}
129+
/>
130+
);
131+
116132
case 'user-selection':
117133
return (
118134
<div className={`App ${showSettingsModal ? 'dimmed' : ''}`}>
Lines changed: 78 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,78 @@
1+
.downloading-model-screen {
2+
position: fixed;
3+
top: 0;
4+
left: 0;
5+
width: 100%;
6+
height: 100%;
7+
display: flex;
8+
align-items: center;
9+
justify-content: center;
10+
background: var(--color-bg);
11+
z-index: 9999;
12+
}
13+
14+
.downloading-model-content {
15+
text-align: center;
16+
animation: fadeIn 0.6s ease-in;
17+
}
18+
19+
.downloading-model-title {
20+
font-size: 4em;
21+
margin: 0;
22+
background: var(--color-primary);
23+
-webkit-background-clip: text;
24+
-webkit-text-fill-color: transparent;
25+
background-clip: text;
26+
font-weight: 600;
27+
}
28+
29+
.downloading-model-heading {
30+
color: var(--color-text);
31+
font-size: 1.5em;
32+
margin: 1.5em 0 0.5em;
33+
font-weight: 500;
34+
}
35+
36+
.downloading-model-subtitle {
37+
color: var(--color-text-muted);
38+
font-size: 1.1em;
39+
margin: 0.5em 0 2em;
40+
font-weight: 300;
41+
}
42+
43+
.downloading-model-spinner {
44+
width: 40px;
45+
height: 40px;
46+
border: 3px solid var(--color-border-subtle);
47+
border-top: 3px solid var(--color-primary);
48+
border-radius: 50%;
49+
animation: spin 1s linear infinite;
50+
margin: 2rem auto 0;
51+
}
52+
53+
@keyframes fadeIn {
54+
from {
55+
opacity: 0;
56+
transform: translateY(20px);
57+
}
58+
to {
59+
opacity: 1;
60+
transform: translateY(0);
61+
}
62+
}
63+
64+
@keyframes spin {
65+
0% { transform: rotate(0deg); }
66+
100% { transform: rotate(360deg); }
67+
}
68+
69+
@media (prefers-color-scheme: dark) {
70+
.downloading-model-subtitle {
71+
color: var(--color-text-muted);
72+
}
73+
74+
.downloading-model-spinner {
75+
border-color: var(--color-border);
76+
border-top-color: var(--color-primary);
77+
}
78+
}
Lines changed: 48 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,48 @@
1+
import { useEffect, useState } from 'react';
2+
import { invoke } from '@tauri-apps/api/core';
3+
import { useTranslation } from '../i18n/hooks/useTranslation';
4+
import './DownloadingModelScreen.css';
5+
6+
interface DownloadingModelScreenProps {
7+
url: string;
8+
outputPath: string;
9+
onComplete: () => void;
10+
onError: (message: string) => void;
11+
}
12+
13+
export default function DownloadingModelScreen({
14+
url,
15+
outputPath,
16+
onComplete,
17+
onError,
18+
}: DownloadingModelScreenProps) {
19+
const { t } = useTranslation(null);
20+
const [started, setStarted] = useState(false);
21+
22+
useEffect(() => {
23+
if (started) return;
24+
setStarted(true);
25+
26+
(async () => {
27+
try {
28+
await invoke('download_model', { url, outputPath });
29+
await invoke('initialize_model', { modelPath: outputPath });
30+
onComplete();
31+
} catch (err) {
32+
const message = err instanceof Error ? err.message : 'Failed to download or load model';
33+
onError(message);
34+
}
35+
})();
36+
}, [url, outputPath, onComplete, onError, started]);
37+
38+
return (
39+
<div className="downloading-model-screen">
40+
<div className="downloading-model-content">
41+
<h1 className="downloading-model-title">{t('ui.appName')}</h1>
42+
<p className="downloading-model-heading">{t('ui.downloadingModelTitle')}</p>
43+
<p className="downloading-model-subtitle">{t('ui.downloadingModelSubtitle')}</p>
44+
<div className="downloading-model-spinner" aria-hidden="true" />
45+
</div>
46+
</div>
47+
);
48+
}

desktop/src/hooks/useAppState.ts

Lines changed: 28 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,7 @@ export interface AppUser {
1111

1212
export type AppView =
1313
| { type: 'loading' }
14+
| { type: 'downloading-model'; url: string; outputPath: string }
1415
| { type: 'user-selection'; preloadedUsers?: AppUser[] | null }
1516
| { type: 'chat', userId: string }
1617
| { type: 'error', message: string, retry?: () => void; onContinue?: () => void };
@@ -242,11 +243,34 @@ export function useAppState() {
242243
if (!setupStatus.modelReady || !setupStatus.globalKBReady) {
243244
try {
244245
logAppTiming('ensure_bundled_defaults_initialized start');
245-
await invoke<{ model_ready: boolean; kb_ready: boolean }>(
246-
'ensure_bundled_defaults_initialized'
247-
);
248-
setupStatus = await checkSetup();
246+
const bundled = await invoke<{
247+
model_ready: boolean;
248+
kb_ready: boolean;
249+
default_model_download_url?: string;
250+
default_model_output_path?: string;
251+
}>('ensure_bundled_defaults_initialized');
249252
logAppTiming('ensure_bundled_defaults_initialized done');
253+
254+
// If no model but we can auto-download, show downloading screen
255+
if (
256+
!bundled.model_ready &&
257+
bundled.default_model_download_url &&
258+
bundled.default_model_output_path
259+
) {
260+
logViewEntered('downloading-model');
261+
setState(prev => ({
262+
...prev,
263+
view: {
264+
type: 'downloading-model',
265+
url: bundled.default_model_download_url,
266+
outputPath: bundled.default_model_output_path,
267+
},
268+
setupStatus: { ...prev.setupStatus, isChecking: false },
269+
}));
270+
return;
271+
}
272+
273+
setupStatus = await checkSetup();
250274
} catch (err) {
251275
console.warn('Bundled defaults init failed:', err);
252276
}

desktop/src/i18n/translations/en.json

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -62,6 +62,8 @@
6262
"appName": "Confidant",
6363
"loadingSubtitle": "Your offline AI assistant",
6464
"loadingTagline": "Mental health support that stays private",
65+
"downloadingModelTitle": "Downloading default model",
66+
"downloadingModelSubtitle": "First-time setup. This may take a few minutes (~2.5 GB).",
6567
"modelManagerPrompt": "Please initialize a model first in the Model Manager tab.",
6668
"userFallback": "User",
6769
"userSettings": "User Settings",

desktop/src/i18n/translations/es.json

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -62,6 +62,8 @@
6262
"appName": "Confidant",
6363
"loadingSubtitle": "Tu asistente de IA sin conexión",
6464
"loadingTagline": "Apoyo en salud mental que se queda en privado",
65+
"downloadingModelTitle": "Descargando modelo predeterminado",
66+
"downloadingModelSubtitle": "Configuración inicial. Puede tardar unos minutos (~2,5 GB).",
6567
"modelManagerPrompt": "Inicializa primero un modelo en la pestaña del Gestor de modelos.",
6668
"userFallback": "Usuario",
6769
"userSettings": "Ajustes de usuario",

0 commit comments

Comments
 (0)