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:
63
components/settings/OllamaModelPicker.tsx
Normal file
63
components/settings/OllamaModelPicker.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
79
components/settings/PreferencesPanel.tsx
Normal file
79
components/settings/PreferencesPanel.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
114
components/settings/ProviderStatus.tsx
Normal file
114
components/settings/ProviderStatus.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user