offgrid-ai 0.18.1 → 0.18.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "offgrid-ai",
3
- "version": "0.18.1",
3
+ "version": "0.18.2",
4
4
  "description": "Privacy-first CLI for running local LLMs — discover, configure, run, and chat",
5
5
  "author": "Eeshan Srivastava (https://eeshans.com)",
6
6
  "type": "module",
package/src/download.mjs CHANGED
@@ -1,7 +1,7 @@
1
1
  // Model download flow — HuggingFace downloads with quant picker and RAM fit.
2
2
  // Used by onboarding (no models found) and the model picker (↓ Download a model).
3
3
 
4
- import { hasHfCli, parseHfRef, resolveHfDownload, downloadModel, listGgufFiles, getHfModelInfo, isMlxRepo } from "./huggingface.mjs";
4
+ import { hasHfCli, parseHfRef, resolveHfDownload, downloadModel, listGgufFiles, listMmprojFiles, getHfModelInfo, isMlxRepo } from "./huggingface.mjs";
5
5
  import { detectHardware, installedRamGB, getFreeDiskBytes } from "./hardware.mjs";
6
6
  import { allFittingModels } from "./recommendations.mjs";
7
7
  import { parseModelName } from "./model-name.mjs";
@@ -140,6 +140,22 @@ export async function downloadFlow(prompt) {
140
140
  return false;
141
141
  }
142
142
 
143
+ // For GGUF, check if the repo has a vision projector (mmproj) to download alongside
144
+ let extraFiles = [];
145
+ if (plan.format === "gguf") {
146
+ try {
147
+ const mmprojFiles = await listMmprojFiles(repo);
148
+ if (mmprojFiles.length > 0) {
149
+ const mmproj = mmprojFiles[0];
150
+ extraFiles = [mmproj.path];
151
+ plan.totalSizeBytes += mmproj.sizeBytes;
152
+ console.log(pc.dim(`Includes vision projector: ${mmproj.path} (${formatBytes(mmproj.sizeBytes)})`));
153
+ }
154
+ } catch {
155
+ // If we can't check for mmproj, proceed without it
156
+ }
157
+ }
158
+
143
159
  console.log(pc.dim(`\nDownloading ${repo}${filename ? `/${filename}` : ""} (${formatBytes(plan.totalSizeBytes)})`));
144
160
  if (plan.format === "mlx") {
145
161
  const modelParts = repo.split("/").filter(Boolean);
@@ -158,7 +174,7 @@ export async function downloadFlow(prompt) {
158
174
  console.log(pc.green("\n✓ Download complete."));
159
175
  await offerOmlxRestart(prompt, "to load the new model");
160
176
  } else {
161
- await downloadModel(plan);
177
+ await downloadModel(plan, { extraFiles });
162
178
  console.log(pc.green("\n✓ Download complete. Run offgrid-ai again to see the model in the picker."));
163
179
  }
164
180
  return true;
@@ -92,11 +92,11 @@ async function getHfTree(repo, { branch = "main", fetchImpl = globalThis.fetch }
92
92
  return await response.json();
93
93
  }
94
94
 
95
- /** List all GGUF files in a HuggingFace repo with their sizes (excludes MTP drafters). */
95
+ /** List all GGUF files in a HuggingFace repo with their sizes (excludes MTP drafters and vision projectors). */
96
96
  export async function listGgufFiles(repo, { fetchImpl = globalThis.fetch } = {}) {
97
97
  const tree = await getHfTree(repo, { fetchImpl });
98
98
  return tree
99
- .filter((f) => f.type === "file" && f.path.endsWith(".gguf") && !isDrafterFile(f.path))
99
+ .filter((f) => f.type === "file" && f.path.endsWith(".gguf") && !isDrafterFile(f.path) && !isMmprojFile(f.path))
100
100
  .map((f) => ({
101
101
  path: f.path,
102
102
  sizeBytes: f.lfs?.size ?? f.size ?? 0,
@@ -116,6 +116,23 @@ function isDrafterFile(path) {
116
116
  return false;
117
117
  }
118
118
 
119
+ /** Check if a GGUF file is a vision projector (mmproj) based on its name. */
120
+ function isMmprojFile(path) {
121
+ const name = path.split("/").pop() ?? path;
122
+ return /mmproj/i.test(name);
123
+ }
124
+
125
+ /** List all mmproj (vision projector) GGUF files in a HuggingFace repo. */
126
+ export async function listMmprojFiles(repo, { fetchImpl = globalThis.fetch } = {}) {
127
+ const tree = await getHfTree(repo, { fetchImpl });
128
+ return tree
129
+ .filter((f) => f.type === "file" && f.path.endsWith(".gguf") && isMmprojFile(f.path))
130
+ .map((f) => ({
131
+ path: f.path,
132
+ sizeBytes: f.lfs?.size ?? f.size ?? 0,
133
+ }));
134
+ }
135
+
119
136
  /** Fetch model metadata from the HF API. */
120
137
  export async function getHfModelInfo(repo, { fetchImpl = globalThis.fetch } = {}) {
121
138
  const url = `https://huggingface.co/api/models/${repo}`;
@@ -178,6 +195,7 @@ export async function resolveHfDownload(input, { fetchImpl = globalThis.fetch }
178
195
  * @param {object} model - from resolveHfDownload
179
196
  * @param {object} [options]
180
197
  * @param {string} [options.localDir] - for MLX: target directory
198
+ * @param {string[]} [options.extraFiles] - additional files to download (e.g. mmproj)
181
199
  * @returns {Promise<{ localDir: string, format: string }>}
182
200
  */
183
201
  export async function downloadModel(model, options = {}) {
@@ -188,6 +206,10 @@ export async function downloadModel(model, options = {}) {
188
206
  // Single file to HF cache — scanner finds it there
189
207
  await mkdir(HF_HUB_DIR, { recursive: true });
190
208
  args.push(model.files[0].filename, "--cache-dir", HF_HUB_DIR);
209
+ // Include additional files (e.g. vision projector) in the same download
210
+ for (const file of options.extraFiles ?? []) {
211
+ args.push(file);
212
+ }
191
213
  localDir = HF_HUB_DIR;
192
214
  } else if (options.localDir) {
193
215
  // Full repo to a flat local directory (oMLX)