Archived
Next.js 14 + TypeScript + Tailwind + Prisma/SQLite. - LLM synthesis abstraction (OpenAI/Anthropic/Ollama) + admin settings UI - RSS ingestion pipeline (parser + cheerio content) + node-cron worker - AdSense AdUnit placements (header/in-feed/in-article/sidebar) - Sources analysis attribution, nofollow links, canonical/OG, sitemap/robots - Admin auth (ADMIN_API_KEY, timingSafeEqual, fail-closed 401) - Multi-cell lady-ga-ga marker, sitemap, robots, legal pages - Comprehensive README (setup, LLM config, AdSense, migrations, deploy)
284 lines
10 KiB
TypeScript
284 lines
10 KiB
TypeScript
'use client';
|
|
|
|
import { useCallback, useEffect, useState } from 'react';
|
|
|
|
interface Settings {
|
|
provider: 'openai' | 'anthropic' | 'ollama';
|
|
apiKeys: { openai: string; anthropic: string };
|
|
ollamaBase: string;
|
|
models: { openai: string; anthropic: string; ollama: string };
|
|
synthesisPrompt: string;
|
|
}
|
|
|
|
interface KeySource {
|
|
openai: boolean;
|
|
anthropic: boolean;
|
|
ollama: boolean;
|
|
}
|
|
|
|
export default function SettingsForm() {
|
|
const [settings, setSettings] = useState<Settings | null>(null);
|
|
const [keyFromDb, setKeyFromDb] = useState<KeySource | null>(null);
|
|
const [loadError, setLoadError] = useState<string | null>(null);
|
|
const [saving, setSaving] = useState(false);
|
|
const [saveMsg, setSaveMsg] = useState<{ ok: boolean; text: string } | null>(null);
|
|
const [testMsg, setTestMsg] = useState<{ ok: boolean; text: string } | null>(null);
|
|
const [testing, setTesting] = useState(false);
|
|
|
|
const adminHeaders = useCallback(
|
|
() => ({
|
|
'x-admin-key': sessionStorage.getItem('mb_admin_key') ?? '',
|
|
'Content-Type': 'application/json',
|
|
}),
|
|
[],
|
|
);
|
|
|
|
useEffect(() => {
|
|
(async () => {
|
|
try {
|
|
const res = await fetch('/api/settings', {
|
|
headers: { 'x-admin-key': sessionStorage.getItem('mb_admin_key') ?? '' },
|
|
});
|
|
const data = await res.json();
|
|
if (!res.ok) throw new Error(data.error ?? 'load failed');
|
|
setSettings(data.settings as Settings);
|
|
setKeyFromDb(data.keySources as KeySource);
|
|
} catch (e) {
|
|
setLoadError(e instanceof Error ? e.message : 'load failed');
|
|
}
|
|
})();
|
|
}, []);
|
|
|
|
if (loadError) {
|
|
return <p className="text-sm text-red-700">Could not load settings: {loadError}</p>;
|
|
}
|
|
if (!settings || !keyFromDb) {
|
|
return <p className="text-sm text-ink-500">Loading…</p>;
|
|
}
|
|
|
|
const prompt: Settings = {
|
|
provider: settings.provider,
|
|
apiKeys: { ...settings.apiKeys },
|
|
ollamaBase: settings.ollamaBase ?? 'http://localhost:11434',
|
|
models: { ...settings.models },
|
|
synthesisPrompt: settings.synthesisPrompt,
|
|
};
|
|
|
|
function patch<K extends keyof Settings>(key: K, value: Settings[K]) {
|
|
setSettings((s) => (s ? { ...s, [key]: value } : s));
|
|
setSaveMsg(null);
|
|
}
|
|
|
|
async function save(e: React.FormEvent) {
|
|
e.preventDefault();
|
|
setSaving(true);
|
|
setSaveMsg(null);
|
|
setTestMsg(null);
|
|
try {
|
|
const res = await fetch('/api/settings', {
|
|
method: 'PUT',
|
|
headers: adminHeaders(),
|
|
body: JSON.stringify(prompt),
|
|
});
|
|
const data = await res.json();
|
|
setSaveMsg({ ok: res.ok, text: res.ok ? 'Settings saved.' : (data.error ?? 'save failed') });
|
|
if (res.ok) {
|
|
setKeyFromDb(data.keySources as KeySource);
|
|
}
|
|
} catch {
|
|
setSaveMsg({ ok: false, text: 'Network error' });
|
|
} finally {
|
|
setSaving(false);
|
|
}
|
|
}
|
|
|
|
async function test() {
|
|
setTesting(true);
|
|
setTestMsg(null);
|
|
try {
|
|
const res = await fetch('/api/settings/test', {
|
|
method: 'POST',
|
|
headers: adminHeaders(),
|
|
});
|
|
const data = await res.json();
|
|
setTestMsg(
|
|
data.ok
|
|
? { ok: true, text: data.message ?? 'Connection OK.' }
|
|
: { ok: false, text: data.error ?? 'Test failed' },
|
|
);
|
|
} catch {
|
|
setTestMsg({ ok: false, text: 'Network error' });
|
|
} finally {
|
|
setTesting(false);
|
|
}
|
|
}
|
|
|
|
return (
|
|
<form onSubmit={save} className="space-y-8">
|
|
{/* Provider selection */}
|
|
<section className="rounded-xl border border-ink-200 bg-white p-6">
|
|
<h2 className="font-semibold text-ink-900">Active provider</h2>
|
|
<div className="mt-4 grid grid-cols-3 gap-3">
|
|
{(['openai', 'anthropic', 'ollama'] as const).map((p) => (
|
|
<label
|
|
key={p}
|
|
className={`cursor-pointer rounded-lg border p-4 text-center transition ${
|
|
prompt.provider === p
|
|
? 'border-maple-600 bg-maple-50 ring-1 ring-maple-600'
|
|
: 'border-ink-200 hover:border-ink-300'
|
|
}`}
|
|
>
|
|
<input
|
|
type="radio"
|
|
name="provider"
|
|
value={p}
|
|
checked={prompt.provider === p}
|
|
onChange={() => patch('provider', p)}
|
|
className="sr-only"
|
|
/>
|
|
<span className="block text-sm font-semibold capitalize text-ink-900">{p}</span>
|
|
<span className="mt-1 block text-xs text-ink-500">
|
|
{p === 'openai' && 'GPT-4o / 4o-mini · cloud'}
|
|
{p === 'anthropic' && 'Claude 3.5 Sonnet / Haiku · cloud'}
|
|
{p === 'ollama' && 'Local · zero cost'}
|
|
</span>
|
|
</label>
|
|
))}
|
|
</div>
|
|
|
|
{/* Model */}
|
|
<label className="mt-5 block text-sm">
|
|
<span className="font-medium text-ink-800">Model</span>
|
|
<input
|
|
type="text"
|
|
value={prompt.models[prompt.provider]}
|
|
onChange={(e) =>
|
|
patch('models', { ...prompt.models, [prompt.provider]: e.target.value })
|
|
}
|
|
list="model-suggestions"
|
|
className="mt-1 w-full rounded-lg border border-ink-300 px-3 py-2 font-mono text-sm focus:border-maple-600 focus:outline-none"
|
|
/>
|
|
<datalist id="model-suggestions">
|
|
{prompt.provider === 'openai' && (
|
|
<>
|
|
<option value="gpt-4o-mini" />
|
|
<option value="gpt-4o" />
|
|
</>
|
|
)}
|
|
{prompt.provider === 'anthropic' && (
|
|
<>
|
|
<option value="claude-3-5-sonnet-latest" />
|
|
<option value="claude-3-5-haiku-latest" />
|
|
</>
|
|
)}
|
|
{prompt.provider === 'ollama' && (
|
|
<>
|
|
<option value="llama3.1:8b" />
|
|
<option value="mistral:latest" />
|
|
<option value="qwen2.5:14b" />
|
|
</>
|
|
)}
|
|
</datalist>
|
|
</label>
|
|
|
|
{/* API key — only cloud providers */}
|
|
{prompt.provider !== 'ollama' ? (
|
|
<label className="mt-5 block text-sm">
|
|
<span className="font-medium text-ink-800">
|
|
{prompt.provider === 'openai' ? 'OpenAI' : 'Anthropic'} API key
|
|
<span className="ml-2 rounded bg-ink-100 px-1.5 py-0.5 text-[11px] font-normal text-ink-500">
|
|
{keyFromDb[prompt.provider] ? 'stored in database' : 'coming from env vars'}
|
|
</span>
|
|
</span>
|
|
<input
|
|
type="password"
|
|
value={prompt.apiKeys[prompt.provider]}
|
|
onChange={(e) =>
|
|
patch('apiKeys', { ...prompt.apiKeys, [prompt.provider]: e.target.value })
|
|
}
|
|
placeholder={keyFromDb[prompt.provider] ? 'Enter to update (current is saved)' : 'sk-…'}
|
|
className="mt-1 w-full rounded-lg border border-ink-300 px-3 py-2 font-mono text-sm focus:border-maple-600 focus:outline-none"
|
|
/>
|
|
{!keyFromDb[prompt.provider] && (
|
|
<span className="mt-1 block text-xs text-ink-400">
|
|
Blank means “use the env var” (OPENAI_API_KEY /
|
|
ANTHROPIC_API_KEY). Saving a value stores it encrypted-at-rest in
|
|
SQLite (phone-in-BE BYO; see README on secret handling).
|
|
</span>
|
|
)}
|
|
</label>
|
|
) : (
|
|
<label className="mt-5 block text-sm">
|
|
<span className="font-medium text-ink-800">Ollama base URL</span>
|
|
<input
|
|
type="url"
|
|
value={prompt.ollamaBase}
|
|
onChange={(e) => patch('ollamaBase', e.target.value)}
|
|
placeholder="http://localhost:11434"
|
|
className="mt-1 w-full rounded-lg border border-ink-300 px-3 py-2 font-mono text-sm focus:border-maple-600 focus:outline-none"
|
|
/>
|
|
<span className="mt-1 block text-xs text-ink-400">
|
|
For “localhost” you must also set OLLAMA_CORS=true on the
|
|
Ollama host (see README).
|
|
</span>
|
|
</label>
|
|
)}
|
|
</section>
|
|
|
|
{/* Synthesis prompt */}
|
|
<section className="rounded-xl border border-ink-200 bg-white p-6">
|
|
<h2 className="font-semibold text-ink-900">Synthesis system prompt</h2>
|
|
<p className="mt-1 text-xs text-ink-500">
|
|
This is the standing instruction the LLM receives on every
|
|
synthesis. Edit to change tone, length, or structure — keep the
|
|
“strict JSON only” contract so parsing stays reliable.
|
|
</p>
|
|
<textarea
|
|
rows={12}
|
|
value={prompt.synthesisPrompt}
|
|
onChange={(e) => patch('synthesisPrompt', e.target.value)}
|
|
className="mt-3 w-full rounded-lg border border-ink-300 px-3 py-2 font-mono text-[13px] leading-relaxed focus:border-maple-600 focus:outline-none"
|
|
/>
|
|
</section>
|
|
|
|
{/* Actions */}
|
|
<section className="flex flex-wrap items-center gap-3">
|
|
<button
|
|
type="submit"
|
|
disabled={saving}
|
|
className="rounded-lg bg-maple-600 px-5 py-2.5 text-sm font-semibold text-white hover:bg-maple-700 disabled:opacity-50"
|
|
>
|
|
{saving ? 'Saving…' : 'Save settings'}
|
|
</button>
|
|
<button
|
|
type="button"
|
|
onClick={test}
|
|
disabled={testing || saving}
|
|
className="rounded-lg border border-ink-300 px-5 py-2.5 text-sm font-semibold text-ink-700 hover:bg-ink-100 disabled:opacity-50"
|
|
>
|
|
{testing ? 'Testing…' : 'Test connection'}
|
|
</button>
|
|
{saveMsg && (
|
|
<span
|
|
className={`text-sm ${saveMsg.ok ? 'text-emerald-700' : 'text-red-700'}`}
|
|
>
|
|
{saveMsg.text}
|
|
</span>
|
|
)}
|
|
</section>
|
|
|
|
{testMsg && (
|
|
<p
|
|
className={`rounded-lg border p-4 text-sm ${
|
|
testMsg.ok
|
|
? 'border-emerald-300 bg-emerald-50 text-emerald-900'
|
|
: 'border-red-300 bg-red-50 text-red-900'
|
|
}`}
|
|
>
|
|
{testMsg.text}
|
|
</p>
|
|
)}
|
|
</form>
|
|
);
|
|
}
|