feat: add local AI captions and queue controls
This commit is contained in:
@@ -7,17 +7,20 @@ import { Gallery } from "./components/Gallery";
|
||||
import { Lightbox } from "./components/Lightbox";
|
||||
import { TagCloud } from "./components/TagCloud";
|
||||
import { TitleBar } from "./components/TitleBar";
|
||||
import { SettingsModal } from "./components/SettingsModal";
|
||||
|
||||
export default function App() {
|
||||
const loadFolders = useGalleryStore((state) => state.loadFolders);
|
||||
const loadBackgroundJobProgress = useGalleryStore((state) => state.loadBackgroundJobProgress);
|
||||
const loadImages = useGalleryStore((state) => state.loadImages);
|
||||
const loadCaptionModelStatus = useGalleryStore((state) => state.loadCaptionModelStatus);
|
||||
const subscribeToProgress = useGalleryStore((state) => state.subscribeToProgress);
|
||||
const activeView = useGalleryStore((state) => state.activeView);
|
||||
|
||||
useEffect(() => {
|
||||
loadFolders().then(() => {
|
||||
void loadBackgroundJobProgress();
|
||||
void loadCaptionModelStatus();
|
||||
return loadImages(true);
|
||||
});
|
||||
let unlisten: (() => void) | undefined;
|
||||
@@ -54,6 +57,7 @@ export default function App() {
|
||||
</div>
|
||||
|
||||
<Lightbox />
|
||||
<SettingsModal />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -2,12 +2,13 @@ import { useEffect, useMemo, useState } from "react";
|
||||
import { invoke } from "@tauri-apps/api/core";
|
||||
import { useGalleryStore } from "../store";
|
||||
|
||||
type WorkerKey = "thumbnail" | "metadata" | "embedding";
|
||||
type WorkerKey = "thumbnail" | "metadata" | "embedding" | "caption";
|
||||
|
||||
const WORKER_FOR_STAGE: Record<string, WorkerKey> = {
|
||||
Thumbnails: "thumbnail",
|
||||
Metadata: "metadata",
|
||||
Embeddings: "embedding",
|
||||
Captions: "caption",
|
||||
};
|
||||
|
||||
interface TaskStage {
|
||||
@@ -22,6 +23,7 @@ interface Task {
|
||||
name: string;
|
||||
stages: TaskStage[];
|
||||
hasFailedEmbeddings: boolean;
|
||||
hasFailedCaptions: boolean;
|
||||
pendingMediaWork: number;
|
||||
embeddingProcessed: number;
|
||||
embeddingTotal: number;
|
||||
@@ -35,6 +37,21 @@ interface FailedEmbeddingItem {
|
||||
error: string | null;
|
||||
}
|
||||
|
||||
interface FolderWorkerStates {
|
||||
folder_id: number;
|
||||
thumbnail_paused: boolean;
|
||||
metadata_paused: boolean;
|
||||
embedding_paused: boolean;
|
||||
caption_paused: boolean;
|
||||
}
|
||||
|
||||
const DEFAULT_PAUSED_STATE: Record<WorkerKey, boolean> = {
|
||||
thumbnail: false,
|
||||
metadata: false,
|
||||
embedding: false,
|
||||
caption: false,
|
||||
};
|
||||
|
||||
export function BackgroundTasks() {
|
||||
const folders = useGalleryStore((state) => state.folders);
|
||||
const indexingProgress = useGalleryStore((state) => state.indexingProgress);
|
||||
@@ -42,24 +59,32 @@ export function BackgroundTasks() {
|
||||
const retryFailedEmbeddings = useGalleryStore((state) => state.retryFailedEmbeddings);
|
||||
const [expanded, setExpanded] = useState(false);
|
||||
const [dismissed, setDismissed] = useState<Record<number, string>>({});
|
||||
const [paused, setPaused] = useState<Record<WorkerKey, boolean>>({
|
||||
thumbnail: false,
|
||||
metadata: false,
|
||||
embedding: false,
|
||||
});
|
||||
const [paused, setPaused] = useState<Record<number, Record<WorkerKey, boolean>>>({});
|
||||
const [failedItems, setFailedItems] = useState<Record<number, FailedEmbeddingItem[]>>({});
|
||||
|
||||
useEffect(() => {
|
||||
invoke<{ thumbnail_paused: boolean; metadata_paused: boolean; embedding_paused: boolean }>(
|
||||
"get_worker_states",
|
||||
).then((states) => {
|
||||
setPaused({
|
||||
thumbnail: states.thumbnail_paused,
|
||||
metadata: states.metadata_paused,
|
||||
embedding: states.embedding_paused,
|
||||
});
|
||||
const folderIds = folders.map((folder) => folder.id);
|
||||
if (folderIds.length === 0) {
|
||||
setPaused({});
|
||||
return;
|
||||
}
|
||||
|
||||
invoke<FolderWorkerStates[]>("get_worker_states", { folderIds }).then((states) => {
|
||||
setPaused(
|
||||
Object.fromEntries(
|
||||
states.map((state) => [
|
||||
state.folder_id,
|
||||
{
|
||||
thumbnail: state.thumbnail_paused,
|
||||
metadata: state.metadata_paused,
|
||||
embedding: state.embedding_paused,
|
||||
caption: state.caption_paused,
|
||||
},
|
||||
]),
|
||||
),
|
||||
);
|
||||
});
|
||||
}, []);
|
||||
}, [folders]);
|
||||
|
||||
// Fetch failed embedding filenames whenever the expanded panel opens or failure counts change.
|
||||
const failedCounts = useMemo(
|
||||
@@ -83,10 +108,21 @@ export function BackgroundTasks() {
|
||||
}
|
||||
}, [expanded, failedCounts]);
|
||||
|
||||
const toggleWorker = (worker: WorkerKey) => {
|
||||
const next = !paused[worker];
|
||||
setPaused((prev) => ({ ...prev, [worker]: next }));
|
||||
void invoke("set_worker_paused", { worker, paused: next });
|
||||
const isWorkerPaused = (folderId: number, worker: WorkerKey) => {
|
||||
return paused[folderId]?.[worker] ?? DEFAULT_PAUSED_STATE[worker];
|
||||
};
|
||||
|
||||
const toggleWorker = (folderId: number, worker: WorkerKey) => {
|
||||
const next = !isWorkerPaused(folderId, worker);
|
||||
setPaused((prev) => ({
|
||||
...prev,
|
||||
[folderId]: {
|
||||
...DEFAULT_PAUSED_STATE,
|
||||
...prev[folderId],
|
||||
[worker]: next,
|
||||
},
|
||||
}));
|
||||
void invoke("set_worker_paused", { worker, folderId, paused: next });
|
||||
};
|
||||
|
||||
const dismissTask = (id: number, snapshot: string) => {
|
||||
@@ -105,13 +141,16 @@ export function BackgroundTasks() {
|
||||
const embeddingPending = jobs?.embedding_pending ?? 0;
|
||||
const embeddingReady = jobs?.embedding_ready ?? 0;
|
||||
const embeddingFailed = jobs?.embedding_failed ?? 0;
|
||||
const captionPending = jobs?.caption_pending ?? 0;
|
||||
const captionFailed = jobs?.caption_failed ?? 0;
|
||||
|
||||
const pendingMediaWork = thumbnailPending + metadataPending + embeddingPending;
|
||||
const pendingMediaWork = thumbnailPending + metadataPending + embeddingPending + captionPending;
|
||||
const embeddingProcessed = embeddingReady + embeddingFailed;
|
||||
const embeddingTotal = embeddingProcessed + embeddingPending;
|
||||
const hasFailedEmbeddings = embeddingFailed > 0;
|
||||
const hasFailedCaptions = captionFailed > 0;
|
||||
|
||||
if (!index && pendingMediaWork === 0 && !hasFailedEmbeddings) return null;
|
||||
if (!index && pendingMediaWork === 0 && !hasFailedEmbeddings && !hasFailedCaptions) return null;
|
||||
|
||||
const stages: TaskStage[] = [];
|
||||
|
||||
@@ -153,6 +192,15 @@ export function BackgroundTasks() {
|
||||
});
|
||||
}
|
||||
|
||||
if (captionPending > 0) {
|
||||
stages.push({
|
||||
label: "Captions",
|
||||
detail: captionPending.toLocaleString(),
|
||||
progress: null,
|
||||
failed: false,
|
||||
});
|
||||
}
|
||||
|
||||
if (hasFailedEmbeddings && pendingMediaWork === 0) {
|
||||
stages.push({
|
||||
label: "Failed",
|
||||
@@ -162,13 +210,23 @@ export function BackgroundTasks() {
|
||||
});
|
||||
}
|
||||
|
||||
const snapshot = `${pendingMediaWork}:${embeddingFailed}`;
|
||||
if (hasFailedCaptions && pendingMediaWork === 0) {
|
||||
stages.push({
|
||||
label: "Failed",
|
||||
detail: `${captionFailed.toLocaleString()} captions`,
|
||||
progress: null,
|
||||
failed: true,
|
||||
});
|
||||
}
|
||||
|
||||
const snapshot = `${pendingMediaWork}:${embeddingFailed}:${captionFailed}`;
|
||||
|
||||
return {
|
||||
id: folder.id,
|
||||
name: folder.name,
|
||||
stages,
|
||||
hasFailedEmbeddings,
|
||||
hasFailedCaptions,
|
||||
pendingMediaWork,
|
||||
embeddingProcessed,
|
||||
embeddingTotal,
|
||||
@@ -184,7 +242,7 @@ export function BackgroundTasks() {
|
||||
|
||||
const primary = tasks[0];
|
||||
const extraCount = tasks.length - 1;
|
||||
const hasFailed = tasks.some((t) => t.hasFailedEmbeddings && t.pendingMediaWork === 0);
|
||||
const hasFailed = tasks.some((t) => (t.hasFailedEmbeddings || t.hasFailedCaptions) && t.pendingMediaWork === 0);
|
||||
|
||||
// Best progress bar value: use embedding progress if available (most informative),
|
||||
// otherwise fall back to scanning progress, otherwise indeterminate.
|
||||
@@ -214,7 +272,7 @@ export function BackgroundTasks() {
|
||||
<div className="flex items-center gap-1.5 flex-1 min-w-0 overflow-hidden">
|
||||
{primary.stages.map((stage) => {
|
||||
const workerKey = WORKER_FOR_STAGE[stage.label];
|
||||
const isPaused = workerKey ? paused[workerKey] : false;
|
||||
const isPaused = workerKey ? isWorkerPaused(primary.id, workerKey) : false;
|
||||
return (
|
||||
<span
|
||||
key={stage.label}
|
||||
@@ -234,7 +292,7 @@ export function BackgroundTasks() {
|
||||
<button
|
||||
className="ml-0.5 opacity-0 group-hover:opacity-100 hover:text-white transition-opacity"
|
||||
title={isPaused ? `Resume ${stage.label}` : `Pause ${stage.label}`}
|
||||
onClick={(e) => { e.stopPropagation(); toggleWorker(workerKey); }}
|
||||
onClick={(e) => { e.stopPropagation(); toggleWorker(primary.id, workerKey); }}
|
||||
>
|
||||
{isPaused ? (
|
||||
<svg className="h-2.5 w-2.5" fill="currentColor" viewBox="0 0 24 24">
|
||||
@@ -312,7 +370,7 @@ export function BackgroundTasks() {
|
||||
const taskEmbeddingStage = task.stages.find((s) => s.label === "Embeddings");
|
||||
const taskScanningStage = task.stages.find((s) => s.label === "Scanning");
|
||||
const taskBarProgress = taskEmbeddingStage?.progress ?? taskScanningStage?.progress ?? null;
|
||||
const taskHasFailed = task.hasFailedEmbeddings && task.pendingMediaWork === 0;
|
||||
const taskHasFailed = (task.hasFailedEmbeddings || task.hasFailedCaptions) && task.pendingMediaWork === 0;
|
||||
|
||||
return (
|
||||
<div key={task.id}>
|
||||
@@ -322,7 +380,7 @@ export function BackgroundTasks() {
|
||||
<div className="flex items-center gap-1.5 flex-1 min-w-0 overflow-hidden">
|
||||
{task.stages.map((stage) => {
|
||||
const workerKey = WORKER_FOR_STAGE[stage.label];
|
||||
const isPaused = workerKey ? paused[workerKey] : false;
|
||||
const isPaused = workerKey ? isWorkerPaused(task.id, workerKey) : false;
|
||||
return (
|
||||
<span
|
||||
key={stage.label}
|
||||
@@ -347,7 +405,7 @@ export function BackgroundTasks() {
|
||||
<button
|
||||
className="ml-0.5 text-gray-600 hover:text-white transition-colors"
|
||||
title={isPaused ? `Resume ${stage.label}` : `Pause ${stage.label}`}
|
||||
onClick={() => toggleWorker(workerKey)}
|
||||
onClick={() => toggleWorker(task.id, workerKey)}
|
||||
>
|
||||
{isPaused ? (
|
||||
<svg className="h-2.5 w-2.5" fill="currentColor" viewBox="0 0 24 24">
|
||||
|
||||
@@ -251,6 +251,10 @@ export function Gallery() {
|
||||
const zoomPreset = useGalleryStore((state) => state.zoomPreset);
|
||||
const search = useGalleryStore((state) => state.search);
|
||||
const searchMode = useGalleryStore((state) => state.searchMode);
|
||||
const collectionTitle = useGalleryStore((state) => state.collectionTitle);
|
||||
const imageLoadError = useGalleryStore((state) => state.imageLoadError);
|
||||
const galleryScrollResetKey = useGalleryStore((state) => state.galleryScrollResetKey);
|
||||
const isSimilarResults = collectionTitle === "Similar Images";
|
||||
|
||||
const parentRef = useRef<HTMLDivElement>(null);
|
||||
const [contextMenu, setContextMenu] = useState<{ x: number; y: number; image: ImageRecord } | null>(null);
|
||||
@@ -271,6 +275,10 @@ export function Gallery() {
|
||||
return () => element.removeEventListener("scroll", handleScroll);
|
||||
}, [handleScroll]);
|
||||
|
||||
useEffect(() => {
|
||||
parentRef.current?.scrollTo({ top: 0, left: 0 });
|
||||
}, [galleryScrollResetKey]);
|
||||
|
||||
useEffect(() => {
|
||||
const close = (event: PointerEvent) => {
|
||||
if ((event.target as HTMLElement | null)?.closest("[data-gallery-context-menu]")) return;
|
||||
@@ -293,12 +301,16 @@ export function Gallery() {
|
||||
<div className="rounded-2xl border border-white/[0.06] bg-white/[0.02] p-8 min-w-72">
|
||||
<div className="h-5 w-5 mx-auto rounded-full border-2 border-white/20 border-t-white/60 animate-spin" />
|
||||
<p className="mt-4 text-sm text-white/40 font-medium">
|
||||
{searchMode === "semantic" && search.trim().length > 0
|
||||
{isSimilarResults
|
||||
? "Finding similar images"
|
||||
: searchMode === "semantic" && search.trim().length > 0
|
||||
? `Searching for matches to "${search}"`
|
||||
: "Loading media"}
|
||||
</p>
|
||||
<p className="text-xs text-white/20 mt-1">
|
||||
{searchMode === "semantic" && search.trim().length > 0
|
||||
{isSimilarResults
|
||||
? "Comparing visual embeddings"
|
||||
: searchMode === "semantic" && search.trim().length > 0
|
||||
? "Semantic search can take a little longer than filename search"
|
||||
: "Fetching results"}
|
||||
</p>
|
||||
@@ -316,12 +328,20 @@ export function Gallery() {
|
||||
d="M4 16l4.586-4.586a2 2 0 012.828 0L16 16m-2-2l1.586-1.586a2 2 0 012.828 0L20 14m-6-6h.01M6 20h12a2 2 0 002-2V6a2 2 0 00-2-2H6a2 2 0 00-2 2v12a2 2 0 002 2z" />
|
||||
</svg>
|
||||
<p className="text-sm text-white/30 font-medium">
|
||||
{searchMode === "semantic" && search.trim().length > 0
|
||||
{imageLoadError
|
||||
? "Could not load results"
|
||||
: isSimilarResults
|
||||
? "No similar images found"
|
||||
: searchMode === "semantic" && search.trim().length > 0
|
||||
? "No semantic matches found"
|
||||
: "No media found"}
|
||||
</p>
|
||||
<p className="text-xs text-white/15 mt-1">
|
||||
{searchMode === "semantic" && search.trim().length > 0
|
||||
{imageLoadError
|
||||
? imageLoadError
|
||||
: isSimilarResults
|
||||
? "This item may be visually isolated, or more embeddings may need to finish processing"
|
||||
: searchMode === "semantic" && search.trim().length > 0
|
||||
? "Try a broader phrase, or wait for more embeddings to finish processing"
|
||||
: "Try adjusting your filters or add a new folder"}
|
||||
</p>
|
||||
|
||||
@@ -47,12 +47,27 @@ function embeddingLabel(status: string, model: string | null): string {
|
||||
|
||||
export function Lightbox() {
|
||||
const selectedImage = useGalleryStore((state) => state.selectedImage);
|
||||
const selectedFolderId = useGalleryStore((state) => state.selectedFolderId);
|
||||
const closeImage = useGalleryStore((state) => state.closeImage);
|
||||
const images = useGalleryStore((state) => state.images);
|
||||
const openImage = useGalleryStore((state) => state.openImage);
|
||||
const loadSimilarImages = useGalleryStore((state) => state.loadSimilarImages);
|
||||
const updateImageDetails = useGalleryStore((state) => state.updateImageDetails);
|
||||
const suggestImageTags = useGalleryStore((state) => state.suggestImageTags);
|
||||
const captionModelStatus = useGalleryStore((state) => state.captionModelStatus);
|
||||
const captionModelPreparing = useGalleryStore((state) => state.captionModelPreparing);
|
||||
const captionModelError = useGalleryStore((state) => state.captionModelError);
|
||||
const captionModelProgress = useGalleryStore((state) => state.captionModelProgress);
|
||||
const aiCaptionsEnabled = useGalleryStore((state) => state.aiCaptionsEnabled);
|
||||
const setAiCaptionsEnabled = useGalleryStore((state) => state.setAiCaptionsEnabled);
|
||||
const setSettingsOpen = useGalleryStore((state) => state.setSettingsOpen);
|
||||
const prepareCaptionModel = useGalleryStore((state) => state.prepareCaptionModel);
|
||||
const queueCaptionForImage = useGalleryStore((state) => state.queueCaptionForImage);
|
||||
const queueCaptionJobs = useGalleryStore((state) => state.queueCaptionJobs);
|
||||
const [zoom, setZoom] = useState(1);
|
||||
const [suggestedTags, setSuggestedTags] = useState<string[]>([]);
|
||||
const [captionQueueing, setCaptionQueueing] = useState(false);
|
||||
const [captionQueueStatus, setCaptionQueueStatus] = useState<string | null>(null);
|
||||
const imageViewportRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
const currentIndex = selectedImage ? images.findIndex((image) => image.id === selectedImage.id) : -1;
|
||||
@@ -68,8 +83,17 @@ export function Lightbox() {
|
||||
|
||||
useEffect(() => {
|
||||
setZoom(1);
|
||||
setSuggestedTags([]);
|
||||
setCaptionQueueStatus(null);
|
||||
}, [selectedImage?.id]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!selectedImage?.generated_caption) return;
|
||||
void suggestImageTags(selectedImage.id)
|
||||
.then(setSuggestedTags)
|
||||
.catch(() => setSuggestedTags([]));
|
||||
}, [selectedImage?.id, selectedImage?.generated_caption, suggestImageTags]);
|
||||
|
||||
useEffect(() => {
|
||||
const viewport = imageViewportRef.current;
|
||||
if (!viewport || !selectedImage || selectedImage.media_kind !== "image") return;
|
||||
@@ -316,6 +340,119 @@ export function Lightbox() {
|
||||
) : null}
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<p className="mb-1 text-xs uppercase tracking-wider text-gray-500">AI Caption</p>
|
||||
{selectedImage.generated_caption ? (
|
||||
<>
|
||||
<p className="text-white">{selectedImage.generated_caption}</p>
|
||||
{suggestedTags.length > 0 ? (
|
||||
<div className="mt-2 flex flex-wrap gap-1.5">
|
||||
{suggestedTags.map((tag) => (
|
||||
<span
|
||||
key={tag}
|
||||
className="rounded-md border border-white/10 bg-white/5 px-2 py-0.5 text-xs text-gray-300"
|
||||
>
|
||||
{tag}
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
) : null}
|
||||
{selectedImage.caption_model ? (
|
||||
<p className="mt-1 text-xs text-gray-600">{selectedImage.caption_model}</p>
|
||||
) : null}
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<p className="text-gray-600">
|
||||
{selectedImage.caption_error ?? "Not generated"}
|
||||
</p>
|
||||
{captionModelStatus?.ready ? (
|
||||
<>
|
||||
<p className={`mt-2 text-xs ${aiCaptionsEnabled ? "text-emerald-400/70" : "text-gray-600"}`}>
|
||||
{aiCaptionsEnabled ? "Florence-2 enabled locally" : "Florence-2 downloaded but disabled"}
|
||||
</p>
|
||||
<div className="mt-2 flex flex-wrap gap-2">
|
||||
<button
|
||||
className="rounded-md border border-white/10 bg-white/5 px-3 py-1.5 text-xs text-gray-300 transition-colors hover:bg-white/10 hover:text-white"
|
||||
onClick={() => setAiCaptionsEnabled(!aiCaptionsEnabled)}
|
||||
>
|
||||
{aiCaptionsEnabled ? "Disable captions" : "Enable captions"}
|
||||
</button>
|
||||
{selectedImage.media_kind === "image" && aiCaptionsEnabled ? (
|
||||
<button
|
||||
className="rounded-md border border-white/10 bg-white/5 px-3 py-1.5 text-xs text-gray-300 transition-colors hover:bg-white/10 hover:text-white disabled:cursor-not-allowed disabled:opacity-50"
|
||||
onClick={() => {
|
||||
setCaptionQueueing(true);
|
||||
setCaptionQueueStatus(null);
|
||||
void queueCaptionForImage(selectedImage.id)
|
||||
.then(() => setCaptionQueueStatus("Queued for background captions."))
|
||||
.catch((error) => setCaptionQueueStatus(String(error)))
|
||||
.finally(() => setCaptionQueueing(false));
|
||||
}}
|
||||
disabled={captionQueueing}
|
||||
>
|
||||
{captionQueueing ? "Queueing..." : "Queue caption"}
|
||||
</button>
|
||||
) : null}
|
||||
{aiCaptionsEnabled ? (
|
||||
<button
|
||||
className="rounded-md border border-white/10 bg-white/5 px-3 py-1.5 text-xs text-gray-300 transition-colors hover:bg-white/10 hover:text-white disabled:cursor-not-allowed disabled:opacity-50"
|
||||
onClick={() => {
|
||||
setCaptionQueueing(true);
|
||||
setCaptionQueueStatus(null);
|
||||
void queueCaptionJobs(selectedFolderId)
|
||||
.then((queued) =>
|
||||
setCaptionQueueStatus(
|
||||
queued === 0
|
||||
? "No missing captions found."
|
||||
: `Queued ${queued.toLocaleString()} image${queued === 1 ? "" : "s"} for background captions.`,
|
||||
),
|
||||
)
|
||||
.catch((error) => setCaptionQueueStatus(String(error)))
|
||||
.finally(() => setCaptionQueueing(false));
|
||||
}}
|
||||
disabled={captionQueueing}
|
||||
>
|
||||
{selectedFolderId === null ? "Queue all" : "Queue folder"}
|
||||
</button>
|
||||
) : null}
|
||||
</div>
|
||||
{captionQueueStatus ? (
|
||||
<p className="mt-2 break-all text-xs text-gray-500">{captionQueueStatus}</p>
|
||||
) : null}
|
||||
</>
|
||||
) : (
|
||||
<button
|
||||
className="mt-2 rounded-md border border-white/10 bg-white/5 px-3 py-1.5 text-xs text-gray-300 transition-colors hover:bg-white/10 hover:text-white disabled:cursor-not-allowed disabled:opacity-50"
|
||||
onClick={() => void prepareCaptionModel()}
|
||||
disabled={captionModelPreparing}
|
||||
>
|
||||
{captionModelProgress
|
||||
? `Downloading ${captionModelProgress.completed_files}/${captionModelProgress.total_files}`
|
||||
: captionModelPreparing
|
||||
? "Preparing Florence-2..."
|
||||
: "Enable local captions"}
|
||||
</button>
|
||||
)}
|
||||
{captionModelProgress?.current_file ? (
|
||||
<p className="mt-2 break-all text-xs text-gray-600">{captionModelProgress.current_file}</p>
|
||||
) : null}
|
||||
{captionModelError ? (
|
||||
<p className="mt-2 text-xs text-amber-300">{captionModelError}</p>
|
||||
) : null}
|
||||
{!captionModelStatus?.ready && !captionModelError ? (
|
||||
<p className="mt-2 text-xs text-gray-600">Downloads Florence-2 on demand for offline captions.</p>
|
||||
) : null}
|
||||
<button
|
||||
className="mt-2 block text-xs text-gray-500 transition-colors hover:text-gray-300"
|
||||
onClick={() => setSettingsOpen(true)}
|
||||
>
|
||||
Open settings
|
||||
</button>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<p className="mb-1 text-xs uppercase tracking-wider text-gray-500">Path</p>
|
||||
<p className="break-all text-xs text-gray-400">{selectedImage.path}</p>
|
||||
|
||||
@@ -0,0 +1,354 @@
|
||||
import { useEffect, useState } from "react";
|
||||
import { useGalleryStore } from "../store";
|
||||
|
||||
type SettingsSection = "ai" | "library" | "display" | "storage";
|
||||
|
||||
const SECTIONS: { id: SettingsSection; label: string; detail: string }[] = [
|
||||
{ id: "ai", label: "Local AI", detail: "Captions and suggestions" },
|
||||
{ id: "library", label: "Library", detail: "Indexing and scanning" },
|
||||
{ id: "display", label: "Display", detail: "Gallery preferences" },
|
||||
{ id: "storage", label: "Storage", detail: "Cache and model files" },
|
||||
];
|
||||
|
||||
function ToggleSwitch({
|
||||
checked,
|
||||
disabled,
|
||||
onChange,
|
||||
label,
|
||||
}: {
|
||||
checked: boolean;
|
||||
disabled?: boolean;
|
||||
onChange: (checked: boolean) => void;
|
||||
label: string;
|
||||
}) {
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
role="switch"
|
||||
aria-checked={checked}
|
||||
aria-label={label}
|
||||
disabled={disabled}
|
||||
onClick={() => onChange(!checked)}
|
||||
className={`relative h-7 w-12 shrink-0 rounded-full border transition-colors duration-150 focus:outline-none focus:ring-2 focus:ring-emerald-400/30 disabled:cursor-not-allowed disabled:opacity-45 ${
|
||||
checked
|
||||
? "border-emerald-400/50 bg-emerald-500/35"
|
||||
: "border-white/12 bg-white/[0.06]"
|
||||
}`}
|
||||
>
|
||||
<span
|
||||
className={`absolute left-0.5 top-0.5 h-6 w-6 rounded-full bg-white shadow-lg shadow-black/40 transition-transform duration-150 ${
|
||||
checked ? "translate-x-5" : "translate-x-0"
|
||||
}`}
|
||||
/>
|
||||
</button>
|
||||
);
|
||||
}
|
||||
|
||||
function StatusPill({ children, tone }: { children: React.ReactNode; tone: "ready" | "muted" | "busy" }) {
|
||||
const className =
|
||||
tone === "ready"
|
||||
? "border-emerald-400/25 bg-emerald-500/10 text-emerald-300"
|
||||
: tone === "busy"
|
||||
? "border-sky-400/25 bg-sky-500/10 text-sky-300"
|
||||
: "border-white/10 bg-white/[0.04] text-gray-500";
|
||||
|
||||
return (
|
||||
<span className={`inline-flex rounded-md border px-2 py-1 text-[11px] font-medium ${className}`}>
|
||||
{children}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
function SettingsRow({
|
||||
title,
|
||||
description,
|
||||
children,
|
||||
}: {
|
||||
title: string;
|
||||
description: string;
|
||||
children: React.ReactNode;
|
||||
}) {
|
||||
return (
|
||||
<div className="flex items-start justify-between gap-5 border-b border-white/[0.07] py-4 last:border-b-0">
|
||||
<div className="min-w-0">
|
||||
<p className="text-sm font-medium text-white">{title}</p>
|
||||
<p className="mt-1 max-w-md text-xs leading-relaxed text-gray-500">{description}</p>
|
||||
</div>
|
||||
<div className="shrink-0">{children}</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function SectionShell({
|
||||
eyebrow,
|
||||
title,
|
||||
children,
|
||||
}: {
|
||||
eyebrow: string;
|
||||
title: string;
|
||||
children: React.ReactNode;
|
||||
}) {
|
||||
return (
|
||||
<div>
|
||||
<p className="text-[10px] font-semibold uppercase tracking-[0.18em] text-gray-600">{eyebrow}</p>
|
||||
<h3 className="mt-1 text-lg font-semibold text-white">{title}</h3>
|
||||
<div className="mt-5 border-t border-white/[0.08]">{children}</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function SettingsModal() {
|
||||
const [activeSection, setActiveSection] = useState<SettingsSection>("ai");
|
||||
const [captionQueueStatus, setCaptionQueueStatus] = useState<string | null>(null);
|
||||
const [captionQueueing, setCaptionQueueing] = useState(false);
|
||||
const settingsOpen = useGalleryStore((state) => state.settingsOpen);
|
||||
const setSettingsOpen = useGalleryStore((state) => state.setSettingsOpen);
|
||||
const folders = useGalleryStore((state) => state.folders);
|
||||
const selectedFolderId = useGalleryStore((state) => state.selectedFolderId);
|
||||
const captionModelStatus = useGalleryStore((state) => state.captionModelStatus);
|
||||
const captionModelPreparing = useGalleryStore((state) => state.captionModelPreparing);
|
||||
const captionModelProgress = useGalleryStore((state) => state.captionModelProgress);
|
||||
const captionModelError = useGalleryStore((state) => state.captionModelError);
|
||||
const captionRuntimeProbe = useGalleryStore((state) => state.captionRuntimeProbe);
|
||||
const captionRuntimeChecking = useGalleryStore((state) => state.captionRuntimeChecking);
|
||||
const aiCaptionsEnabled = useGalleryStore((state) => state.aiCaptionsEnabled);
|
||||
const setAiCaptionsEnabled = useGalleryStore((state) => state.setAiCaptionsEnabled);
|
||||
const prepareCaptionModel = useGalleryStore((state) => state.prepareCaptionModel);
|
||||
const deleteCaptionModel = useGalleryStore((state) => state.deleteCaptionModel);
|
||||
const probeCaptionRuntime = useGalleryStore((state) => state.probeCaptionRuntime);
|
||||
const queueCaptionJobs = useGalleryStore((state) => state.queueCaptionJobs);
|
||||
|
||||
useEffect(() => {
|
||||
if (!settingsOpen) return;
|
||||
const handleKeyDown = (event: KeyboardEvent) => {
|
||||
if (event.key === "Escape") setSettingsOpen(false);
|
||||
};
|
||||
window.addEventListener("keydown", handleKeyDown);
|
||||
return () => window.removeEventListener("keydown", handleKeyDown);
|
||||
}, [settingsOpen, setSettingsOpen]);
|
||||
|
||||
if (!settingsOpen) return null;
|
||||
|
||||
const modelReady = captionModelStatus?.ready ?? false;
|
||||
const downloadLabel = captionModelProgress
|
||||
? `Downloading ${captionModelProgress.completed_files}/${captionModelProgress.total_files}`
|
||||
: captionModelPreparing
|
||||
? "Preparing Florence-2..."
|
||||
: modelReady
|
||||
? "Downloaded"
|
||||
: "Download Florence-2";
|
||||
const downloadPercent = captionModelProgress
|
||||
? Math.round((captionModelProgress.completed_files / Math.max(captionModelProgress.total_files, 1)) * 100)
|
||||
: 0;
|
||||
const selectedFolder = folders.find((folder) => folder.id === selectedFolderId);
|
||||
const captionScopeLabel = selectedFolder ? selectedFolder.name : "all libraries";
|
||||
|
||||
return (
|
||||
<div
|
||||
className="fixed inset-0 z-[60] flex items-center justify-center bg-black/65 px-6 backdrop-blur-sm"
|
||||
onClick={() => setSettingsOpen(false)}
|
||||
>
|
||||
<div
|
||||
className="flex h-[min(680px,calc(100vh-56px))] w-full max-w-4xl overflow-hidden rounded-lg border border-white/10 bg-[#07080f] shadow-2xl shadow-black/60"
|
||||
onClick={(event) => event.stopPropagation()}
|
||||
>
|
||||
<aside className="flex w-56 shrink-0 flex-col border-r border-white/[0.07] bg-white/[0.025]">
|
||||
<div className="border-b border-white/[0.07] px-5 py-5">
|
||||
<p className="text-base font-semibold text-white">Settings</p>
|
||||
<p className="mt-1 text-xs text-gray-600">Phokus preferences</p>
|
||||
</div>
|
||||
<div className="flex-1 space-y-1 overflow-y-auto p-2">
|
||||
{SECTIONS.map((section) => (
|
||||
<button
|
||||
key={section.id}
|
||||
className={`w-full rounded-md px-3 py-2.5 text-left transition-colors ${
|
||||
activeSection === section.id
|
||||
? "bg-white/10 text-white"
|
||||
: "text-gray-500 hover:bg-white/[0.055] hover:text-gray-200"
|
||||
}`}
|
||||
onClick={() => setActiveSection(section.id)}
|
||||
>
|
||||
<span className="block text-[13px] font-medium">{section.label}</span>
|
||||
<span className="mt-0.5 block text-[11px] text-gray-600">{section.detail}</span>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</aside>
|
||||
|
||||
<main className="flex min-w-0 flex-1 flex-col">
|
||||
<div className="flex h-14 shrink-0 items-center justify-between border-b border-white/[0.07] px-6">
|
||||
<div className="flex items-center gap-2">
|
||||
{modelReady ? <StatusPill tone="ready">Florence-2 ready</StatusPill> : <StatusPill tone="muted">Optional</StatusPill>}
|
||||
{captionModelPreparing ? <StatusPill tone="busy">Working</StatusPill> : null}
|
||||
</div>
|
||||
<button
|
||||
className="rounded-md p-1.5 text-gray-500 transition-colors hover:bg-white/[0.06] hover:text-white"
|
||||
onClick={() => setSettingsOpen(false)}
|
||||
title="Close settings"
|
||||
>
|
||||
<svg className="h-4 w-4" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M6 18L18 6M6 6l12 12" />
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="flex-1 overflow-y-auto px-7 py-6">
|
||||
{activeSection === "ai" ? (
|
||||
<SectionShell eyebrow="Local AI" title="Captions and suggested tags">
|
||||
<SettingsRow
|
||||
title="AI captions"
|
||||
description="Generate captions and suggested tags with the local Florence-2 model."
|
||||
>
|
||||
<ToggleSwitch
|
||||
checked={aiCaptionsEnabled && modelReady}
|
||||
disabled={!modelReady || captionModelPreparing}
|
||||
onChange={setAiCaptionsEnabled}
|
||||
label="AI captions"
|
||||
/>
|
||||
</SettingsRow>
|
||||
|
||||
<SettingsRow
|
||||
title="Florence-2 model"
|
||||
description={modelReady ? "Stored locally and available offline." : "Download the model before enabling local captions."}
|
||||
>
|
||||
<div className="flex flex-col items-end gap-2">
|
||||
<button
|
||||
className="relative overflow-hidden rounded-md border border-white/10 bg-white/[0.055] px-3 py-1.5 text-xs text-gray-300 transition-colors hover:bg-white/10 hover:text-white disabled:cursor-not-allowed disabled:opacity-45"
|
||||
onClick={() => void prepareCaptionModel()}
|
||||
disabled={captionModelPreparing || modelReady}
|
||||
>
|
||||
{captionModelProgress ? (
|
||||
<span
|
||||
className="absolute inset-y-0 left-0 bg-emerald-400/15 transition-[width] duration-200"
|
||||
style={{ width: `${downloadPercent}%` }}
|
||||
/>
|
||||
) : null}
|
||||
<span className="relative">{downloadLabel}</span>
|
||||
</button>
|
||||
{modelReady ? (
|
||||
<>
|
||||
<button
|
||||
className="rounded-md border border-white/10 bg-white/[0.055] px-3 py-1.5 text-xs text-gray-300 transition-colors hover:bg-white/10 hover:text-white disabled:cursor-not-allowed disabled:opacity-45"
|
||||
onClick={() => void probeCaptionRuntime()}
|
||||
disabled={captionRuntimeChecking}
|
||||
>
|
||||
{captionRuntimeChecking ? "Checking runtime..." : "Check runtime"}
|
||||
</button>
|
||||
<button
|
||||
className="rounded-md border border-red-400/20 bg-red-500/10 px-3 py-1.5 text-xs text-red-200 transition-colors hover:bg-red-500/15 disabled:cursor-not-allowed disabled:opacity-45"
|
||||
onClick={() => void deleteCaptionModel()}
|
||||
disabled={captionModelPreparing || captionRuntimeChecking}
|
||||
>
|
||||
Delete model files
|
||||
</button>
|
||||
</>
|
||||
) : null}
|
||||
</div>
|
||||
</SettingsRow>
|
||||
|
||||
<SettingsRow
|
||||
title="Caption queue"
|
||||
description={`Generate missing captions in ${captionScopeLabel}. Captions update in the gallery as the local worker finishes each image.`}
|
||||
>
|
||||
<button
|
||||
className="rounded-md border border-white/10 bg-white/[0.055] px-3 py-1.5 text-xs text-gray-300 transition-colors hover:bg-white/10 hover:text-white disabled:cursor-not-allowed disabled:opacity-45"
|
||||
onClick={() => {
|
||||
setCaptionQueueing(true);
|
||||
setCaptionQueueStatus(null);
|
||||
void queueCaptionJobs(selectedFolderId)
|
||||
.then((queued) => {
|
||||
setCaptionQueueStatus(
|
||||
queued === 0
|
||||
? "No missing captions found."
|
||||
: `Queued ${queued.toLocaleString()} image${queued === 1 ? "" : "s"}.`,
|
||||
);
|
||||
})
|
||||
.catch((error) => setCaptionQueueStatus(String(error)))
|
||||
.finally(() => setCaptionQueueing(false));
|
||||
}}
|
||||
disabled={!modelReady || !aiCaptionsEnabled || captionQueueing}
|
||||
>
|
||||
{captionQueueing ? "Queueing..." : selectedFolder ? "Caption this library" : "Caption all libraries"}
|
||||
</button>
|
||||
</SettingsRow>
|
||||
|
||||
<div className="py-4">
|
||||
<p className="text-xs font-medium text-gray-400">Model location</p>
|
||||
<p className="mt-2 break-all rounded-md border border-white/[0.07] bg-black/20 px-3 py-2 text-xs text-gray-600">
|
||||
{modelReady ? captionModelStatus?.local_dir : "Not downloaded"}
|
||||
</p>
|
||||
{captionModelProgress?.current_file ? (
|
||||
<p className="mt-3 break-all text-xs text-gray-500">{captionModelProgress.current_file}</p>
|
||||
) : null}
|
||||
{captionModelError ? (
|
||||
<p className="mt-3 text-xs text-amber-300">{captionModelError}</p>
|
||||
) : null}
|
||||
{captionQueueStatus ? (
|
||||
<p className="mt-3 text-xs text-gray-500">{captionQueueStatus}</p>
|
||||
) : null}
|
||||
{captionRuntimeProbe ? (
|
||||
<div className="mt-4 border-t border-white/[0.07] pt-4">
|
||||
<div className="flex items-center justify-between gap-3">
|
||||
<p className="text-xs font-medium text-gray-400">Runtime check</p>
|
||||
<StatusPill tone="ready">Ready</StatusPill>
|
||||
</div>
|
||||
<p className="mt-2 text-xs text-gray-600">
|
||||
Tokenizer vocabulary: {captionRuntimeProbe.tokenizer_vocab_size.toLocaleString()}
|
||||
</p>
|
||||
<div className="mt-3 space-y-2">
|
||||
{captionRuntimeProbe.sessions.map((session) => (
|
||||
<div key={session.file} className="rounded-md border border-white/[0.07] bg-black/20 px-3 py-2">
|
||||
<p className="break-all text-xs text-gray-400">{session.file}</p>
|
||||
<p className="mt-1 text-[11px] text-gray-600">
|
||||
{session.inputs.length} input{session.inputs.length === 1 ? "" : "s"} · {session.outputs.length} output{session.outputs.length === 1 ? "" : "s"}
|
||||
</p>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
</SectionShell>
|
||||
) : null}
|
||||
|
||||
{activeSection === "library" ? (
|
||||
<SectionShell eyebrow="Library" title="Indexing and scanning">
|
||||
<SettingsRow title="Background workers" description="Folder-level pause controls remain in the background tasks panel.">
|
||||
<StatusPill tone="muted">Managed per folder</StatusPill>
|
||||
</SettingsRow>
|
||||
<SettingsRow title="Reindexing" description="Use the library sidebar to rescan a folder when files change.">
|
||||
<StatusPill tone="muted">Available</StatusPill>
|
||||
</SettingsRow>
|
||||
</SectionShell>
|
||||
) : null}
|
||||
|
||||
{activeSection === "display" ? (
|
||||
<SectionShell eyebrow="Display" title="Gallery preferences">
|
||||
<SettingsRow title="Grid density" description="Use the toolbar size control to change thumbnail density.">
|
||||
<StatusPill tone="muted">Toolbar</StatusPill>
|
||||
</SettingsRow>
|
||||
<SettingsRow title="Result view" description="Similar results reset to the top on each new search.">
|
||||
<StatusPill tone="ready">Enabled</StatusPill>
|
||||
</SettingsRow>
|
||||
</SectionShell>
|
||||
) : null}
|
||||
|
||||
{activeSection === "storage" ? (
|
||||
<SectionShell eyebrow="Storage" title="Local files">
|
||||
<SettingsRow title="Florence-2" description="Remove the local model without changing the rest of the library.">
|
||||
<button
|
||||
className="rounded-md border border-red-400/20 bg-red-500/10 px-3 py-1.5 text-xs text-red-200 transition-colors hover:bg-red-500/15 disabled:cursor-not-allowed disabled:opacity-45"
|
||||
onClick={() => void deleteCaptionModel()}
|
||||
disabled={captionModelPreparing || !modelReady}
|
||||
>
|
||||
Delete model files
|
||||
</button>
|
||||
</SettingsRow>
|
||||
</SectionShell>
|
||||
) : null}
|
||||
</div>
|
||||
</main>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,5 +1,6 @@
|
||||
import { useState, useEffect } from "react";
|
||||
import { getCurrentWindow } from "@tauri-apps/api/window";
|
||||
import { useGalleryStore } from "../store";
|
||||
|
||||
// SVG icons for window controls
|
||||
function MinimizeIcon() {
|
||||
@@ -37,6 +38,7 @@ function CloseIcon() {
|
||||
|
||||
export function TitleBar() {
|
||||
const [isMaximized, setIsMaximized] = useState(false);
|
||||
const setSettingsOpen = useGalleryStore((state) => state.setSettingsOpen);
|
||||
const appWindow = getCurrentWindow();
|
||||
|
||||
useEffect(() => {
|
||||
@@ -85,6 +87,17 @@ export function TitleBar() {
|
||||
className="flex items-stretch h-full"
|
||||
style={{ WebkitAppRegion: "no-drag" } as React.CSSProperties}
|
||||
>
|
||||
<button
|
||||
onClick={() => setSettingsOpen(true)}
|
||||
title="Settings"
|
||||
className="group flex h-full w-10 items-center justify-center text-gray-600 transition-colors duration-100 hover:bg-white/6 hover:text-gray-300"
|
||||
>
|
||||
<svg className="h-3.5 w-3.5" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={1.8} d="M10.325 4.317c.426-1.756 2.924-1.756 3.35 0a1.724 1.724 0 002.573 1.066c1.543-.94 3.31.826 2.37 2.37a1.724 1.724 0 001.065 2.572c1.756.426 1.756 2.924 0 3.35a1.724 1.724 0 00-1.066 2.573c.94 1.543-.826 3.31-2.37 2.37a1.724 1.724 0 00-2.572 1.065c-.426 1.756-2.924 1.756-3.35 0a1.724 1.724 0 00-2.573-1.066c-1.543.94-3.31-.826-2.37-2.37a1.724 1.724 0 00-1.065-2.572c-1.756-.426-1.756-2.924 0-3.35a1.724 1.724 0 001.066-2.573c-.94-1.543.826-3.31 2.37-2.37.996.607 2.296.07 2.572-1.065z" />
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={1.8} d="M15 12a3 3 0 11-6 0 3 3 0 016 0z" />
|
||||
</svg>
|
||||
</button>
|
||||
|
||||
{/* Minimize */}
|
||||
<button
|
||||
onClick={handleMinimize}
|
||||
|
||||
+244
-27
@@ -40,6 +40,10 @@ export interface ImageRecord {
|
||||
embedding_model: string | null;
|
||||
embedding_updated_at: string | null;
|
||||
embedding_error: string | null;
|
||||
generated_caption: string | null;
|
||||
caption_model: string | null;
|
||||
caption_updated_at: string | null;
|
||||
caption_error: string | null;
|
||||
}
|
||||
|
||||
export interface IndexProgress {
|
||||
@@ -57,6 +61,9 @@ export interface FolderJobProgress {
|
||||
embedding_pending: number;
|
||||
embedding_ready: number;
|
||||
embedding_failed: number;
|
||||
caption_pending: number;
|
||||
caption_ready: number;
|
||||
caption_failed: number;
|
||||
}
|
||||
|
||||
export interface MediaJobProgressEvent {
|
||||
@@ -80,6 +87,39 @@ export interface TagCloudEntry {
|
||||
thumbnail_path: string | null;
|
||||
}
|
||||
|
||||
export interface CaptionModelStatus {
|
||||
model_id: string;
|
||||
model_name: string;
|
||||
local_dir: string;
|
||||
ready: boolean;
|
||||
missing_files: string[];
|
||||
}
|
||||
|
||||
export interface CaptionModelProgress {
|
||||
total_files: number;
|
||||
completed_files: number;
|
||||
current_file: string | null;
|
||||
done: boolean;
|
||||
}
|
||||
|
||||
export interface CaptionRuntimeSessionProbe {
|
||||
file: string;
|
||||
inputs: string[];
|
||||
outputs: string[];
|
||||
}
|
||||
|
||||
export interface CaptionRuntimeProbe {
|
||||
ready: boolean;
|
||||
tokenizer_vocab_size: number;
|
||||
sessions: CaptionRuntimeSessionProbe[];
|
||||
}
|
||||
|
||||
export interface CaptionVisionProbe {
|
||||
input_shape: number[];
|
||||
output_shape: number[];
|
||||
output_values: number;
|
||||
}
|
||||
|
||||
export type SortOrder =
|
||||
| "date_desc"
|
||||
| "date_asc"
|
||||
@@ -97,6 +137,7 @@ interface GalleryState {
|
||||
totalImages: number;
|
||||
loadedCount: number;
|
||||
loadingImages: boolean;
|
||||
imageLoadError: string | null;
|
||||
search: string;
|
||||
searchMode: SearchMode;
|
||||
sort: SortOrder;
|
||||
@@ -106,6 +147,9 @@ interface GalleryState {
|
||||
zoomPreset: ZoomPreset;
|
||||
selectedImage: ImageRecord | null;
|
||||
collectionTitle: string | null;
|
||||
similarSourceImageId: number | null;
|
||||
similarHasMore: boolean;
|
||||
galleryScrollResetKey: number;
|
||||
activeView: ActiveView;
|
||||
tagCloudEntries: TagCloudEntry[];
|
||||
tagCloudLoading: boolean;
|
||||
@@ -113,6 +157,14 @@ interface GalleryState {
|
||||
indexingProgress: Record<number, IndexProgress>;
|
||||
mediaJobProgress: Record<number, FolderJobProgress>;
|
||||
cacheDir: string;
|
||||
captionModelStatus: CaptionModelStatus | null;
|
||||
captionModelPreparing: boolean;
|
||||
captionModelError: string | null;
|
||||
captionModelProgress: CaptionModelProgress | null;
|
||||
captionRuntimeProbe: CaptionRuntimeProbe | null;
|
||||
captionRuntimeChecking: boolean;
|
||||
aiCaptionsEnabled: boolean;
|
||||
settingsOpen: boolean;
|
||||
|
||||
loadFolders: () => Promise<void>;
|
||||
loadBackgroundJobProgress: () => Promise<void>;
|
||||
@@ -136,7 +188,18 @@ interface GalleryState {
|
||||
setView: (view: ActiveView) => void;
|
||||
loadTagCloud: () => Promise<void>;
|
||||
searchByTag: (imageId: number) => void;
|
||||
loadSimilarImages: (imageId: number) => Promise<void>;
|
||||
loadSimilarImages: (imageId: number, folderId?: number | null, reset?: boolean) => Promise<void>;
|
||||
suggestImageTags: (imageId: number) => Promise<string[]>;
|
||||
loadCaptionModelStatus: () => Promise<void>;
|
||||
prepareCaptionModel: () => Promise<void>;
|
||||
deleteCaptionModel: () => Promise<void>;
|
||||
probeCaptionRuntime: () => Promise<void>;
|
||||
probeCaptionImage: (imageId: number) => Promise<CaptionVisionProbe>;
|
||||
generateCaptionForImage: (imageId: number) => Promise<ImageRecord>;
|
||||
queueCaptionJobs: (folderId?: number | null) => Promise<number>;
|
||||
queueCaptionForImage: (imageId: number) => Promise<number>;
|
||||
setAiCaptionsEnabled: (enabled: boolean) => void;
|
||||
setSettingsOpen: (open: boolean) => void;
|
||||
retryFailedEmbeddings: (folderId: number) => Promise<void>;
|
||||
updateImageDetails: (imageId: number, updates: { favorite?: boolean; rating?: number }) => Promise<void>;
|
||||
setCacheDir: (dir: string) => void;
|
||||
@@ -144,6 +207,12 @@ interface GalleryState {
|
||||
}
|
||||
|
||||
const PAGE_SIZE = 200;
|
||||
const AI_CAPTIONS_ENABLED_KEY = "phokus.aiCaptionsEnabled";
|
||||
|
||||
function initialAiCaptionsEnabled(): boolean {
|
||||
if (typeof window === "undefined") return false;
|
||||
return window.localStorage.getItem(AI_CAPTIONS_ENABLED_KEY) === "true";
|
||||
}
|
||||
|
||||
function mergeIntoVisibleWindow(
|
||||
currentImages: ImageRecord[],
|
||||
@@ -266,6 +335,7 @@ export const useGalleryStore = create<GalleryState>((set, get) => ({
|
||||
totalImages: 0,
|
||||
loadedCount: 0,
|
||||
loadingImages: false,
|
||||
imageLoadError: null,
|
||||
search: "",
|
||||
searchMode: "filename",
|
||||
sort: "date_desc",
|
||||
@@ -275,6 +345,9 @@ export const useGalleryStore = create<GalleryState>((set, get) => ({
|
||||
zoomPreset: "comfortable",
|
||||
selectedImage: null,
|
||||
collectionTitle: null,
|
||||
similarSourceImageId: null,
|
||||
similarHasMore: false,
|
||||
galleryScrollResetKey: 0,
|
||||
activeView: "gallery",
|
||||
tagCloudEntries: [],
|
||||
tagCloudLoading: false,
|
||||
@@ -282,6 +355,14 @@ export const useGalleryStore = create<GalleryState>((set, get) => ({
|
||||
indexingProgress: {},
|
||||
mediaJobProgress: {},
|
||||
cacheDir: "",
|
||||
captionModelStatus: null,
|
||||
captionModelPreparing: false,
|
||||
captionModelError: null,
|
||||
captionModelProgress: null,
|
||||
captionRuntimeProbe: null,
|
||||
captionRuntimeChecking: false,
|
||||
aiCaptionsEnabled: initialAiCaptionsEnabled(),
|
||||
settingsOpen: false,
|
||||
|
||||
setCacheDir: (cacheDir) => set({ cacheDir }),
|
||||
|
||||
@@ -327,13 +408,13 @@ export const useGalleryStore = create<GalleryState>((set, get) => ({
|
||||
},
|
||||
|
||||
selectFolder: (folderId) => {
|
||||
set({ selectedFolderId: folderId, images: [], loadedCount: 0, collectionTitle: null, activeView: "gallery", failedEmbeddingsOnly: false });
|
||||
set({ selectedFolderId: folderId, images: [], loadedCount: 0, collectionTitle: null, similarSourceImageId: null, similarHasMore: false, activeView: "gallery", failedEmbeddingsOnly: false, imageLoadError: null });
|
||||
void get().loadImages(true);
|
||||
},
|
||||
|
||||
loadImages: async (reset = false) => {
|
||||
const { selectedFolderId, search, searchMode, sort, loadedCount, mediaFilter, favoritesOnly, failedEmbeddingsOnly } = get();
|
||||
set({ loadingImages: true });
|
||||
set({ loadingImages: true, imageLoadError: null });
|
||||
|
||||
try {
|
||||
if (searchMode === "semantic" && search.trim()) {
|
||||
@@ -353,6 +434,8 @@ export const useGalleryStore = create<GalleryState>((set, get) => ({
|
||||
loadedCount: images.length,
|
||||
loadingImages: false,
|
||||
collectionTitle: `Semantic search: ${search}`,
|
||||
similarSourceImageId: null,
|
||||
similarHasMore: false,
|
||||
});
|
||||
return;
|
||||
}
|
||||
@@ -382,56 +465,63 @@ export const useGalleryStore = create<GalleryState>((set, get) => ({
|
||||
loadedCount: reset ? result.images.length : state.loadedCount + result.images.length,
|
||||
loadingImages: false,
|
||||
collectionTitle: reset ? null : state.collectionTitle,
|
||||
similarSourceImageId: null,
|
||||
similarHasMore: false,
|
||||
}));
|
||||
} catch (error) {
|
||||
console.error("Failed to load media:", error);
|
||||
set({ loadingImages: false });
|
||||
set({ loadingImages: false, imageLoadError: String(error) });
|
||||
}
|
||||
},
|
||||
|
||||
loadMoreImages: async () => {
|
||||
const { loadedCount, totalImages, loadingImages } = get();
|
||||
const { loadedCount, totalImages, loadingImages, collectionTitle, similarSourceImageId, similarHasMore, selectedFolderId } = get();
|
||||
if (loadingImages || loadedCount >= totalImages) return;
|
||||
if (collectionTitle === "Similar Images" && similarSourceImageId !== null) {
|
||||
if (!similarHasMore) return;
|
||||
await get().loadSimilarImages(similarSourceImageId, selectedFolderId, false);
|
||||
return;
|
||||
}
|
||||
await get().loadImages(false);
|
||||
},
|
||||
|
||||
setSearch: (search) => {
|
||||
set({ search, images: [], loadedCount: 0, collectionTitle: null });
|
||||
set({ search, images: [], loadedCount: 0, collectionTitle: null, similarSourceImageId: null, similarHasMore: false, imageLoadError: null });
|
||||
void get().loadImages(true);
|
||||
},
|
||||
|
||||
clearSearch: () => {
|
||||
set({ search: "", images: [], loadedCount: 0, collectionTitle: null });
|
||||
set({ search: "", images: [], loadedCount: 0, collectionTitle: null, similarSourceImageId: null, similarHasMore: false, imageLoadError: null });
|
||||
void get().loadImages(true);
|
||||
},
|
||||
|
||||
resetSearch: () => {
|
||||
set({ search: "", searchMode: "filename", images: [], loadedCount: 0, collectionTitle: null });
|
||||
set({ search: "", searchMode: "filename", images: [], loadedCount: 0, collectionTitle: null, similarSourceImageId: null, similarHasMore: false, imageLoadError: null });
|
||||
void get().loadImages(true);
|
||||
},
|
||||
|
||||
setSearchMode: (searchMode) => {
|
||||
set({ searchMode, images: [], loadedCount: 0, collectionTitle: null });
|
||||
set({ searchMode, images: [], loadedCount: 0, collectionTitle: null, similarSourceImageId: null, similarHasMore: false, imageLoadError: null });
|
||||
void get().loadImages(true);
|
||||
},
|
||||
|
||||
setSort: (sort) => {
|
||||
set({ sort, images: [], loadedCount: 0, collectionTitle: null });
|
||||
set({ sort, images: [], loadedCount: 0, collectionTitle: null, similarSourceImageId: null, similarHasMore: false, imageLoadError: null });
|
||||
void get().loadImages(true);
|
||||
},
|
||||
|
||||
setMediaFilter: (mediaFilter) => {
|
||||
set({ mediaFilter, images: [], loadedCount: 0, collectionTitle: null });
|
||||
set({ mediaFilter, images: [], loadedCount: 0, collectionTitle: null, similarSourceImageId: null, similarHasMore: false, imageLoadError: null });
|
||||
void get().loadImages(true);
|
||||
},
|
||||
|
||||
setFavoritesOnly: (favoritesOnly) => {
|
||||
set({ favoritesOnly, images: [], loadedCount: 0, collectionTitle: null });
|
||||
set({ favoritesOnly, images: [], loadedCount: 0, collectionTitle: null, similarSourceImageId: null, similarHasMore: false, imageLoadError: null });
|
||||
void get().loadImages(true);
|
||||
},
|
||||
|
||||
setFailedEmbeddingsOnly: (failedEmbeddingsOnly) => {
|
||||
set({ failedEmbeddingsOnly, images: [], loadedCount: 0, collectionTitle: null });
|
||||
set({ failedEmbeddingsOnly, images: [], loadedCount: 0, collectionTitle: null, similarSourceImageId: null, similarHasMore: false, imageLoadError: null });
|
||||
void get().loadImages(true);
|
||||
},
|
||||
|
||||
@@ -461,26 +551,145 @@ export const useGalleryStore = create<GalleryState>((set, get) => ({
|
||||
},
|
||||
|
||||
searchByTag: (imageId) => {
|
||||
set({ activeView: "gallery", images: [], loadedCount: 0, loadingImages: true, collectionTitle: "Similar Images" });
|
||||
void get().loadSimilarImages(imageId);
|
||||
const { selectedFolderId } = get();
|
||||
set((state) => ({ activeView: "gallery", images: [], loadedCount: 0, loadingImages: true, collectionTitle: "Similar Images", imageLoadError: null, galleryScrollResetKey: state.galleryScrollResetKey + 1 }));
|
||||
void get().loadSimilarImages(imageId, selectedFolderId);
|
||||
},
|
||||
|
||||
loadSimilarImages: async (imageId) => {
|
||||
set({ images: [], loadedCount: 0, loadingImages: true, collectionTitle: "Similar Images" });
|
||||
const images = await invoke<ImageRecord[]>("find_similar_images", {
|
||||
params: { image_id: imageId, limit: PAGE_SIZE },
|
||||
});
|
||||
set({
|
||||
images,
|
||||
totalImages: images.length,
|
||||
loadedCount: images.length,
|
||||
loadingImages: false,
|
||||
loadSimilarImages: async (imageId, folderId = get().selectedFolderId, reset = true) => {
|
||||
const requestedLimit = reset ? PAGE_SIZE : get().loadedCount + PAGE_SIZE;
|
||||
set((state) => ({
|
||||
images: reset ? [] : get().images,
|
||||
loadedCount: reset ? 0 : get().loadedCount,
|
||||
loadingImages: true,
|
||||
collectionTitle: "Similar Images",
|
||||
selectedFolderId: null,
|
||||
selectedImage: null,
|
||||
imageLoadError: null,
|
||||
similarSourceImageId: imageId,
|
||||
galleryScrollResetKey: reset ? state.galleryScrollResetKey + 1 : state.galleryScrollResetKey,
|
||||
}));
|
||||
try {
|
||||
const images = await invoke<ImageRecord[]>("find_similar_images", {
|
||||
params: { image_id: imageId, limit: requestedLimit },
|
||||
});
|
||||
const hasMore = images.length >= requestedLimit;
|
||||
set({
|
||||
images,
|
||||
totalImages: hasMore ? images.length + PAGE_SIZE : images.length,
|
||||
loadedCount: images.length,
|
||||
loadingImages: false,
|
||||
imageLoadError: null,
|
||||
collectionTitle: "Similar Images",
|
||||
similarSourceImageId: imageId,
|
||||
similarHasMore: hasMore,
|
||||
selectedFolderId: folderId ?? null,
|
||||
selectedImage: reset ? null : get().selectedImage,
|
||||
});
|
||||
} catch (error) {
|
||||
console.error("Failed to load similar images:", error);
|
||||
set({
|
||||
images: [],
|
||||
totalImages: 0,
|
||||
loadedCount: 0,
|
||||
loadingImages: false,
|
||||
imageLoadError: String(error),
|
||||
collectionTitle: "Similar Images",
|
||||
similarSourceImageId: imageId,
|
||||
similarHasMore: false,
|
||||
selectedFolderId: folderId ?? null,
|
||||
selectedImage: null,
|
||||
});
|
||||
}
|
||||
},
|
||||
|
||||
suggestImageTags: async (imageId) => {
|
||||
return invoke<string[]>("suggest_image_tags", {
|
||||
params: { image_id: imageId, limit: 2 },
|
||||
});
|
||||
},
|
||||
|
||||
loadCaptionModelStatus: async () => {
|
||||
try {
|
||||
const captionModelStatus = await invoke<CaptionModelStatus>("get_caption_model_status");
|
||||
set({ captionModelStatus, captionModelError: null });
|
||||
} catch (error) {
|
||||
set({ captionModelError: String(error) });
|
||||
}
|
||||
},
|
||||
|
||||
prepareCaptionModel: async () => {
|
||||
set({ captionModelPreparing: true, captionModelError: null, captionModelProgress: null });
|
||||
try {
|
||||
const captionModelStatus = await invoke<CaptionModelStatus>("prepare_caption_model");
|
||||
window.localStorage.setItem(AI_CAPTIONS_ENABLED_KEY, String(captionModelStatus.ready));
|
||||
set({ captionModelStatus, captionModelPreparing: false, captionModelError: null, captionModelProgress: null, aiCaptionsEnabled: captionModelStatus.ready });
|
||||
} catch (error) {
|
||||
set({ captionModelPreparing: false, captionModelError: String(error), captionModelProgress: null });
|
||||
}
|
||||
},
|
||||
|
||||
deleteCaptionModel: async () => {
|
||||
set({ captionModelPreparing: true, captionModelError: null, captionModelProgress: null });
|
||||
try {
|
||||
const captionModelStatus = await invoke<CaptionModelStatus>("delete_caption_model");
|
||||
window.localStorage.setItem(AI_CAPTIONS_ENABLED_KEY, "false");
|
||||
set({ captionModelStatus, captionModelPreparing: false, captionModelError: null, captionModelProgress: null, captionRuntimeProbe: null, aiCaptionsEnabled: false });
|
||||
} catch (error) {
|
||||
set({ captionModelPreparing: false, captionModelError: String(error), captionModelProgress: null });
|
||||
}
|
||||
},
|
||||
|
||||
probeCaptionRuntime: async () => {
|
||||
set({ captionRuntimeChecking: true, captionModelError: null });
|
||||
try {
|
||||
const captionRuntimeProbe = await invoke<CaptionRuntimeProbe>("probe_caption_runtime");
|
||||
set({ captionRuntimeProbe, captionRuntimeChecking: false, captionModelError: null });
|
||||
} catch (error) {
|
||||
set({ captionRuntimeChecking: false, captionModelError: String(error), captionRuntimeProbe: null });
|
||||
}
|
||||
},
|
||||
|
||||
probeCaptionImage: async (imageId) => {
|
||||
return invoke<CaptionVisionProbe>("probe_caption_image", {
|
||||
params: { image_id: imageId },
|
||||
});
|
||||
},
|
||||
|
||||
generateCaptionForImage: async (imageId) => {
|
||||
const updatedImage = await invoke<ImageRecord>("generate_caption_for_image", {
|
||||
params: { image_id: imageId },
|
||||
});
|
||||
|
||||
set((state) => ({
|
||||
images: replaceImage(state.images, updatedImage, state.sort),
|
||||
selectedImage: state.selectedImage?.id === updatedImage.id ? updatedImage : state.selectedImage,
|
||||
}));
|
||||
|
||||
return updatedImage;
|
||||
},
|
||||
|
||||
queueCaptionJobs: async (folderId = get().selectedFolderId) => {
|
||||
const queued = await invoke<number>("queue_caption_jobs", {
|
||||
params: { folder_id: folderId ?? null, image_id: null },
|
||||
});
|
||||
await get().loadBackgroundJobProgress();
|
||||
return queued;
|
||||
},
|
||||
|
||||
queueCaptionForImage: async (imageId) => {
|
||||
const queued = await invoke<number>("queue_caption_jobs", {
|
||||
params: { folder_id: null, image_id: imageId },
|
||||
});
|
||||
await get().loadBackgroundJobProgress();
|
||||
return queued;
|
||||
},
|
||||
|
||||
setAiCaptionsEnabled: (aiCaptionsEnabled) => {
|
||||
window.localStorage.setItem(AI_CAPTIONS_ENABLED_KEY, String(aiCaptionsEnabled));
|
||||
set({ aiCaptionsEnabled });
|
||||
},
|
||||
|
||||
setSettingsOpen: (settingsOpen) => set({ settingsOpen }),
|
||||
|
||||
retryFailedEmbeddings: async (folderId) => {
|
||||
await invoke("retry_failed_embeddings", { params: { folder_id: folderId } });
|
||||
await get().loadBackgroundJobProgress();
|
||||
@@ -536,6 +745,13 @@ export const useGalleryStore = create<GalleryState>((set, get) => ({
|
||||
});
|
||||
});
|
||||
|
||||
const unlistenCaptionModelProgress = await listen<CaptionModelProgress>("caption-model-progress", (event) => {
|
||||
set({
|
||||
captionModelProgress: event.payload.done ? null : event.payload,
|
||||
captionModelPreparing: !event.payload.done,
|
||||
});
|
||||
});
|
||||
|
||||
const unlistenImages = await listen<IndexedImagesBatch>("indexed-images", (event) => {
|
||||
const batch = event.payload;
|
||||
|
||||
@@ -600,6 +816,7 @@ export const useGalleryStore = create<GalleryState>((set, get) => ({
|
||||
return () => {
|
||||
unlistenProgress();
|
||||
unlistenMediaJobs();
|
||||
unlistenCaptionModelProgress();
|
||||
unlistenImages();
|
||||
unlistenThumbnails();
|
||||
};
|
||||
|
||||
Reference in New Issue
Block a user