feat(dashboard): clickable LLM model selector — switch blog engine at runtime

- client.ts: BLOG_LLM_PROVIDER/OLLAMA_LLM_MODEL as mutable state (setLlmProvider/
  getLlmProvider). Reads blog-llm-settings.json on startup for persistence.
  All generate()/checkHealth()/chat() calls use dynamic provider() + llmModel()
  — no restart required for switches.

- blog.ts: POST /api/blog/llm/switch endpoint — validates provider, calls
  setLlmProvider(), writes settings file, returns previous+active state.

- index.html: all 4 model cards now clickable (cursor:pointer, hover fade).
  switchBlogLlm(provider, model) — disables cards during switch, shows
  green/red feedback toast, auto-refreshes status. SSH instruction removed.
This commit is contained in:
Rene Fichtmueller 2026-04-29 01:15:45 +02:00
parent 39a63e0401
commit 270bd12382
3 changed files with 149 additions and 33 deletions

View File

@ -11,14 +11,51 @@
* The default local blog model is the latest RunPod-trained FO_BlogLLM adapter. * The default local blog model is the latest RunPod-trained FO_BlogLLM adapter.
*/ */
const OLLAMA_URL = process.env.OLLAMA_URL || "http://localhost:11434"; import { existsSync, readFileSync, writeFileSync } from "fs";
const LLM_MODEL = process.env.OLLAMA_LLM_MODEL || "fo-blog-v7"; import { join } from "path";
const OLLAMA_URL = process.env.OLLAMA_URL || "http://localhost:11434";
const ANTHROPIC_API_KEY = process.env.ANTHROPIC_API_KEY || ""; const ANTHROPIC_API_KEY = process.env.ANTHROPIC_API_KEY || "";
const ANTHROPIC_MODEL = process.env.ANTHROPIC_MODEL || "claude-sonnet-4-20250514"; const ANTHROPIC_MODEL = process.env.ANTHROPIC_MODEL || "claude-sonnet-4-20250514";
const BLOG_LLM_PROVIDER = process.env.BLOG_LLM_PROVIDER || "ollama";
const CLAUDE_BRIDGE_URL = process.env.CLAUDE_BRIDGE_URL || "http://localhost:3250"; const CLAUDE_BRIDGE_URL = process.env.CLAUDE_BRIDGE_URL || "http://localhost:3250";
// ── Runtime-switchable provider state ──────────────────────────────────────
// Reads from /opt/tip/blog-llm-settings.json if present (written by /api/blog/llm/switch).
// Falls back to process.env, then to defaults. No restart required for switches.
const SETTINGS_FILE = join(process.env.TIP_ROOT || "/opt/tip", "blog-llm-settings.json");
interface LlmSettings { provider: string; ollamaModel: string }
function loadSettings(): LlmSettings {
try {
if (existsSync(SETTINGS_FILE)) {
const raw = JSON.parse(readFileSync(SETTINGS_FILE, "utf8")) as LlmSettings;
return { provider: raw.provider || "ollama", ollamaModel: raw.ollamaModel || "fo-blog-v7" };
}
} catch { /* ignore corrupt file */ }
return {
provider: process.env.BLOG_LLM_PROVIDER || "ollama",
ollamaModel: process.env.OLLAMA_LLM_MODEL || "fo-blog-v7",
};
}
let _settings = loadSettings();
/** Switch the active LLM provider at runtime. Persists to settings file. */
export function setLlmProvider(provider: string, ollamaModel?: string): void {
_settings = { provider, ollamaModel: ollamaModel || _settings.ollamaModel };
try { writeFileSync(SETTINGS_FILE, JSON.stringify(_settings, null, 2), "utf8"); } catch { /* non-fatal */ }
console.log(`[LLM] Provider switched → ${provider}${ollamaModel ? ` (${ollamaModel})` : ""}`);
}
/** Returns the currently active provider config. */
export function getLlmProvider(): LlmSettings { return { ..._settings }; }
// Convenience getters used below (re-read on every call for zero-latency switch)
function provider(): string { return _settings.provider; }
function llmModel(): string { return _settings.ollamaModel; }
interface LlmResponse { interface LlmResponse {
text: string; text: string;
model: string; model: string;
@ -184,7 +221,7 @@ async function generateOllama(
method: "POST", method: "POST",
headers: { "Content-Type": "application/json" }, headers: { "Content-Type": "application/json" },
body: JSON.stringify({ body: JSON.stringify({
model: LLM_MODEL, model: llmModel(),
prompt: userPrompt, prompt: userPrompt,
system: systemPrompt, system: systemPrompt,
stream: false, stream: false,
@ -276,10 +313,10 @@ export async function generate(
userPrompt: string, userPrompt: string,
options?: { temperature?: number; maxTokens?: number; timeoutMs?: number }, options?: { temperature?: number; maxTokens?: number; timeoutMs?: number },
): Promise<LlmResponse> { ): Promise<LlmResponse> {
if (BLOG_LLM_PROVIDER === "claude-code") { if (provider() === "claude-code") {
return generateClaudeBridge(systemPrompt, userPrompt, options); return generateClaudeBridge(systemPrompt, userPrompt, options);
} }
if (BLOG_LLM_PROVIDER === "anthropic" && ANTHROPIC_API_KEY) { if (provider() === "anthropic" && ANTHROPIC_API_KEY) {
return generateClaude(systemPrompt, userPrompt, options); return generateClaude(systemPrompt, userPrompt, options);
} }
return generateOllama(systemPrompt, userPrompt, options); return generateOllama(systemPrompt, userPrompt, options);
@ -295,7 +332,7 @@ export async function chat(
method: "POST", method: "POST",
headers: { "Content-Type": "application/json" }, headers: { "Content-Type": "application/json" },
body: JSON.stringify({ body: JSON.stringify({
model: LLM_MODEL, model: llmModel(),
messages, messages,
stream: false, stream: false,
options: { options: {
@ -329,7 +366,10 @@ export async function chat(
/** Check if configured LLM provider is available */ /** Check if configured LLM provider is available */
export async function checkHealth(): Promise<{ ok: boolean; model: string; provider: string; error?: string }> { export async function checkHealth(): Promise<{ ok: boolean; model: string; provider: string; error?: string }> {
if (BLOG_LLM_PROVIDER === "claude-code") { const p = provider();
const m = llmModel();
if (p === "claude-code") {
try { try {
const resp = await fetch(`${CLAUDE_BRIDGE_URL}/health`, { signal: AbortSignal.timeout(5000) }); const resp = await fetch(`${CLAUDE_BRIDGE_URL}/health`, { signal: AbortSignal.timeout(5000) });
if (!resp.ok) return { ok: false, model: "claude-code", provider: "claude-code", error: `HTTP ${resp.status}` }; if (!resp.ok) return { ok: false, model: "claude-code", provider: "claude-code", error: `HTTP ${resp.status}` };
@ -339,20 +379,19 @@ export async function checkHealth(): Promise<{ ok: boolean; model: string; provi
} }
} }
if (BLOG_LLM_PROVIDER === "anthropic" && ANTHROPIC_API_KEY) { if (p === "anthropic" && ANTHROPIC_API_KEY) {
// Key presence check only — live API call causes 429 when pipeline is running
return { ok: true, model: ANTHROPIC_MODEL, provider: "anthropic" }; return { ok: true, model: ANTHROPIC_MODEL, provider: "anthropic" };
} }
try { try {
const resp = await fetch(`${OLLAMA_URL}/api/tags`, { signal: AbortSignal.timeout(5000) }); const resp = await fetch(`${OLLAMA_URL}/api/tags`, { signal: AbortSignal.timeout(5000) });
if (!resp.ok) return { ok: false, model: LLM_MODEL, provider: "ollama", error: `HTTP ${resp.status}` }; if (!resp.ok) return { ok: false, model: m, provider: "ollama", error: `HTTP ${resp.status}` };
const data = await resp.json() as { models: Array<{ name: string }> }; const data = await resp.json() as { models: Array<{ name: string }> };
const hasModel = data.models.some((m) => m.name.includes(LLM_MODEL.split(":")[0])); const hasModel = data.models.some((tag) => tag.name.includes(m.split(":")[0]));
return { ok: hasModel, model: LLM_MODEL, provider: "ollama", error: hasModel ? undefined : `Model ${LLM_MODEL} not found` }; return { ok: hasModel, model: m, provider: "ollama", error: hasModel ? undefined : `Model ${m} not found` };
} catch (err) { } catch (err) {
return { ok: false, model: LLM_MODEL, provider: "ollama", error: (err as Error).message }; return { ok: false, model: m, provider: "ollama", error: (err as Error).message };
} }
} }

View File

@ -11,6 +11,7 @@
*/ */
import { Router, Request, Response } from "express"; import { Router, Request, Response } from "express";
import { pool } from "../db/client"; import { pool } from "../db/client";
import { setLlmProvider, getLlmProvider } from "../llm/client";
/** In-memory pipeline progress tracker — step updates pushed here, polled via GET /api/blog/:id/progress */ /** In-memory pipeline progress tracker — step updates pushed here, polled via GET /api/blog/:id/progress */
const pipelineProgress = new Map<string, { step: number; total: number; label: string; pct: number }>(); const pipelineProgress = new Map<string, { step: number; total: number; label: string; pct: number }>();
@ -1556,6 +1557,33 @@ blogRouter.post("/llm/reset-queue", (_req: Request, res: Response) => {
res.json({ success: true, message: "Ollama queue reset — stuck requests cleared" }); res.json({ success: true, message: "Ollama queue reset — stuck requests cleared" });
}); });
// POST /api/blog/llm/switch — Switch active LLM provider at runtime (no restart needed)
// Body: { provider: "claude-code" | "anthropic" | "ollama", model?: "fo-blog-v7" | "qwen2.5:14b" }
blogRouter.post("/llm/switch", (req: Request, res: Response) => {
const { provider, model } = req.body as { provider?: string; model?: string };
const validProviders = ["claude-code", "anthropic", "ollama"];
if (!provider || !validProviders.includes(provider)) {
res.status(400).json({
success: false,
error: `Invalid provider. Valid: ${validProviders.join(", ")}`,
});
return;
}
const prev = getLlmProvider();
setLlmProvider(provider, model);
const next = getLlmProvider();
console.log(`[blog/llm/switch] ${prev.provider}${next.provider} model=${next.ollamaModel}`);
res.json({
success: true,
previous: { provider: prev.provider, model: prev.ollamaModel },
active: { provider: next.provider, model: next.ollamaModel },
message: `Switched to ${next.provider}${next.ollamaModel ? ` (${next.ollamaModel})` : ""}`,
});
});
// GET /api/blog/:id — Get a specific draft with full content // GET /api/blog/:id — Get a specific draft with full content
// GET /api/blog/:id/progress — Real-time pipeline step progress (in-memory) // GET /api/blog/:id/progress — Real-time pipeline step progress (in-memory)
blogRouter.get("/:id/progress", (req: Request, res: Response) => { blogRouter.get("/:id/progress", (req: Request, res: Response) => {

View File

@ -1299,7 +1299,7 @@
<div style="display:grid;grid-template-columns:repeat(4,1fr);gap:0.85rem"> <div style="display:grid;grid-template-columns:repeat(4,1fr);gap:0.85rem">
<!-- Claude-Code (active — flat-rate via claude-bridge) --> <!-- Claude-Code (active — flat-rate via claude-bridge) -->
<div id="blog-model-card-cc" style="border-radius:8px;padding:0.85rem;border:2px solid var(--border);background:var(--surface)"> <div id="blog-model-card-cc" onclick="switchBlogLlm('claude-code')" style="border-radius:8px;padding:0.85rem;border:2px solid var(--border);background:var(--surface);cursor:pointer;transition:opacity 0.15s" onmouseenter="this.style.opacity='0.85'" onmouseleave="this.style.opacity='1'">
<div style="display:flex;align-items:flex-start;justify-content:space-between;margin-bottom:0.6rem"> <div style="display:flex;align-items:flex-start;justify-content:space-between;margin-bottom:0.6rem">
<div> <div>
<div style="font-size:0.8rem;font-weight:700;color:var(--text-bright)">🤖 claude-code</div> <div style="font-size:0.8rem;font-weight:700;color:var(--text-bright)">🤖 claude-code</div>
@ -1324,7 +1324,7 @@
</div> </div>
<!-- Claude API --> <!-- Claude API -->
<div id="blog-model-card-claude" style="border-radius:8px;padding:0.85rem;border:2px solid var(--border);background:var(--surface)"> <div id="blog-model-card-claude" onclick="switchBlogLlm('anthropic')" style="border-radius:8px;padding:0.85rem;border:2px solid var(--border);background:var(--surface);cursor:pointer;transition:opacity 0.15s" onmouseenter="this.style.opacity='0.85'" onmouseleave="this.style.opacity='1'">
<div style="display:flex;align-items:flex-start;justify-content:space-between;margin-bottom:0.6rem"> <div style="display:flex;align-items:flex-start;justify-content:space-between;margin-bottom:0.6rem">
<div> <div>
<div style="font-size:0.8rem;font-weight:700;color:var(--text-bright)">🧠 claude-sonnet-4-6</div> <div style="font-size:0.8rem;font-weight:700;color:var(--text-bright)">🧠 claude-sonnet-4-6</div>
@ -1345,40 +1345,40 @@
<div id="blog-model-claude-status" style="font-size:0.7rem;color:var(--text-dim)">checking…</div> <div id="blog-model-claude-status" style="font-size:0.7rem;color:var(--text-dim)">checking…</div>
</div> </div>
<!-- Fine-tuned local fo-blog-v5 --> <!-- Fine-tuned local FO_BlogLLM RunPod -->
<div id="blog-model-card-fo" style="border-radius:8px;padding:0.85rem;border:2px solid var(--border);background:var(--surface)"> <div id="blog-model-card-fo" onclick="switchBlogLlm('ollama','fo-blog-v7')" style="border-radius:8px;padding:0.85rem;border:2px solid var(--border);background:var(--surface);cursor:pointer;transition:opacity 0.15s" onmouseenter="this.style.opacity='0.85'" onmouseleave="this.style.opacity='1'">
<div style="display:flex;align-items:flex-start;justify-content:space-between;margin-bottom:0.6rem"> <div style="display:flex;align-items:flex-start;justify-content:space-between;margin-bottom:0.6rem">
<div> <div>
<div style="font-size:0.8rem;font-weight:700;color:var(--text-bright)">🎯 fo-blog-v5</div> <div style="font-size:0.8rem;font-weight:700;color:var(--text-bright)">🎯 fo-blog-v7</div>
<div style="font-size:0.68rem;color:var(--text-dim);margin-top:2px">Ollama / Mac Studio</div> <div style="font-size:0.68rem;color:var(--text-dim);margin-top:2px">Adapter Bridge / Mac Studio</div>
</div> </div>
<span id="blog-model-fo-active" style="display:none;font-size:0.62rem;padding:2px 6px;border-radius:3px;background:#1a7a3a;color:#fff;font-weight:700">● AKTIV</span> <span id="blog-model-fo-active" style="display:none;font-size:0.62rem;padding:2px 6px;border-radius:3px;background:#1a7a3a;color:#fff;font-weight:700">● AKTIV</span>
</div> </div>
<div style="font-size:0.72rem;color:var(--text);line-height:1.8;margin-bottom:0.6rem"> <div style="font-size:0.72rem;color:var(--text);line-height:1.8;margin-bottom:0.6rem">
<div><span style="color:var(--accent)">★★★★</span><span style="color:var(--text-dim)"></span> Blog-Qualität</div> <div><span style="color:var(--accent)">★★★★</span><span style="color:var(--text-dim)"></span> Blog-Qualität</div>
<div><span style="color:var(--accent)">★★★★★</span> Geschwindigkeit</div> <div><span style="color:var(--accent)">★★★★★</span> Geschwindigkeit</div>
<div style="margin-top:0.35rem;color:#1a7a3a;font-weight:500">Fine-tuned auf TIP-Stil (v5)</div> <div style="margin-top:0.35rem;color:#1a7a3a;font-weight:500">Neu trainierte RunPod-Version</div>
<div style="color:#1a7a3a;font-weight:500">✓ Lokal / keine API-Kosten</div> <div style="color:#1a7a3a;font-weight:500">✓ Lokal / keine API-Kosten</div>
<div style="color:#b45309;font-weight:500">Gelegentlicher Mode Collapse</div> <div style="color:#b45309;font-weight:500">Qualitaet laeuft schon, Feinschliff folgt</div>
</div> </div>
<div style="background:#1e1e1e;border-radius:5px;padding:0.5rem 0.65rem;margin-bottom:0.5rem"> <div style="background:#1e1e1e;border-radius:5px;padding:0.5rem 0.65rem;margin-bottom:0.5rem">
<code style="font-size:0.65rem;color:#f8f8f2;line-height:1.6;word-break:break-all;display:block">BLOG_LLM_PROVIDER=ollama<br>OLLAMA_LLM_MODEL=fo-blog-v5</code> <code style="font-size:0.65rem;color:#f8f8f2;line-height:1.6;word-break:break-all;display:block">BLOG_LLM_PROVIDER=ollama<br>OLLAMA_LLM_MODEL=fo-blog-v7</code>
</div> </div>
<div id="blog-model-fo-status" style="font-size:0.7rem;color:var(--text-dim)">checking…</div> <div id="blog-model-fo-status" style="font-size:0.7rem;color:var(--text-dim)">checking…</div>
</div> </div>
<!-- Standard Qwen --> <!-- Standard Qwen -->
<div id="blog-model-card-qwen" style="border-radius:8px;padding:0.85rem;border:2px solid var(--border);background:var(--surface)"> <div id="blog-model-card-qwen" onclick="switchBlogLlm('ollama','qwen2.5:14b')" style="border-radius:8px;padding:0.85rem;border:2px solid var(--border);background:var(--surface);cursor:pointer;transition:opacity 0.15s" onmouseenter="this.style.opacity='0.85'" onmouseleave="this.style.opacity='1'">
<div style="display:flex;align-items:flex-start;justify-content:space-between;margin-bottom:0.6rem"> <div style="display:flex;align-items:flex-start;justify-content:space-between;margin-bottom:0.6rem">
<div> <div>
<div style="font-size:0.8rem;font-weight:700;color:var(--text-bright)">⚡ qwen2.5:14b</div> <div style="font-size:0.8rem;font-weight:700;color:var(--text-bright)">⚡ qwen2.5:14b (Fallback)</div>
<div style="font-size:0.68rem;color:var(--text-dim);margin-top:2px">Ollama / Mac Studio</div> <div style="font-size:0.68rem;color:var(--text-dim);margin-top:2px">Ollama / Mac Studio</div>
</div> </div>
</div> </div>
<div style="font-size:0.72rem;color:var(--text);line-height:1.8;margin-bottom:0.6rem"> <div style="font-size:0.72rem;color:var(--text);line-height:1.8;margin-bottom:0.6rem">
<div><span style="color:var(--accent)">★★★</span><span style="color:var(--text-dim)">★★</span> Blog-Qualität</div> <div><span style="color:var(--accent)">★★★</span><span style="color:var(--text-dim)">★★</span> Blog-Qualität</div>
<div><span style="color:var(--accent)">★★★★</span><span style="color:var(--text-dim)"></span> Geschwindigkeit</div> <div><span style="color:var(--accent)">★★★★</span><span style="color:var(--text-dim)"></span> Geschwindigkeit</div>
<div style="margin-top:0.35rem;color:#1a7a3a;font-weight:500">✓ Allzweck-Modell</div> <div style="margin-top:0.35rem;color:#1a7a3a;font-weight:500">✓ Allzweck-/Fallback-Modell</div>
<div style="color:#1a7a3a;font-weight:500">✓ Keine API-Kosten</div> <div style="color:#1a7a3a;font-weight:500">✓ Keine API-Kosten</div>
<div style="color:#b45309;font-weight:500">⚠ Mode Collapse bei komplexen Prompts</div> <div style="color:#b45309;font-weight:500">⚠ Mode Collapse bei komplexen Prompts</div>
</div> </div>
@ -1390,12 +1390,10 @@
</div><!-- end model grid --> </div><!-- end model grid -->
<!-- Config note --> <!-- Switch feedback -->
<div style="margin-top:0.85rem;padding:0.6rem 0.85rem;background:var(--surface2);border-radius:6px;font-size:0.72rem;color:var(--text)"> <div id="blog-llm-switch-msg" style="display:none;margin-top:0.85rem;padding:0.55rem 0.85rem;border-radius:6px;font-size:0.75rem;font-weight:600"></div>
<strong>Modell wechseln:</strong> SSH → Erik → <div style="margin-top:0.6rem;padding:0.45rem 0.85rem;background:var(--surface2);border-radius:6px;font-size:0.7rem;color:var(--text-dim)">
<code style="background:#1e1e1e;color:#f8f8f2;padding:1px 5px;border-radius:3px">nano /opt/tip/ecosystem.config.js</code> 💡 Karte anklicken zum Aktivieren — wechselt sofort ohne Neustart
→ BLOG_LLM_PROVIDER + CLAUDE_BRIDGE_URL / ANTHROPIC_API_KEY →
<code style="background:#1e1e1e;color:#f8f8f2;padding:1px 5px;border-radius:3px">pm2 restart tip-api --update-env</code>
</div> </div>
</div><!-- end LLM panel --> </div><!-- end LLM panel -->
@ -5630,6 +5628,57 @@ async function runFinder() {
async function switchBlogLlm(providerKey, model) {
var msgEl = document.getElementById('blog-llm-switch-msg');
// Disable all cards visually while switching
['cc','claude','fo','qwen'].forEach(function(k) {
var c = document.getElementById('blog-model-card-' + k);
if (c) { c.style.pointerEvents = 'none'; c.style.opacity = '0.5'; }
});
if (msgEl) {
msgEl.style.display = 'block';
msgEl.style.background = 'var(--surface3)';
msgEl.style.color = 'var(--text-dim)';
msgEl.textContent = '↺ Wechsle zu ' + providerKey + (model ? ' (' + model + ')' : '') + '…';
}
try {
var body = { provider: providerKey };
if (model) body.model = model;
var res = await fetch(API + '/api/blog/llm/switch', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(body)
});
var data = await res.json();
if (data.success) {
if (msgEl) {
msgEl.style.background = '#d1fae5';
msgEl.style.color = '#065f46';
msgEl.textContent = '✓ ' + data.message;
setTimeout(function() { if (msgEl) msgEl.style.display = 'none'; }, 3000);
}
await loadBlogLLMStatus();
} else {
if (msgEl) {
msgEl.style.background = '#fee2e2';
msgEl.style.color = '#b91c1c';
msgEl.textContent = '✗ Fehler: ' + (data.error || 'Unbekannt');
}
}
} catch(e) {
if (msgEl) {
msgEl.style.background = '#fee2e2';
msgEl.style.color = '#b91c1c';
msgEl.textContent = '✗ Netzwerkfehler: ' + e.message;
}
} finally {
['cc','claude','fo','qwen'].forEach(function(k) {
var c = document.getElementById('blog-model-card-' + k);
if (c) { c.style.pointerEvents = ''; c.style.opacity = ''; }
});
}
}
async function loadBlogLLMStatus() { async function loadBlogLLMStatus() {
try { try {
var data = await api('/api/blog/llm/status'); var data = await api('/api/blog/llm/status');
@ -5702,7 +5751,7 @@ async function loadBlogLLMStatus() {
if (foActive) foActive.style.display = 'inline'; if (foActive) foActive.style.display = 'inline';
var foStatusEl = document.getElementById('blog-model-fo-status'); var foStatusEl = document.getElementById('blog-model-fo-status');
if (foStatusEl) { if (foStatusEl) {
foStatusEl.textContent = llm.ok ? '● Aktiv — Ollama erreichbar' : '⚠ Ollama nicht erreichbar: ' + (llm.error || '').slice(0, 60); foStatusEl.textContent = llm.ok ? ('● Aktiv — ' + (llm.model || 'fo-blog-v7') + ' erreichbar') : '⚠ Ollama/Bridge nicht erreichbar: ' + (llm.error || '').slice(0, 60);
foStatusEl.style.color = llm.ok ? '#1a7a3a' : '#b45309'; foStatusEl.style.color = llm.ok ? '#1a7a3a' : '#b45309';
foStatusEl.style.fontWeight = '600'; foStatusEl.style.fontWeight = '600';
} }