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)
139 lines
4.6 KiB
TypeScript
139 lines
4.6 KiB
TypeScript
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());
|
|
}
|