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,138 @@
|
||||
import { NextRequest, NextResponse } from 'next/server';
|
||||
import { isAuthorized } from '@/lib/auth/admin';
|
||||
import { prisma } from '@/lib/db';
|
||||
import {
|
||||
getLlmSettings,
|
||||
upsertSetting,
|
||||
SETTING_KEYS,
|
||||
} from '@/lib/llm';
|
||||
import type { LlmProviderId } from '@/lib/settings';
|
||||
|
||||
export const runtime = 'nodejs';
|
||||
export const dynamic = 'force-dynamic';
|
||||
|
||||
const PROVIDERS: LlmProviderId[] = ['openai', 'anthropic', 'ollama'];
|
||||
|
||||
const PRO_MODEL_KEYS: Record<LlmProviderId, string> = {
|
||||
openai: 'llm.model.openai',
|
||||
anthropic: 'llm.model.anthropic',
|
||||
ollama: 'llm.model.ollama',
|
||||
};
|
||||
|
||||
/** Last-4 for display only — full secrets never leave the server. */
|
||||
function last4(secret: string): string {
|
||||
return secret.length >= 4 ? `…${secret.slice(-4)}` : '';
|
||||
}
|
||||
|
||||
function isProvider(v: unknown): v is LlmProviderId {
|
||||
return v === 'openai' || v === 'anthropic' || v === 'ollama';
|
||||
}
|
||||
|
||||
async function readSetting(key: string): Promise<string | null> {
|
||||
const row = await prisma.setting.findUnique({ where: { key } });
|
||||
return row?.value ?? null;
|
||||
}
|
||||
|
||||
interface SettingsBody {
|
||||
provider?: unknown;
|
||||
model?: unknown; // legacy single-model field
|
||||
models?: Partial<Record<LlmProviderId, unknown>>;
|
||||
apiKeys?: { openai?: unknown; anthropic?: unknown };
|
||||
ollamaBase?: unknown;
|
||||
synthesisPrompt?: unknown;
|
||||
}
|
||||
|
||||
async function toResponseBody() {
|
||||
const s = await getLlmSettings();
|
||||
const [dbProvider, dbModel, dbOpenai, dbAnthropic] = await Promise.all([
|
||||
readSetting(SETTING_KEYS.provider),
|
||||
readSetting(SETTING_KEYS.model),
|
||||
readSetting(SETTING_KEYS.openaiKey),
|
||||
readSetting(SETTING_KEYS.anthropicKey),
|
||||
]);
|
||||
const models = {} as Record<LlmProviderId, string>;
|
||||
for (const p of PROVIDERS) models[p] = (await readSetting(PRO_MODEL_KEYS[p])) ?? s.model;
|
||||
// A legacy single-model override applies to the provider it was set for.
|
||||
const legacyOwner = isProvider(dbProvider) ? dbProvider : 'openai';
|
||||
if (dbModel) models[legacyOwner] = dbModel;
|
||||
|
||||
return {
|
||||
settings: {
|
||||
provider: s.provider,
|
||||
models,
|
||||
apiKeys: { openai: '', anthropic: '' }, // secrets are never echoed
|
||||
ollamaBase: s.ollamaBaseUrl,
|
||||
synthesisPrompt: s.synthesisPrompt,
|
||||
},
|
||||
keySources: {
|
||||
openai: Boolean(dbOpenai),
|
||||
anthropic: Boolean(dbAnthropic),
|
||||
ollama: true,
|
||||
},
|
||||
keys: {
|
||||
openaiLast4: last4(s.openaiApiKey),
|
||||
anthropicLast4: last4(s.anthropicApiKey),
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
export async function GET(req: NextRequest) {
|
||||
if (!isAuthorized(req)) {
|
||||
return NextResponse.json({ error: 'unauthorized' }, { status: 401 });
|
||||
}
|
||||
return NextResponse.json(await toResponseBody());
|
||||
}
|
||||
|
||||
export async function PUT(req: NextRequest) {
|
||||
if (!isAuthorized(req)) {
|
||||
return NextResponse.json({ error: 'unauthorized' }, { status: 401 });
|
||||
}
|
||||
|
||||
const body = (await req.json().catch(() => null)) as SettingsBody | null;
|
||||
if (!body) return NextResponse.json({ error: 'invalid json' }, { status: 400 });
|
||||
|
||||
// Provider
|
||||
if (body.provider !== undefined) {
|
||||
if (!isProvider(body.provider)) {
|
||||
return NextResponse.json(
|
||||
{ error: 'provider must be openai | anthropic | ollama' },
|
||||
{ status: 400 },
|
||||
);
|
||||
}
|
||||
await upsertSetting(SETTING_KEYS.provider, body.provider);
|
||||
}
|
||||
const active =
|
||||
(body.provider as LlmProviderId | undefined) ?? (await getLlmSettings()).provider;
|
||||
|
||||
// Models — per-provider, with single-model fallback for simple clients
|
||||
if (body.models && typeof body.models === 'object') {
|
||||
for (const p of PROVIDERS) {
|
||||
const m = String(body.models[p] ?? '').trim();
|
||||
if (m) {
|
||||
await upsertSetting(PRO_MODEL_KEYS[p], m);
|
||||
if (p === active) await upsertSetting(SETTING_KEYS.model, m);
|
||||
}
|
||||
}
|
||||
} else if (typeof body.model === 'string' && body.model.trim()) {
|
||||
await upsertSetting(SETTING_KEYS.model, body.model.trim());
|
||||
await upsertSetting(PRO_MODEL_KEYS[active], body.model.trim());
|
||||
}
|
||||
|
||||
// API keys (secrets; blank from the UI means "keep using the env var")
|
||||
if (body.apiKeys) {
|
||||
const openaiKey = String(body.apiKeys.openai ?? '').trim();
|
||||
const anthropicKey = String(body.apiKeys.anthropic ?? '').trim();
|
||||
if (openaiKey) await upsertSetting(SETTING_KEYS.openaiKey, openaiKey, true);
|
||||
if (anthropicKey) await upsertSetting(SETTING_KEYS.anthropicKey, anthropicKey, true);
|
||||
}
|
||||
|
||||
// Ollama base URL
|
||||
const ollamaBase = String(body.ollamaBase ?? '').trim();
|
||||
if (ollamaBase) await upsertSetting(SETTING_KEYS.ollamaBase, ollamaBase);
|
||||
|
||||
// Synthesis prompt
|
||||
const prompt = String(body.synthesisPrompt ?? '').trim();
|
||||
if (prompt) await upsertSetting(SETTING_KEYS.synthesisPrompt, prompt);
|
||||
|
||||
return NextResponse.json(await toResponseBody());
|
||||
}
|
||||
Reference in New Issue
Block a user