feat: analysis providers, settings UI, song search, WAV duration fix

- Multi-provider AI analysis (Anthropic, OpenAI, Ollama, Algorithmic)
- server-only guards on all provider files; client bundle fix
- /settings page with provider status, Ollama model picker, preferences
- Song search box on /analyze replacing raw MBID input (debounced, keyboard nav)
- Auto-register song via MusicBrainz on POST /api/tracks (no more 404)
- Fix WAV duration bug: last section songEnd was double-counting elapsed time
- Registry sync comment updated for self-hosted HTTPS git servers

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
AJ Avezzano
2026-04-03 18:46:17 -04:00
parent 51f67f0aeb
commit 8b9d72bc9d
22 changed files with 1803 additions and 293 deletions

View File

@@ -7,18 +7,31 @@
* 1. User drops / selects an audio file (MP3, WAV, AAC, OGG, etc.)
* 2. Browser decodes the audio and runs BPM detection (Web Audio API)
* 3. Optional: user provides song title, artist, MusicBrainz ID
* 4. Client sends { bpm, duration, … } to POST /api/analyze
* 5. Server calls Claude → returns a CTP document draft
* 6. User can review the sections, download the .ctp.json, or submit to DB
* 4. User selects an analysis provider
* 5. Client sends { bpm, duration, provider, … } to POST /api/analyze
* 6. Server returns a CTP document draft
* 7. User can review the sections, download the .ctp.json, or submit to DB
*/
import { useState, useRef, useCallback } from "react";
import { useState, useRef, useCallback, useEffect } from "react";
import { detectBPM, type BPMDetectionResult } from "@/lib/analysis/bpm-detect";
import TempoMapEditor from "@/components/TempoMapEditor";
import type { CTPDocument } from "@/lib/ctp/schema";
import type { ProviderInfo } from "@/lib/analysis/providers";
import { RECOMMENDED_OLLAMA_MODELS } from "@/lib/analysis/constants";
const PROVIDER_KEY = "clicktrack_analysis_provider";
const MODEL_KEY = "clicktrack_ollama_model";
// ─── Types ────────────────────────────────────────────────────────────────────
interface SongResult {
mbid: string;
title: string;
artist: string;
duration_seconds: number | null;
}
type Stage =
| "idle"
| "decoding"
@@ -36,12 +49,10 @@ interface AnalyzerState {
ctp: CTPDocument | null;
warnings: string[];
errorMsg: string;
// Optional metadata the user may fill in before AI generation
title: string;
artist: string;
mbid: string;
contributedBy: string;
// Toggle: use halfTimeBpm instead of primary bpm
useHalfTime: boolean;
}
@@ -81,10 +92,167 @@ export default function TempoAnalyzer() {
const dropRef = useRef<HTMLDivElement>(null);
const [isDragging, setIsDragging] = useState(false);
// Provider state
const [providers, setProviders] = useState<ProviderInfo[]>([]);
const [ollamaModels, setOllamaModels] = useState<string[]>([]);
const [selectedProvider, setSelectedProvider] = useState<string>("");
const [selectedOllamaModel, setSelectedOllamaModel] = useState<string>("");
const [unavailableNotice, setUnavailableNotice] = useState<string>("");
const [unavailableModelNotice, setUnavailableModelNotice] = useState<string>("");
// Song search
const [songQuery, setSongQuery] = useState("");
const [songResults, setSongResults] = useState<SongResult[]>([]);
const [songDropdownOpen, setSongDropdownOpen] = useState(false);
const [songHighlightIdx, setSongHighlightIdx] = useState(-1);
const [songSearchFailed, setSongSearchFailed] = useState(false);
const [selectedSongLabel, setSelectedSongLabel] = useState("");
const searchDebounceRef = useRef<ReturnType<typeof setTimeout> | null>(null);
const songDropdownRef = useRef<HTMLDivElement>(null);
const update = (patch: Partial<AnalyzerState>) =>
setState((prev) => ({ ...prev, ...patch }));
// ── File handling ────────────────────────────────────────────────────────
// ── Load providers on mount ───────────────────────────────────────────────
useEffect(() => {
async function loadProviders() {
try {
const res = await fetch("/api/analyze/providers");
const data = await res.json() as { providers: ProviderInfo[]; ollamaModels: string[] };
const available = data.providers.filter((p) => p.available);
setProviders(available);
setOllamaModels(data.ollamaModels);
// Restore saved provider preference
const savedProvider = localStorage.getItem(PROVIDER_KEY);
if (savedProvider) {
const found = available.find((p) => p.id === savedProvider);
if (found) {
setSelectedProvider(found.id);
} else {
// Saved provider no longer available
const unavailable = data.providers.find((p) => p.id === savedProvider);
const label = unavailable?.label ?? savedProvider;
setUnavailableNotice(
`Your previous provider (${label}) is not currently available.`
);
setSelectedProvider(available[0]?.id ?? "");
}
} else {
setSelectedProvider(available[0]?.id ?? "");
}
// Restore saved Ollama model
if (data.ollamaModels.length > 0) {
const savedModel = localStorage.getItem(MODEL_KEY);
if (savedModel && data.ollamaModels.includes(savedModel)) {
setSelectedOllamaModel(savedModel);
} else {
if (savedModel) {
setUnavailableModelNotice(
`Your previous model (${savedModel}) is no longer available.`
);
}
setSelectedOllamaModel(data.ollamaModels[0]);
}
}
} catch {
// Provider fetch failure — app remains functional, generate will fail gracefully
}
}
loadProviders();
}, []);
useEffect(() => {
function onMouseDown(e: MouseEvent) {
if (songDropdownRef.current && !songDropdownRef.current.contains(e.target as Node)) {
setSongDropdownOpen(false);
}
}
document.addEventListener("mousedown", onMouseDown);
return () => document.removeEventListener("mousedown", onMouseDown);
}, []);
// ── Song search ───────────────────────────────────────────────────────────
function handleSongQueryChange(value: string) {
setSongQuery(value);
setSongHighlightIdx(-1);
if (searchDebounceRef.current) clearTimeout(searchDebounceRef.current);
if (!value.trim()) {
setSongResults([]);
setSongDropdownOpen(false);
return;
}
searchDebounceRef.current = setTimeout(async () => {
try {
const res = await fetch(`/api/songs?q=${encodeURIComponent(value)}&limit=5`);
if (!res.ok) throw new Error("Search failed");
const data = await res.json() as { songs: SongResult[] };
setSongResults(data.songs);
setSongDropdownOpen(true);
setSongSearchFailed(false);
} catch {
setSongSearchFailed(true);
setSongDropdownOpen(false);
}
}, 350);
}
function handleSongSelect(song: SongResult) {
update({ mbid: song.mbid, title: song.title, artist: song.artist });
const label = `${song.title}${song.artist}`;
setSelectedSongLabel(label);
setSongQuery(label);
setSongDropdownOpen(false);
setSongResults([]);
setSongHighlightIdx(-1);
}
function handleSongClear() {
update({ mbid: "", title: "", artist: "" });
setSelectedSongLabel("");
setSongQuery("");
setSongResults([]);
setSongDropdownOpen(false);
setSongHighlightIdx(-1);
if (searchDebounceRef.current) clearTimeout(searchDebounceRef.current);
}
function handleSongKeyDown(e: React.KeyboardEvent<HTMLInputElement>) {
if (e.key === "ArrowDown") {
e.preventDefault();
setSongHighlightIdx((i) => Math.min(i + 1, songResults.length - 1));
setSongDropdownOpen(true);
} else if (e.key === "ArrowUp") {
e.preventDefault();
setSongHighlightIdx((i) => Math.max(i - 1, 0));
} else if (e.key === "Enter") {
e.preventDefault();
if (songHighlightIdx >= 0 && songResults[songHighlightIdx]) {
handleSongSelect(songResults[songHighlightIdx]);
}
} else if (e.key === "Escape") {
setSongDropdownOpen(false);
}
}
function handleProviderChange(id: string) {
setSelectedProvider(id);
setUnavailableNotice("");
localStorage.setItem(PROVIDER_KEY, id);
}
function handleOllamaModelChange(model: string) {
setSelectedOllamaModel(model);
setUnavailableModelNotice("");
localStorage.setItem(MODEL_KEY, model);
}
// ── File handling ─────────────────────────────────────────────────────────
const handleFile = useCallback(async (file: File) => {
if (!file.type.startsWith("audio/") && !file.name.match(/\.(mp3|wav|aac|ogg|flac|m4a|aiff)$/i)) {
@@ -96,7 +264,6 @@ export default function TempoAnalyzer() {
const abort = new AbortController();
abortRef.current = abort;
// Try to pre-fill title/artist from filename: "Artist - Title.mp3"
const base = file.name.replace(/\.[^.]+$/, "");
const dashIdx = base.indexOf(" - ");
const autoTitle = dashIdx > -1 ? base.slice(dashIdx + 3) : base;
@@ -116,7 +283,7 @@ export default function TempoAnalyzer() {
try {
update({ stage: "detecting" });
const detection = await detectBPM(file, abort.signal);
update({ detection, stage: "idle" }); // wait for user to confirm/edit metadata
update({ detection, stage: "idle" });
} catch (err) {
if ((err as Error).name === "AbortError") return;
update({
@@ -136,12 +303,12 @@ export default function TempoAnalyzer() {
function handleFileInput(e: React.ChangeEvent<HTMLInputElement>) {
const file = e.target.files?.[0];
if (file) handleFile(file);
e.target.value = ""; // reset so re-selecting same file works
e.target.value = "";
}
// ── AI generation ────────────────────────────────────────────────────────
// ── Generation ────────────────────────────────────────────────────────────
async function handleGenerate() {
async function runGenerate(providerId: string, ollamaModel: string) {
if (!state.detection) return;
const effectiveBpm =
@@ -149,38 +316,60 @@ export default function TempoAnalyzer() {
? state.detection.halfTimeBpm
: state.detection.bpm;
update({ stage: "generating", ctp: null, warnings: [] });
update({ stage: "generating", ctp: null, warnings: [], errorMsg: "" });
const body: Record<string, unknown> = {
bpm: effectiveBpm,
duration: state.detection.duration,
title: state.title || undefined,
artist: state.artist || undefined,
mbid: state.mbid || undefined,
contributed_by: state.contributedBy || undefined,
provider: providerId,
};
if (providerId === "ollama") {
body.ollamaModel = ollamaModel;
}
const res = await fetch("/api/analyze", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(body),
});
const data = await res.json() as { ctp?: CTPDocument; warnings?: string[]; error?: string };
if (!res.ok) {
throw new Error(data.error ?? `Server error ${res.status}`);
}
update({ ctp: data.ctp ?? null, warnings: data.warnings ?? [], stage: "review" });
}
async function handleGenerate() {
try {
const res = await fetch("/api/analyze", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
bpm: effectiveBpm,
duration: state.detection.duration,
title: state.title || undefined,
artist: state.artist || undefined,
mbid: state.mbid || undefined,
contributed_by: state.contributedBy || undefined,
}),
});
const data = await res.json();
if (!res.ok) {
throw new Error(data.error ?? `Server error ${res.status}`);
}
update({ ctp: data.ctp, warnings: data.warnings ?? [], stage: "review" });
await runGenerate(selectedProvider, selectedOllamaModel);
} catch (err) {
update({
stage: "error",
errorMsg: `Generation failed: ${err instanceof Error ? err.message : String(err)}`,
errorMsg: err instanceof Error ? err.message : String(err),
});
}
}
// ── Submit to DB ─────────────────────────────────────────────────────────
async function handleRetry() {
try {
await runGenerate(selectedProvider, selectedOllamaModel);
} catch (err) {
update({
stage: "error",
errorMsg: err instanceof Error ? err.message : String(err),
});
}
}
// ── Submit to DB ──────────────────────────────────────────────────────────
async function handleSubmit() {
if (!state.ctp) return;
@@ -193,7 +382,7 @@ export default function TempoAnalyzer() {
body: JSON.stringify(state.ctp),
});
const data = await res.json();
const data = await res.json() as { error?: string };
if (!res.ok) {
throw new Error(data.error ?? `Server error ${res.status}`);
}
@@ -207,7 +396,7 @@ export default function TempoAnalyzer() {
}
}
// ── Download CTP file ────────────────────────────────────────────────────
// ── Download CTP file ────────────────────────────────────────────────────
function handleDownload() {
if (!state.ctp) return;
@@ -225,17 +414,26 @@ export default function TempoAnalyzer() {
URL.revokeObjectURL(url);
}
// ── Reset ────────────────────────────────────────────────────────────────
// ── Reset ────────────────────────────────────────────────────────────────
function handleReset() {
abortRef.current?.abort();
setState(INITIAL_STATE);
setSongQuery("");
setSongResults([]);
setSongDropdownOpen(false);
setSelectedSongLabel("");
setSongHighlightIdx(-1);
if (searchDebounceRef.current) clearTimeout(searchDebounceRef.current);
}
// ─── Render ───────────────────────────────────────────────────────────────
const { stage, file, detection, ctp, warnings, errorMsg, useHalfTime } = state;
const isProcessing = stage === "decoding" || stage === "detecting" || stage === "generating" || stage === "saving";
const isProcessing =
stage === "decoding" || stage === "detecting" || stage === "generating" || stage === "saving";
const selectedProviderInfo = providers.find((p) => p.id === selectedProvider);
return (
<div className="space-y-8">
@@ -279,28 +477,79 @@ export default function TempoAnalyzer() {
<p className="font-medium text-zinc-200">
{stage === "decoding" && "Decoding audio…"}
{stage === "detecting" && "Detecting tempo…"}
{stage === "generating" && "Generating tempo map with AI…"}
{stage === "generating" && "Generating tempo map…"}
{stage === "saving" && "Saving to database…"}
</p>
{stage === "generating" && (
{stage === "generating" && selectedProviderInfo?.id === "ollama" && (
<p className="mt-1 text-sm text-zinc-500">
Claude is analysing the song structure this takes ~515 seconds.
Local AI generation may take 3090 seconds depending on your hardware.
</p>
)}
{stage === "generating" && selectedProviderInfo?.id !== "ollama" && (
<p className="mt-1 text-sm text-zinc-500">
Analysing song structure this takes ~515 seconds.
</p>
)}
</div>
)}
{/* ── Error ─────────────────────────────────────────────────────── */}
{/* ── Error panel ────────────────────────────────────────────────── */}
{stage === "error" && (
<div className="rounded-xl border border-red-800 bg-red-950/30 px-6 py-5">
<p className="text-red-400 font-medium mb-1">Error</p>
<p className="text-sm text-red-300">{errorMsg}</p>
<button
onClick={handleReset}
className="mt-4 text-sm text-zinc-400 hover:text-zinc-200 underline"
>
Try again
</button>
<div className="rounded-xl border border-red-800 bg-red-950/30 px-6 py-5 space-y-4">
<div>
<p className="text-red-400 font-medium mb-1">Generation failed</p>
<p className="text-sm text-red-300">{errorMsg}</p>
</div>
{/* Provider selector in error state */}
{providers.length >= 2 && (
<div className="space-y-2">
<label className="block text-xs text-zinc-500">Analysis provider</label>
<select
value={selectedProvider}
onChange={(e) => handleProviderChange(e.target.value)}
className="rounded-lg border border-zinc-700 bg-zinc-800 px-3 py-2 text-sm text-zinc-100 focus:border-green-500 focus:outline-none"
>
{providers.map((p) => (
<option key={p.id} value={p.id}>
{p.label}
</option>
))}
</select>
</div>
)}
{selectedProvider === "ollama" && ollamaModels.length > 0 && (
<div className="space-y-2">
<label className="block text-xs text-zinc-500">Ollama model</label>
<select
value={selectedOllamaModel}
onChange={(e) => handleOllamaModelChange(e.target.value)}
className="rounded-lg border border-zinc-700 bg-zinc-800 px-3 py-2 text-sm text-zinc-100 focus:border-green-500 focus:outline-none"
>
{ollamaModels.map((m) => (
<option key={m} value={m}>
{RECOMMENDED_OLLAMA_MODELS.includes(m) ? `${m}` : m}
</option>
))}
</select>
</div>
)}
<div className="flex gap-3">
<button
onClick={handleRetry}
className="rounded-lg bg-red-800/60 px-4 py-2 text-sm font-medium text-red-200 hover:bg-red-700/60 transition-colors"
>
Retry
</button>
<button
onClick={handleReset}
className="text-sm text-zinc-400 hover:text-zinc-200 underline self-center"
>
Start over
</button>
</div>
</div>
)}
@@ -371,12 +620,83 @@ export default function TempoAnalyzer() {
</p>
)}
{/* Metadata form */}
{/* Metadata form + provider selector */}
{stage === "idle" && (
<>
{/* Song search */}
<div ref={songDropdownRef} className="relative">
<div className="flex items-center justify-between mb-1">
<label className="block text-xs text-zinc-500">Song</label>
{selectedSongLabel && (
<button
type="button"
onClick={handleSongClear}
className="text-xs text-zinc-500 hover:text-zinc-300"
>
Clear
</button>
)}
</div>
{songSearchFailed ? (
<div className="space-y-1">
<p className="text-xs text-amber-400">Search unavailable enter MusicBrainz ID manually:</p>
<input
value={state.mbid}
onChange={(e) => update({ mbid: e.target.value })}
placeholder="xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx"
className="w-full rounded-lg border border-zinc-700 bg-zinc-800 px-3 py-2 text-sm font-mono text-zinc-100 placeholder:text-zinc-600 focus:border-green-500 focus:outline-none"
/>
</div>
) : (
<input
value={songQuery}
onChange={(e) => {
const val = e.target.value;
if (selectedSongLabel) {
update({ mbid: "", title: "", artist: "" });
setSelectedSongLabel("");
}
handleSongQueryChange(val);
}}
onKeyDown={handleSongKeyDown}
placeholder="Search by title or artist…"
autoComplete="off"
className="w-full rounded-lg border border-zinc-700 bg-zinc-800 px-3 py-2 text-sm text-zinc-100 placeholder:text-zinc-600 focus:border-green-500 focus:outline-none"
/>
)}
{songDropdownOpen && (
<div className="absolute z-10 mt-1 w-full rounded-lg border border-zinc-700 bg-zinc-900 shadow-xl overflow-hidden">
{songResults.length === 0 ? (
<p className="px-3 py-2 text-sm text-zinc-500">
No songs found try the search page first to register the song.
</p>
) : (
songResults.map((song, i) => (
<button
key={song.mbid}
type="button"
onMouseDown={(e) => { e.preventDefault(); handleSongSelect(song); }}
className={`w-full text-left px-3 py-2 text-sm transition-colors ${
i === songHighlightIdx
? "bg-zinc-700 text-zinc-100"
: "text-zinc-300 hover:bg-zinc-800"
}`}
>
<span className="font-medium">{song.title}</span>
<span className="text-zinc-500"> {song.artist}</span>
</button>
))
)}
</div>
)}
</div>
{/* Title + artist manual overrides */}
<div className="grid gap-3 sm:grid-cols-2">
<div>
<label className="block text-xs text-zinc-500 mb-1">Song title</label>
<label className="block text-xs text-zinc-500 mb-1">
Song title <span className="text-zinc-600">(override)</span>
</label>
<input
value={state.title}
onChange={(e) => update({ title: e.target.value })}
@@ -385,7 +705,9 @@ export default function TempoAnalyzer() {
/>
</div>
<div>
<label className="block text-xs text-zinc-500 mb-1">Artist</label>
<label className="block text-xs text-zinc-500 mb-1">
Artist <span className="text-zinc-600">(override)</span>
</label>
<input
value={state.artist}
onChange={(e) => update({ artist: e.target.value })}
@@ -393,34 +715,88 @@ export default function TempoAnalyzer() {
className="w-full rounded-lg border border-zinc-700 bg-zinc-800 px-3 py-2 text-sm text-zinc-100 placeholder:text-zinc-600 focus:border-green-500 focus:outline-none"
/>
</div>
<div>
<label className="block text-xs text-zinc-500 mb-1">
MusicBrainz ID{" "}
<span className="text-zinc-600">(optional)</span>
</label>
<input
value={state.mbid}
onChange={(e) => update({ mbid: e.target.value })}
placeholder="xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx"
className="w-full rounded-lg border border-zinc-700 bg-zinc-800 px-3 py-2 text-sm font-mono text-zinc-100 placeholder:text-zinc-600 focus:border-green-500 focus:outline-none"
/>
</div>
<div>
<label className="block text-xs text-zinc-500 mb-1">Your name / handle</label>
<input
value={state.contributedBy}
onChange={(e) => update({ contributedBy: e.target.value })}
placeholder="e.g. guitar_pete"
className="w-full rounded-lg border border-zinc-700 bg-zinc-800 px-3 py-2 text-sm text-zinc-100 placeholder:text-zinc-600 focus:border-green-500 focus:outline-none"
/>
</div>
</div>
{/* Contributed by */}
<div>
<label className="block text-xs text-zinc-500 mb-1">Your name / handle</label>
<input
value={state.contributedBy}
onChange={(e) => update({ contributedBy: e.target.value })}
placeholder="e.g. guitar_pete"
className="w-full rounded-lg border border-zinc-700 bg-zinc-800 px-3 py-2 text-sm text-zinc-100 placeholder:text-zinc-600 focus:border-green-500 focus:outline-none"
/>
</div>
{/* Provider selector */}
{unavailableNotice && (
<div className="rounded-lg border border-amber-800/50 bg-amber-950/20 px-4 py-3">
<p className="text-sm text-amber-300">{unavailableNotice}</p>
</div>
)}
{providers.length >= 2 && (
<div className="space-y-2">
<label className="block text-xs text-zinc-500">Analysis provider</label>
<select
value={selectedProvider}
onChange={(e) => handleProviderChange(e.target.value)}
className="rounded-lg border border-zinc-700 bg-zinc-800 px-3 py-2 text-sm text-zinc-100 focus:border-green-500 focus:outline-none"
>
{providers.map((p) => (
<option key={p.id} value={p.id}>
{p.label}
</option>
))}
</select>
</div>
)}
{/* Ollama model selector */}
{selectedProvider === "ollama" && ollamaModels.length > 0 && (
<div className="space-y-2">
{unavailableModelNotice && (
<div className="rounded-lg border border-amber-800/50 bg-amber-950/20 px-3 py-2">
<p className="text-xs text-amber-300">{unavailableModelNotice}</p>
</div>
)}
<label className="block text-xs text-zinc-500">Ollama model</label>
<select
value={selectedOllamaModel}
onChange={(e) => handleOllamaModelChange(e.target.value)}
className="rounded-lg border border-zinc-700 bg-zinc-800 px-3 py-2 text-sm text-zinc-100 focus:border-green-500 focus:outline-none"
>
{ollamaModels.map((m) => (
<option key={m} value={m}>
{RECOMMENDED_OLLAMA_MODELS.includes(m) ? `${m}` : m}
</option>
))}
</select>
</div>
)}
{/* Context-aware callouts */}
{selectedProvider === "algorithmic" && (
<div className="rounded-lg border border-zinc-700 bg-zinc-800/40 px-4 py-3">
<p className="text-sm text-zinc-400">
Algorithmic mode estimates song structure from BPM and duration. Section labels and bar counts are approximate review carefully before submitting.
</p>
</div>
)}
{selectedProvider === "ollama" && (
<div className="rounded-lg border border-zinc-700 bg-zinc-800/40 px-4 py-3">
<p className="text-sm text-zinc-400">
Local AI generation may take 3090 seconds depending on your hardware.
</p>
</div>
)}
<button
onClick={handleGenerate}
className="w-full rounded-lg bg-green-600 py-3 font-semibold text-white hover:bg-green-500 transition-colors"
>
Generate tempo map with AI
Generate tempo map
</button>
</>
)}

View File

@@ -0,0 +1,63 @@
"use client";
import { RECOMMENDED_OLLAMA_MODELS } from "@/lib/analysis/constants";
interface OllamaModelPickerProps {
models: string[];
value: string;
onChange: (model: string) => void;
onRefresh: () => void;
refreshing: boolean;
}
export default function OllamaModelPicker({
models,
value,
onChange,
onRefresh,
refreshing,
}: OllamaModelPickerProps) {
if (models.length === 0) {
return (
<p className="text-sm text-zinc-500">
No models found. Pull a model with{" "}
<code className="text-zinc-400">ollama pull qwen2.5:7b</code> and refresh.
</p>
);
}
return (
<div className="space-y-3">
<div className="flex flex-col gap-2">
{models.map((model) => {
const isRecommended = RECOMMENDED_OLLAMA_MODELS.includes(model);
return (
<label key={model} className="flex items-center gap-3 cursor-pointer group">
<input
type="radio"
name="ollama-model"
value={model}
checked={value === model}
onChange={() => onChange(model)}
className="accent-green-500"
/>
<span className="text-sm text-zinc-300 group-hover:text-zinc-100 transition-colors">
{model}
</span>
{isRecommended && (
<span className="text-xs text-amber-400"> recommended</span>
)}
</label>
);
})}
</div>
<button
onClick={onRefresh}
disabled={refreshing}
className="text-xs text-zinc-500 hover:text-zinc-300 underline disabled:opacity-50"
>
{refreshing ? "Refreshing…" : "Refresh model list"}
</button>
</div>
);
}

View File

@@ -0,0 +1,79 @@
"use client";
import { useState, useEffect } from "react";
const PROVIDER_KEY = "clicktrack_analysis_provider";
const MODEL_KEY = "clicktrack_ollama_model";
export default function PreferencesPanel() {
const [rememberProvider, setRememberProvider] = useState(true);
const [rememberModel, setRememberModel] = useState(true);
const [cleared, setCleared] = useState(false);
useEffect(() => {
// Reflect current state: if the keys exist, persistence is active
const hasProvider = localStorage.getItem(PROVIDER_KEY) !== null;
const hasModel = localStorage.getItem(MODEL_KEY) !== null;
setRememberProvider(hasProvider);
setRememberModel(hasModel);
}, []);
function handleToggleProvider(on: boolean) {
setRememberProvider(on);
if (!on) {
localStorage.removeItem(PROVIDER_KEY);
}
}
function handleToggleModel(on: boolean) {
setRememberModel(on);
if (!on) {
localStorage.removeItem(MODEL_KEY);
}
}
function handleClear() {
localStorage.removeItem(PROVIDER_KEY);
localStorage.removeItem(MODEL_KEY);
setRememberProvider(false);
setRememberModel(false);
setCleared(true);
setTimeout(() => setCleared(false), 3000);
}
return (
<div className="space-y-4">
<label className="flex items-center gap-3 cursor-pointer">
<input
type="checkbox"
checked={rememberProvider}
onChange={(e) => handleToggleProvider(e.target.checked)}
className="accent-green-500 w-4 h-4"
/>
<span className="text-sm text-zinc-300">Remember my last provider selection</span>
</label>
<label className="flex items-center gap-3 cursor-pointer">
<input
type="checkbox"
checked={rememberModel}
onChange={(e) => handleToggleModel(e.target.checked)}
className="accent-green-500 w-4 h-4"
/>
<span className="text-sm text-zinc-300">Remember my last Ollama model</span>
</label>
<div className="pt-1">
<button
onClick={handleClear}
className="rounded-lg border border-zinc-700 px-4 py-2 text-sm text-zinc-400 hover:border-zinc-500 hover:text-zinc-200 transition-colors"
>
Clear saved preferences
</button>
{cleared && (
<span className="ml-3 text-sm text-green-400">Preferences cleared.</span>
)}
</div>
</div>
);
}

View File

@@ -0,0 +1,114 @@
"use client";
import { useState } from "react";
import type { ProviderInfo } from "@/lib/analysis/providers";
interface ProviderStatusProps {
provider: ProviderInfo;
selectedOllamaModel?: string;
isDefault: boolean;
onSetDefault: (id: string) => void;
}
const TYPE_LABELS: Record<ProviderInfo["type"], string> = {
"cloud-ai": "Cloud AI",
"local-ai": "Local AI",
"algorithmic": "No AI",
};
export default function ProviderStatus({
provider,
selectedOllamaModel,
isDefault,
onSetDefault,
}: ProviderStatusProps) {
const [testStatus, setTestStatus] = useState<"idle" | "testing" | "ok" | "error">("idle");
const [testError, setTestError] = useState("");
async function handleTest() {
setTestStatus("testing");
setTestError("");
try {
const res = await fetch("/api/analyze/test", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
provider: provider.id,
ollamaModel: provider.id === "ollama" ? selectedOllamaModel : undefined,
}),
});
const data = await res.json() as { ok: boolean; error?: string };
if (data.ok) {
setTestStatus("ok");
} else {
setTestStatus("error");
setTestError(data.error ?? "Unknown error");
}
} catch (err) {
setTestStatus("error");
setTestError(err instanceof Error ? err.message : String(err));
}
}
return (
<div className="rounded-lg border border-zinc-800 bg-zinc-900/60 p-4">
<div className="flex items-start justify-between gap-3">
<div className="flex-1 min-w-0">
<div className="flex items-center gap-2 flex-wrap">
<span className="font-medium text-zinc-200">{provider.label}</span>
<span className="rounded px-1.5 py-0.5 text-xs bg-zinc-800 text-zinc-400">
{TYPE_LABELS[provider.type]}
</span>
{provider.available ? (
<span className="rounded px-1.5 py-0.5 text-xs bg-green-900/50 text-green-400">
Available
</span>
) : (
<span className="rounded px-1.5 py-0.5 text-xs bg-zinc-800 text-zinc-500">
Unavailable
</span>
)}
{isDefault && (
<span className="rounded px-1.5 py-0.5 text-xs bg-blue-900/50 text-blue-400">
Default
</span>
)}
</div>
{!provider.available && provider.unavailableReason && (
<p className="mt-1 text-xs text-zinc-600">{provider.unavailableReason}</p>
)}
{provider.id === "ollama" && provider.available && provider.ollamaBaseUrl && (
<p className="mt-1 text-xs text-zinc-600 font-mono">{provider.ollamaBaseUrl}</p>
)}
</div>
{provider.available && (
<div className="flex items-center gap-2 shrink-0">
{!isDefault && (
<button
onClick={() => onSetDefault(provider.id)}
className="text-xs text-zinc-500 hover:text-zinc-300 underline"
>
Set as default
</button>
)}
<button
onClick={handleTest}
disabled={testStatus === "testing"}
className="rounded px-3 py-1 text-xs font-medium border border-zinc-700 text-zinc-300 hover:border-zinc-500 hover:text-zinc-100 transition-colors disabled:opacity-50"
>
{testStatus === "testing" ? "Testing…" : "Test"}
</button>
</div>
)}
</div>
{testStatus === "ok" && (
<p className="mt-2 text-xs text-green-400"> Working</p>
)}
{testStatus === "error" && (
<p className="mt-2 text-xs text-red-400"> {testError}</p>
)}
</div>
);
}