Archived
MapleBrief: production Canadian news aggregator
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)
This commit is contained in:
@@ -0,0 +1,283 @@
|
||||
'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>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user