feat: first-run wizard pulls models with a real progress bar
New pull_model command streams Ollama /api/pull NDJSON over a Channel (status lines + completed/total bytes). The wizard's model step gains a 'type any tag to pull' input: uninstalled models download in-app with a progress bar instead of the old 'run ollama pull yourself' dead end. The NDJSON line-loop is factored out of the chat streaming path and shared.
This commit is contained in:
@@ -1,13 +1,26 @@
|
||||
import { useEffect, useState } from "react";
|
||||
import { invoke } from "@tauri-apps/api/core";
|
||||
import { invoke, Channel } from "@tauri-apps/api/core";
|
||||
import { useToast } from "./Toast";
|
||||
|
||||
// ponytail: mirrors the Rust PullEvent enum (adjacently tagged, camelCase).
|
||||
type PullEvent =
|
||||
| { type: "progress"; data: { completed: number | null; total: number | null } }
|
||||
| { type: "status"; data: string }
|
||||
| { type: "done" }
|
||||
| { type: "error"; data: string };
|
||||
|
||||
export function FirstRunWizard({ onComplete }: { onComplete: () => void }) {
|
||||
const [step, setStep] = useState("welcome");
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [error, setError] = useState("");
|
||||
const [models, setModels] = useState<string[]>([]);
|
||||
const [selectedModel, setSelectedModel] = useState<string>("");
|
||||
// ponytail: custom tag not in the installed list — pulled in-wizard with a
|
||||
// progress bar instead of the old "run ollama pull yourself" dead end.
|
||||
const [customModel, setCustomModel] = useState("");
|
||||
const [pulling, setPulling] = useState(false);
|
||||
const [pullPct, setPullPct] = useState<number | null>(null);
|
||||
const [pullStatus, setPullStatus] = useState("");
|
||||
// ponytail: wizard defaults to local Ollama; no API-key UI here (Settings covers remote).
|
||||
const apiUrl = "http://localhost:11434";
|
||||
const apiKey = "";
|
||||
@@ -42,20 +55,18 @@ export function FirstRunWizard({ onComplete }: { onComplete: () => void }) {
|
||||
setLoading(false);
|
||||
}
|
||||
|
||||
async function pullModel(model: string) {
|
||||
// ponytail: save the chosen model and finish — no pull needed when it's
|
||||
// already installed.
|
||||
async function saveModelAndFinish(model: string) {
|
||||
setLoading(true);
|
||||
setError("");
|
||||
try {
|
||||
// We don't have a direct pull command, but we can suggest the user to run `ollama pull` in terminal.
|
||||
// For now, we'll just set the model in config and hope it exists.
|
||||
// Alternatively, we could invoke a generate command to trigger a pull? Not sure.
|
||||
// We'll just set the config and complete.
|
||||
await invoke("set_llm_config", {
|
||||
config: {
|
||||
provider: "ollama",
|
||||
api_url: apiUrl,
|
||||
api_key: apiKey,
|
||||
model: model,
|
||||
model,
|
||||
temperature: 0.7,
|
||||
max_tokens: 512,
|
||||
top_p: 0.9,
|
||||
@@ -63,7 +74,6 @@ export function FirstRunWizard({ onComplete }: { onComplete: () => void }) {
|
||||
embed_model: "nomic-embed-text",
|
||||
}
|
||||
});
|
||||
addToast(`Model ${model} selected. You may need to run 'ollama pull ${model}' if not already downloaded.`, "success");
|
||||
setStep("done");
|
||||
} catch (e) {
|
||||
setError(String(e));
|
||||
@@ -72,6 +82,42 @@ export function FirstRunWizard({ onComplete }: { onComplete: () => void }) {
|
||||
setLoading(false);
|
||||
}
|
||||
|
||||
// Real `ollama pull` over the Channel — progress bar driven by the
|
||||
// completed/total bytes Ollama streams; falls back to the status line.
|
||||
async function pullAndFinish(model: string) {
|
||||
setPulling(true);
|
||||
setError("");
|
||||
setPullPct(null);
|
||||
setPullStatus("starting pull…");
|
||||
const channel = new Channel<PullEvent>();
|
||||
let failed = false;
|
||||
channel.onmessage = (ev) => {
|
||||
if (ev.type === "progress") {
|
||||
setPullStatus("");
|
||||
setPullPct(ev.data.completed != null && ev.data.total ? ev.data.completed / ev.data.total : null);
|
||||
} else if (ev.type === "status") {
|
||||
setPullStatus(ev.data);
|
||||
setPullPct(null);
|
||||
} else if (ev.type === "done") {
|
||||
addToast(`Model ${model} pulled`, "success");
|
||||
saveModelAndFinish(model);
|
||||
setPulling(false);
|
||||
} else if (ev.type === "error") {
|
||||
failed = true;
|
||||
setError(`Pull failed: ${ev.data}`);
|
||||
setPulling(false);
|
||||
}
|
||||
};
|
||||
try {
|
||||
await invoke("pull_model", { req: { model }, channel });
|
||||
} catch (e) {
|
||||
if (!failed) {
|
||||
setError(`Pull failed: ${e}`);
|
||||
setPulling(false);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function skip() {
|
||||
// Skip wizard and go to settings
|
||||
onComplete();
|
||||
@@ -93,11 +139,17 @@ export function FirstRunWizard({ onComplete }: { onComplete: () => void }) {
|
||||
return;
|
||||
}
|
||||
if (step === "model") {
|
||||
if (!selectedModel) {
|
||||
setError("Please select a model");
|
||||
const model = customModel.trim() || selectedModel;
|
||||
if (!model) {
|
||||
setError("Choose a model or type a tag to pull");
|
||||
return;
|
||||
}
|
||||
await pullModel(selectedModel);
|
||||
// Installed → save directly; anything else gets pulled first.
|
||||
if (models.includes(model)) {
|
||||
await saveModelAndFinish(model);
|
||||
} else {
|
||||
await pullAndFinish(model);
|
||||
}
|
||||
return;
|
||||
}
|
||||
if (step === "done") {
|
||||
@@ -193,7 +245,7 @@ export function FirstRunWizard({ onComplete }: { onComplete: () => void }) {
|
||||
<label className="text-[var(--color-text-secondary)] text-xs font-medium">Model</label>
|
||||
<select
|
||||
value={selectedModel}
|
||||
onChange={(e) => setSelectedModel(e.target.value)}
|
||||
onChange={(e) => { setSelectedModel(e.target.value); setCustomModel(""); }}
|
||||
className="rounded-lg bg-[var(--color-bg-surface)] border border-[var(--color-border-glass)] px-3 py-2 text-[var(--color-text-primary)] focus:outline-none focus:border-[var(--color-gold-bright)] text-xs cursor-pointer"
|
||||
>
|
||||
{models.map((m) => (
|
||||
@@ -204,13 +256,37 @@ export function FirstRunWizard({ onComplete }: { onComplete: () => void }) {
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
<div className="flex flex-col gap-3">
|
||||
<div className="flex flex-col gap-1">
|
||||
<label className="text-[var(--color-text-secondary)] text-xs font-medium">Or type any Ollama tag to pull</label>
|
||||
<input
|
||||
value={customModel}
|
||||
onChange={(e) => { setCustomModel(e.target.value); setSelectedModel(""); }}
|
||||
placeholder="e.g. llama3.2"
|
||||
className="rounded-lg bg-[var(--color-bg-surface)] border border-[var(--color-border-glass)] px-3 py-2 text-[var(--color-text-primary)] placeholder-[var(--color-text-dim)] focus:outline-none focus:border-[var(--color-gold-bright)] text-xs"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
{pulling ? (
|
||||
<div className="flex flex-col gap-2 mt-4">
|
||||
<p className="text-[var(--color-text-secondary)] text-xs">Pulling {customModel.trim() || selectedModel} …</p>
|
||||
<div className="w-full h-2 rounded-full bg-[var(--color-bg-surface)] overflow-hidden">
|
||||
<div
|
||||
className="h-full bg-[var(--color-gold-bright)] transition-all duration-300"
|
||||
style={{ width: pullPct != null ? `${Math.round(pullPct * 100)}%` : "100%" }}
|
||||
/>
|
||||
</div>
|
||||
<p className="text-[var(--color-text-dim)] text-xs">
|
||||
{pullPct != null ? `${Math.round(pullPct * 100)}%` : pullStatus}
|
||||
</p>
|
||||
</div>
|
||||
) : (
|
||||
<div className="flex flex-col gap-3 mt-4">
|
||||
<button
|
||||
onClick={handleSubmit}
|
||||
disabled={loading || !selectedModel}
|
||||
disabled={loading || (!customModel.trim() && !selectedModel)}
|
||||
className="rounded-lg bg-[var(--color-gold-bright)] text-[var(--color-bg-deep)] px-4 py-2 text-sm font-semibold hover:bg-[var(--color-gold-muted)] transition-colors cursor-pointer disabled:opacity-50"
|
||||
>
|
||||
{loading ? "Setting model…" : "Set Model"}
|
||||
{loading ? "Setting model…" : "Use Model"}
|
||||
</button>
|
||||
<button
|
||||
onClick={back}
|
||||
@@ -225,14 +301,18 @@ export function FirstRunWizard({ onComplete }: { onComplete: () => void }) {
|
||||
Skip for now (go to Settings)
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
{error && (
|
||||
<p className="text-[var(--color-danger)] text-sm mt-4">{error}</p>
|
||||
)}
|
||||
<p className="text-[var(--color-text-dim)] text-sm mt-4">
|
||||
If you don't see your model, you may need to download it first. In a terminal, run:{' '}
|
||||
<code className="font-mono text-[var(--color-gold-bright)]">ollama pull llama3.2</code>
|
||||
</p>
|
||||
{!pulling && (
|
||||
<p className="text-[var(--color-text-dim)] text-xs mt-4">
|
||||
Models not in the list are pulled automatically — type any Ollama tag
|
||||
(e.g. <code className="font-mono text-[var(--color-gold-bright)]">llama3.2</code>, or
|
||||
<code className="font-mono text-[var(--color-gold-bright)]">nomic-embed-text</code> for
|
||||
lore search) and DM-Pal downloads it with a progress bar.
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user