Studio: show the llama.cpp update banner sooner and keep it until dismissed (#6162)
Follow-up to #6097. The update banner appeared 8s after load and auto-hid after about 10s. Show it about 1s after a newer prebuilt is detected and keep it up until the user dismisses it (click outside or the X) or runs the update; it stays during an in-progress update so the progress is visible. Co-authored-by: danielhanchen <michaelhan2050@gmail.com>
This commit is contained in:
parent
50e4e9c459
commit
aa3b46f4c1
2 changed files with 59 additions and 50 deletions
|
|
@ -5,7 +5,7 @@ import { Button } from "@/components/ui/button";
|
|||
import { useLlamaUpdateCheck } from "@/hooks/use-llama-update-check";
|
||||
import { toast } from "@/lib/toast";
|
||||
import { AnimatePresence, motion } from "motion/react";
|
||||
import { type ReactElement } from "react";
|
||||
import { type ReactElement, useEffect, useRef } from "react";
|
||||
|
||||
const EASE_OUT_QUART: [number, number, number, number] = [0.165, 0.84, 0.44, 1];
|
||||
|
||||
|
|
@ -14,9 +14,9 @@ interface LlamaUpdateBannerProps {
|
|||
}
|
||||
|
||||
/**
|
||||
* Non-invasive "Update llama.cpp" affordance. Appears bottom-right when a newer
|
||||
* prebuilt is available, fades on its own after ~10s, and re-surfaces hourly.
|
||||
* Clicking Update swaps the prebuilt in place via POST /api/llama/update.
|
||||
* Non-invasive "Update llama.cpp" affordance. Appears bottom-right ~1s after a
|
||||
* newer prebuilt is detected and stays up until dismissed (click outside / X)
|
||||
* or updated. Clicking Update swaps the prebuilt in place via POST /api/llama/update.
|
||||
*/
|
||||
export function LlamaUpdateBanner({
|
||||
enabled = true,
|
||||
|
|
@ -32,16 +32,38 @@ export function LlamaUpdateBanner({
|
|||
`llama.cpp updated to ${result.tag ?? "the latest build"}. Reload your model to use it.`,
|
||||
);
|
||||
} else if (result) {
|
||||
toast.error(`llama.cpp update failed: ${result.error ?? "unknown error"}`);
|
||||
toast.error(
|
||||
`llama.cpp update failed: ${result.error ?? "unknown error"}`,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
const show = visible && status != null && (status.update_available || applying);
|
||||
const show =
|
||||
visible && status != null && (status.update_available || applying);
|
||||
const bannerRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
// Dismiss when the user clicks anything outside the banner. Kept off while an
|
||||
// update is applying so the progress stays visible.
|
||||
useEffect(() => {
|
||||
if (!show || applying) return;
|
||||
function onPointerDown(event: PointerEvent) {
|
||||
if (
|
||||
bannerRef.current &&
|
||||
!bannerRef.current.contains(event.target as Node)
|
||||
) {
|
||||
dismiss();
|
||||
}
|
||||
}
|
||||
document.addEventListener("pointerdown", onPointerDown, true);
|
||||
return () =>
|
||||
document.removeEventListener("pointerdown", onPointerDown, true);
|
||||
}, [show, applying, dismiss]);
|
||||
|
||||
return (
|
||||
<AnimatePresence>
|
||||
{show ? (
|
||||
<motion.div
|
||||
ref={bannerRef}
|
||||
initial={{ opacity: 0, y: 12, scale: 0.96 }}
|
||||
animate={{ opacity: 1, y: 0, scale: 1 }}
|
||||
exit={{ opacity: 0, y: 8, scale: 0.97 }}
|
||||
|
|
@ -50,7 +72,7 @@ export function LlamaUpdateBanner({
|
|||
data-testid="llama-update-banner"
|
||||
>
|
||||
<div className="corner-squircle relative overflow-hidden border border-border/60 bg-background/95 px-4 py-3 shadow-lg backdrop-blur-md">
|
||||
{!applying ? (
|
||||
{applying ? null : (
|
||||
<button
|
||||
type="button"
|
||||
onClick={dismiss}
|
||||
|
|
@ -73,7 +95,7 @@ export function LlamaUpdateBanner({
|
|||
/>
|
||||
</svg>
|
||||
</button>
|
||||
) : null}
|
||||
)}
|
||||
|
||||
<div className="flex items-center gap-2 pr-5">
|
||||
<span className="text-base" aria-hidden="true">
|
||||
|
|
|
|||
|
|
@ -4,12 +4,10 @@
|
|||
import { authFetch, getAuthToken } from "@/features/auth";
|
||||
import { useCallback, useEffect, useRef, useState } from "react";
|
||||
|
||||
// First check shortly after load, then re-surface as an hourly reminder.
|
||||
const FIRST_CHECK_DELAY_MS = 8000;
|
||||
// First check shortly after load, then re-surface as an hourly reminder. The
|
||||
// banner stays up until the user dismisses it (click outside / X) or updates.
|
||||
const FIRST_CHECK_DELAY_MS = 1000;
|
||||
const REMINDER_INTERVAL_MS = 60 * 60 * 1000; // ~1 hour
|
||||
// The banner fades on its own after this long (non-invasive). It re-appears on
|
||||
// the next hourly reminder while an update is still available.
|
||||
const AUTO_HIDE_MS = 10000;
|
||||
// While an update is applying, poll the job state at this cadence.
|
||||
const JOB_POLL_INTERVAL_MS = 3000;
|
||||
|
||||
|
|
@ -48,7 +46,9 @@ function parseStatus(value: unknown): LlamaUpdateStatus | null {
|
|||
};
|
||||
}
|
||||
|
||||
async function fetchStatus(forceRefresh = false): Promise<LlamaUpdateStatus | null> {
|
||||
async function fetchStatus(
|
||||
forceRefresh = false,
|
||||
): Promise<LlamaUpdateStatus | null> {
|
||||
if (!getAuthToken()) return null;
|
||||
try {
|
||||
const res = await authFetch(
|
||||
|
|
@ -73,28 +73,18 @@ export interface LlamaApplyResult {
|
|||
|
||||
/**
|
||||
* Polls the backend for a newer llama.cpp prebuilt. When one exists, `visible`
|
||||
* is true for a 10s window (fades on its own), and re-surfaces every ~hour as a
|
||||
* gentle reminder. `apply()` triggers the in-place swap and tracks the job.
|
||||
* becomes true ~1s after load and stays up until the user dismisses it (click
|
||||
* outside / X) or updates; it re-surfaces every ~hour as a reminder. `apply()`
|
||||
* triggers the in-place swap and tracks the job.
|
||||
*/
|
||||
export function useLlamaUpdateCheck({ enabled = true }: UseLlamaUpdateCheckOptions = {}) {
|
||||
export function useLlamaUpdateCheck({
|
||||
enabled = true,
|
||||
}: UseLlamaUpdateCheckOptions = {}) {
|
||||
const [status, setStatus] = useState<LlamaUpdateStatus | null>(null);
|
||||
const [visible, setVisible] = useState(false);
|
||||
const [applying, setApplying] = useState(false);
|
||||
const hideTimer = useRef<ReturnType<typeof setTimeout> | null>(null);
|
||||
const pollTimer = useRef<ReturnType<typeof setInterval> | null>(null);
|
||||
|
||||
const clearHideTimer = useCallback(() => {
|
||||
if (hideTimer.current) {
|
||||
clearTimeout(hideTimer.current);
|
||||
hideTimer.current = null;
|
||||
}
|
||||
}, []);
|
||||
|
||||
const armAutoHide = useCallback(() => {
|
||||
clearHideTimer();
|
||||
hideTimer.current = setTimeout(() => setVisible(false), AUTO_HIDE_MS);
|
||||
}, [clearHideTimer]);
|
||||
|
||||
const clearPollTimer = useCallback(() => {
|
||||
if (pollTimer.current) {
|
||||
clearInterval(pollTimer.current);
|
||||
|
|
@ -118,20 +108,19 @@ export function useLlamaUpdateCheck({ enabled = true }: UseLlamaUpdateCheckOptio
|
|||
setVisible(false);
|
||||
onDone?.({ ok: true, tag: s.job.to_tag });
|
||||
} else if (s.job.state === "error") {
|
||||
armAutoHide();
|
||||
// Leave the banner up so the user can retry; clearing applying drops
|
||||
// the "Updating..." state.
|
||||
onDone?.({ ok: false, error: s.job.error });
|
||||
} else {
|
||||
// idle without a terminal result (job reset): stop so the banner
|
||||
// does not stick on "Updating...".
|
||||
armAutoHide();
|
||||
// idle without a terminal result (job reset): stop tracking.
|
||||
onDone?.({ ok: false, error: "update did not complete" });
|
||||
}
|
||||
}, JOB_POLL_INTERVAL_MS);
|
||||
},
|
||||
[armAutoHide, clearPollTimer],
|
||||
[clearPollTimer],
|
||||
);
|
||||
|
||||
// Surface the banner for the auto-hide window when an update is available.
|
||||
// Surface the banner when an update is available; it stays up until dismissed.
|
||||
const surfaceIfAvailable = useCallback(
|
||||
(next: LlamaUpdateStatus | null) => {
|
||||
if (!next) return;
|
||||
|
|
@ -141,16 +130,14 @@ export function useLlamaUpdateCheck({ enabled = true }: UseLlamaUpdateCheckOptio
|
|||
// job so "Updating..." clears when it finishes instead of sticking.
|
||||
setApplying(true);
|
||||
setVisible(true);
|
||||
clearHideTimer();
|
||||
if (!pollTimer.current) startJobPoll();
|
||||
return;
|
||||
}
|
||||
if (next.update_available) {
|
||||
setVisible(true);
|
||||
armAutoHide();
|
||||
}
|
||||
},
|
||||
[armAutoHide, clearHideTimer, startJobPoll],
|
||||
[startJobPoll],
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
|
|
@ -173,21 +160,18 @@ export function useLlamaUpdateCheck({ enabled = true }: UseLlamaUpdateCheckOptio
|
|||
canceled = true;
|
||||
clearTimeout(firstTimer);
|
||||
clearInterval(reminder);
|
||||
clearHideTimer();
|
||||
clearPollTimer();
|
||||
};
|
||||
}, [enabled, surfaceIfAvailable, clearHideTimer, clearPollTimer]);
|
||||
}, [enabled, surfaceIfAvailable, clearPollTimer]);
|
||||
|
||||
const dismiss = useCallback(() => {
|
||||
clearHideTimer();
|
||||
setVisible(false);
|
||||
}, [clearHideTimer]);
|
||||
}, []);
|
||||
|
||||
const apply = useCallback(async (): Promise<LlamaApplyResult> => {
|
||||
if (applying) return { ok: false, error: "already running" };
|
||||
setApplying(true);
|
||||
setVisible(true);
|
||||
clearHideTimer();
|
||||
let action: {
|
||||
started?: boolean;
|
||||
reason?: string | null;
|
||||
|
|
@ -197,7 +181,6 @@ export function useLlamaUpdateCheck({ enabled = true }: UseLlamaUpdateCheckOptio
|
|||
const res = await authFetch("/api/llama/update", { method: "POST" });
|
||||
if (!res.ok) {
|
||||
setApplying(false);
|
||||
armAutoHide();
|
||||
return { ok: false, error: `HTTP ${res.status}` };
|
||||
}
|
||||
try {
|
||||
|
|
@ -207,24 +190,28 @@ export function useLlamaUpdateCheck({ enabled = true }: UseLlamaUpdateCheckOptio
|
|||
}
|
||||
} catch (e) {
|
||||
setApplying(false);
|
||||
armAutoHide();
|
||||
return { ok: false, error: String(e) };
|
||||
}
|
||||
|
||||
// 200 without a started job (no marker / installer missing) leaves it idle,
|
||||
// so surface the reason instead of polling forever. already_running is the
|
||||
// exception: a job is in flight, so track it to completion below.
|
||||
if (action && action.started === false && action.reason !== "already_running") {
|
||||
if (
|
||||
action &&
|
||||
action.started === false &&
|
||||
action.reason !== "already_running"
|
||||
) {
|
||||
setApplying(false);
|
||||
armAutoHide();
|
||||
return {
|
||||
ok: false,
|
||||
error: action.message ?? action.reason ?? "update was not started",
|
||||
};
|
||||
}
|
||||
|
||||
return await new Promise<LlamaApplyResult>((resolve) => startJobPoll(resolve));
|
||||
}, [applying, armAutoHide, clearHideTimer, startJobPoll]);
|
||||
return await new Promise<LlamaApplyResult>((resolve) =>
|
||||
startJobPoll(resolve),
|
||||
);
|
||||
}, [applying, startJobPoll]);
|
||||
|
||||
return {
|
||||
status: enabled ? status : null,
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue