From 270bd12382a3ecb916d8534952be1ea55df61f16 Mon Sep 17 00:00:00 2001 From: Rene Fichtmueller Date: Wed, 29 Apr 2026 01:15:45 +0200 Subject: [PATCH] =?UTF-8?q?feat(dashboard):=20clickable=20LLM=20model=20se?= =?UTF-8?q?lector=20=E2=80=94=20switch=20blog=20engine=20at=20runtime?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 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. --- packages/api/src/llm/client.ts | 67 +++++++++++++++++++------ packages/api/src/routes/blog.ts | 28 +++++++++++ packages/dashboard/index.html | 87 ++++++++++++++++++++++++++------- 3 files changed, 149 insertions(+), 33 deletions(-) diff --git a/packages/api/src/llm/client.ts b/packages/api/src/llm/client.ts index b5059a3..4901698 100644 --- a/packages/api/src/llm/client.ts +++ b/packages/api/src/llm/client.ts @@ -11,14 +11,51 @@ * The default local blog model is the latest RunPod-trained FO_BlogLLM adapter. */ -const OLLAMA_URL = process.env.OLLAMA_URL || "http://localhost:11434"; -const LLM_MODEL = process.env.OLLAMA_LLM_MODEL || "fo-blog-v7"; +import { existsSync, readFileSync, writeFileSync } from "fs"; +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_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"; +// ── 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 { text: string; model: string; @@ -184,7 +221,7 @@ async function generateOllama( method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ - model: LLM_MODEL, + model: llmModel(), prompt: userPrompt, system: systemPrompt, stream: false, @@ -276,10 +313,10 @@ export async function generate( userPrompt: string, options?: { temperature?: number; maxTokens?: number; timeoutMs?: number }, ): Promise { - if (BLOG_LLM_PROVIDER === "claude-code") { + if (provider() === "claude-code") { 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 generateOllama(systemPrompt, userPrompt, options); @@ -295,7 +332,7 @@ export async function chat( method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ - model: LLM_MODEL, + model: llmModel(), messages, stream: false, options: { @@ -329,7 +366,10 @@ export async function chat( /** Check if configured LLM provider is available */ 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 { 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}` }; @@ -339,20 +379,19 @@ export async function checkHealth(): Promise<{ ok: boolean; model: string; provi } } - if (BLOG_LLM_PROVIDER === "anthropic" && ANTHROPIC_API_KEY) { - // Key presence check only — live API call causes 429 when pipeline is running + if (p === "anthropic" && ANTHROPIC_API_KEY) { return { ok: true, model: ANTHROPIC_MODEL, provider: "anthropic" }; } try { 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 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) { - 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 }; } } diff --git a/packages/api/src/routes/blog.ts b/packages/api/src/routes/blog.ts index a6a26fb..a2eb2aa 100644 --- a/packages/api/src/routes/blog.ts +++ b/packages/api/src/routes/blog.ts @@ -11,6 +11,7 @@ */ import { Router, Request, Response } from "express"; 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 */ const pipelineProgress = new Map(); @@ -1556,6 +1557,33 @@ blogRouter.post("/llm/reset-queue", (_req: Request, res: Response) => { 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/progress — Real-time pipeline step progress (in-memory) blogRouter.get("/:id/progress", (req: Request, res: Response) => { diff --git a/packages/dashboard/index.html b/packages/dashboard/index.html index b82a8f3..e583d9a 100644 --- a/packages/dashboard/index.html +++ b/packages/dashboard/index.html @@ -1299,7 +1299,7 @@
-
+
🤖 claude-code
@@ -1324,7 +1324,7 @@
-
+
🧠 claude-sonnet-4-6
@@ -1345,40 +1345,40 @@
checking…
- -
+ +
-
🎯 fo-blog-v5
-
Ollama / Mac Studio
+
🎯 fo-blog-v7
+
Adapter Bridge / Mac Studio
★★★★ Blog-Qualität
★★★★★ Geschwindigkeit
-
✓ Fine-tuned auf TIP-Stil (v5)
+
✓ Neu trainierte RunPod-Version
✓ Lokal / keine API-Kosten
-
⚠ Gelegentlicher Mode Collapse
+
⚠ Qualitaet laeuft schon, Feinschliff folgt
- BLOG_LLM_PROVIDER=ollama
OLLAMA_LLM_MODEL=fo-blog-v5
+ BLOG_LLM_PROVIDER=ollama
OLLAMA_LLM_MODEL=fo-blog-v7
checking…
-
+
-
⚡ qwen2.5:14b
+
⚡ qwen2.5:14b (Fallback)
Ollama / Mac Studio
★★★★★ Blog-Qualität
★★★★ Geschwindigkeit
-
✓ Allzweck-Modell
+
✓ Allzweck-/Fallback-Modell
✓ Keine API-Kosten
⚠ Mode Collapse bei komplexen Prompts
@@ -1390,12 +1390,10 @@
- -
- Modell wechseln: SSH → Erik → - nano /opt/tip/ecosystem.config.js - → BLOG_LLM_PROVIDER + CLAUDE_BRIDGE_URL / ANTHROPIC_API_KEY → - pm2 restart tip-api --update-env + + +
+ 💡 Karte anklicken zum Aktivieren — wechselt sofort ohne Neustart
@@ -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() { try { var data = await api('/api/blog/llm/status'); @@ -5702,7 +5751,7 @@ async function loadBlogLLMStatus() { if (foActive) foActive.style.display = 'inline'; var foStatusEl = document.getElementById('blog-model-fo-status'); 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.fontWeight = '600'; }