feat: add claude-code LLM provider + update dashboard to fo-blog-v5

- client.ts: add claude-code provider routing BLOG_LLM_PROVIDER=claude-code
  to claude-bridge (flat-rate, no API billing via Claude Code subscription)
- checkHealth() now pings /health on claude-bridge for real availability check
- Default OLLAMA_LLM_MODEL changed from qwen2.5:14b to fo-blog-v5
- Dashboard: add claude-code card (EMPFOHLEN), rename fo-blog-v3 → fo-blog-v5
- loadBlogLLMStatus() handles all 3 providers: claude-code/anthropic/ollama
- Grid expanded from 3 to 4 columns to accommodate new card
- ecosystem.config.js + .env on Erik: OLLAMA_LLM_MODEL=fo-blog-v5 confirmed
This commit is contained in:
Rene Fichtmueller 2026-04-18 20:45:14 +02:00
parent b9bdcd6fc6
commit 2ebba07bb0
3 changed files with 140 additions and 30 deletions

View File

@ -3,6 +3,7 @@
Format: `{"d":"YYYY-MM-DD","t":"TYPE","m":"Description"}`
Types: FEAT · FIX · UI · DATA · AI · INFRA
{"d":"2026-04-18","t":"AI","m":"Blog LLM: claude-code provider implemented in packages/api/src/llm/client.ts — routes BLOG_LLM_PROVIDER=claude-code to claude-bridge (http://localhost:3250/api/generate) on Erik using Claude Code flat-rate subscription. No API billing. checkHealth() pings /health endpoint. Dashboard updated: added claude-code card (EMPFOHLEN, AKTIV), fo-blog-v3-qwen7b card replaced with fo-blog-v5, loadBlogLLMStatus() now handles claude-code provider with correct badge/border highlighting. ecosystem.config.js + .env updated: OLLAMA_LLM_MODEL=fo-blog-v5, BLOG_LLM_PROVIDER=claude-code confirmed active via pm2 env."}
{"d":"2026-04-18","t":"FIX","m":"Cloudflare Tunnel DNS mass-update: after deleting phantom eo-pulse tunnel and creating main-prod (90c22eb0), 31 context-x.org + 7 fichtmueller.org DNS records still pointed to the deleted 641c39a5 tunnel → 530 on all services. Bulk-patched via Cloudflare API: all records now point to main-prod. Created missing admin.magatama.fichtmueller.org CNAME. TIP cloudflared-tip.service restart policy changed to Restart=always (was on-failure, so clean exits caused permanent outage). peercortex.org remains 530 — DNS is in a separate inaccessible Cloudflare account (NS: fattouche/elisabeth.ns.cloudflare.com); needs manual login."}
{"d":"2026-04-18","t":"DATA","m":"Image backfill: GBICS og:image + QSFPTEK backfill scripts run on Erik — 226 new images added (671 → 897 total, 17.5% → 23.4% coverage). OSFP form factor: 0 → 68 images. QSFPTEK og:image URL bug fixed (double-hostname prefix stripped). OSFP-DR8-800G manually set to GBICS-compatible image (cdn11.bigcommerce.com DR8 product photo)."}
{"d":"2026-04-18","t":"FIX","m":"FS.com scraper: all 247 prices written as €79 (wrong) — root cause: 'Gratis Versand ab 79 € (ohne MwSt.)' free-shipping banner appears on every FS.com product page. PRICE_QUALIFIED bodyText regex matched this banner text before reaching the actual product price. Fix: (1) DOM-based price extraction added to page.evaluate — targets [class*='price-value']/[class*='product-price'] etc., skipping elements inside shipping/banner/footer parents; (2) bodyText qualified patterns now check 200-char context for versand/shipping/gratis keywords and skip matches that appear in shipping context; (3) waitForSelector for price elements added before evaluate; (4) deleted 247 invalid €79 observations from DB."}

View File

@ -1,20 +1,23 @@
/**
* LLM client for blog generation supports Ollama (local) and Anthropic Claude (API).
* LLM client for blog generation supports Ollama (local), Anthropic Claude (API),
* and Claude-Code (flat-rate via claude-bridge on Erik).
*
* Provider selection:
* BLOG_LLM_PROVIDER=claude-code Claude via claude-bridge (flat-rate, recommended)
* BLOG_LLM_PROVIDER=anthropic Claude Sonnet/Haiku via Anthropic API
* BLOG_LLM_PROVIDER=ollama qwen2.5 on local Ollama (default)
* BLOG_LLM_PROVIDER=ollama fine-tuned fo-blog-v5 on local Ollama (default)
*
* Claude is strongly recommended for blog generation qwen2.5:14b cannot
* follow complex multi-constraint prompts (mode collapse).
* Claude-code is preferred: uses Claude Code subscription (flat-rate), no API costs.
* Ollama fo-blog-v5 is the fallback for offline/local usage.
*/
const OLLAMA_URL = process.env.OLLAMA_URL || "http://localhost:11434";
const LLM_MODEL = process.env.OLLAMA_LLM_MODEL || "qwen2.5:14b";
const LLM_MODEL = process.env.OLLAMA_LLM_MODEL || "fo-blog-v5";
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";
interface LlmResponse {
text: string;
@ -222,6 +225,48 @@ async function generateOllama(
});
}
// ═══════════════════════════════════════════════════════
// CLAUDE-CODE PROVIDER (claude-bridge — flat-rate via Claude Code subscription)
// ═══════════════════════════════════════════════════════
async function generateClaudeBridge(
systemPrompt: string,
userPrompt: string,
options?: { temperature?: number; maxTokens?: number; timeoutMs?: number },
): Promise<LlmResponse> {
const startTime = Date.now();
// claude-bridge expects combined prompt — system + user joined with double newline
const fullPrompt = `${systemPrompt}\n\n${userPrompt}`;
const resp = await fetch(`${CLAUDE_BRIDGE_URL}/api/generate`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ prompt: fullPrompt }),
signal: AbortSignal.timeout(options?.timeoutMs ?? 300000),
});
if (!resp.ok) {
const errText = await resp.text();
throw new Error(`Claude bridge failed: ${resp.status} ${errText.slice(0, 200)}`);
}
const data = await resp.json() as { success: boolean; content?: string; error?: string };
if (!data.success || !data.content) {
throw new Error(`Claude bridge returned empty response: ${JSON.stringify(data)}`);
}
const duration = Date.now() - startTime;
console.log(`[LLM] Claude-bridge: ${data.content.length} chars, ${duration}ms`);
return {
text: data.content,
model: "claude-code",
totalDuration: duration * 1_000_000, // ns for compat with Ollama callers
evalCount: Math.ceil(data.content.length / 4), // approx tokens
};
}
// ═══════════════════════════════════════════════════════
// PUBLIC API — auto-routes to configured provider
// ═══════════════════════════════════════════════════════
@ -231,6 +276,9 @@ export async function generate(
userPrompt: string,
options?: { temperature?: number; maxTokens?: number; timeoutMs?: number },
): Promise<LlmResponse> {
if (BLOG_LLM_PROVIDER === "claude-code") {
return generateClaudeBridge(systemPrompt, userPrompt, options);
}
if (BLOG_LLM_PROVIDER === "anthropic" && ANTHROPIC_API_KEY) {
return generateClaude(systemPrompt, userPrompt, options);
}
@ -281,6 +329,16 @@ 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") {
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}` };
return { ok: true, model: "claude-code", provider: "claude-code" };
} catch (err) {
return { ok: false, model: "claude-code", provider: "claude-code", error: (err as Error).message };
}
}
if (BLOG_LLM_PROVIDER === "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" };

View File

@ -1243,23 +1243,48 @@
</div>
<!-- Model cards -->
<div style="display:grid;grid-template-columns:repeat(3,1fr);gap:0.85rem">
<div style="display:grid;grid-template-columns:repeat(4,1fr);gap:0.85rem">
<!-- Claude (recommended) -->
<!-- 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 style="display:flex;align-items:flex-start;justify-content:space-between;margin-bottom:0.6rem">
<div>
<div style="font-size:0.8rem;font-weight:700;color:var(--text-bright)">🤖 claude-code</div>
<div style="font-size:0.68rem;color:var(--text-dim);margin-top:2px">claude-bridge / Erik</div>
</div>
<div style="display:flex;flex-direction:column;align-items:flex-end;gap:3px">
<span style="font-size:0.62rem;padding:2px 6px;border-radius:3px;background:var(--accent);color:#fff;font-weight:700;white-space:nowrap">★ EMPFOHLEN</span>
<span id="blog-model-cc-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><span style="color:var(--accent)">★★★★★</span> Blog-Qualität</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">✓ Kein Mode Collapse</div>
<div style="color:#1a7a3a;font-weight:500">✓ Flat-rate (kein API-Billing)</div>
<div style="color:#1a7a3a;font-weight:500">✓ Claude Code Subscription</div>
</div>
<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=claude-code<br>CLAUDE_BRIDGE_URL=http://localhost:3250</code>
</div>
<div id="blog-model-cc-status" style="font-size:0.7rem;color:var(--text-dim)">checking…</div>
</div>
<!-- Claude API -->
<div id="blog-model-card-claude" style="border-radius:8px;padding:0.85rem;border:2px solid var(--border);background:var(--surface)">
<div style="display:flex;align-items:flex-start;justify-content:space-between;margin-bottom:0.6rem">
<div>
<div style="font-size:0.8rem;font-weight:700;color:var(--text-bright)">🧠 claude-sonnet-4-6</div>
<div style="font-size:0.68rem;color:var(--text-dim);margin-top:2px">Anthropic API</div>
</div>
<span style="font-size:0.62rem;padding:2px 6px;border-radius:3px;background:var(--accent);color:#fff;font-weight:700;white-space:nowrap">★ EMPFOHLEN</span>
<span id="blog-model-claude-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 style="font-size:0.72rem;color:var(--text);line-height:1.8;margin-bottom:0.6rem">
<div><span style="color:var(--accent)">★★★★★</span> Blog-Qualität</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">✓ Komplexe Multi-Constraint Prompts</div>
<div style="color:#1a7a3a;font-weight:500">✓ Kein Mode Collapse</div>
<div style="color:#1a7a3a;font-weight:500">✓ 4096 Token Output</div>
<div style="color:#b45309;font-weight:500">⚠ API-Kosten pro Artikel</div>
</div>
<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=anthropic<br>ANTHROPIC_MODEL=claude-sonnet-4-6</code>
@ -1267,11 +1292,11 @@
<div id="blog-model-claude-status" style="font-size:0.7rem;color:var(--text-dim)">checking…</div>
</div>
<!-- Fine-tuned local -->
<!-- Fine-tuned local fo-blog-v5 -->
<div id="blog-model-card-fo" style="border-radius:8px;padding:0.85rem;border:2px solid var(--border);background:var(--surface)">
<div style="display:flex;align-items:flex-start;justify-content:space-between;margin-bottom:0.6rem">
<div>
<div style="font-size:0.8rem;font-weight:700;color:var(--text-bright)">🎯 fo-blog-v3-qwen7b</div>
<div style="font-size:0.8rem;font-weight:700;color:var(--text-bright)">🎯 fo-blog-v5</div>
<div style="font-size:0.68rem;color:var(--text-dim);margin-top:2px">Ollama / Mac Studio</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>
@ -1279,12 +1304,12 @@
<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> Geschwindigkeit</div>
<div style="margin-top:0.35rem;color:#1a7a3a;font-weight:500">✓ Fine-tuned auf TIP-Stil</div>
<div style="color:#1a7a3a;font-weight:500">✓ Lokal / kein API-Kosten</div>
<div style="margin-top:0.35rem;color:#1a7a3a;font-weight:500">✓ Fine-tuned auf TIP-Stil (v5)</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>
<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-v3-qwen7b</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-v5</code>
</div>
<div id="blog-model-fo-status" style="font-size:0.7rem;color:var(--text-dim)">checking…</div>
</div>
@ -1316,7 +1341,7 @@
<div style="margin-top:0.85rem;padding:0.6rem 0.85rem;background:var(--surface2);border-radius:6px;font-size:0.72rem;color:var(--text)">
<strong>Modell wechseln:</strong> SSH → Erik →
<code style="background:#1e1e1e;color:#f8f8f2;padding:1px 5px;border-radius:3px">nano /opt/tip/ecosystem.config.js</code>
→ BLOG_LLM_PROVIDER + ANTHROPIC_API_KEY →
→ 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><!-- end LLM panel -->
@ -4934,7 +4959,8 @@ async function loadBlogLLMStatus() {
}
if (activeModel) activeModel.textContent = llm.model || '—';
if (activeProvider) {
activeProvider.textContent = llm.provider === 'anthropic' ? 'anthropic' : 'ollama';
var provLabel = llm.provider === 'claude-code' ? 'claude-code' : llm.provider === 'anthropic' ? 'anthropic' : 'ollama';
activeProvider.textContent = provLabel;
activeProvider.style.background = 'var(--accent)';
activeProvider.style.color = '#fff';
}
@ -4943,35 +4969,60 @@ async function loadBlogLLMStatus() {
queueEl.textContent = q > 0 ? 'Queue: ' + q + ' Jobs' : 'Queue: idle';
}
// Mark active model card with accent border
var foActive = document.getElementById('blog-model-fo-active');
var foCard = document.getElementById('blog-model-card-fo');
var claudeCard = document.getElementById('blog-model-card-claude');
// Reset all card borders + active badges
['cc','claude','fo'].forEach(function(k) {
var card = document.getElementById('blog-model-card-' + k);
if (card) card.style.border = '2px solid var(--border)';
var badge2 = document.getElementById('blog-model-' + k + '-active');
if (badge2) badge2.style.display = 'none';
});
if (llm.provider === 'anthropic') {
if (llm.provider === 'claude-code') {
var ccCard = document.getElementById('blog-model-card-cc');
if (ccCard) ccCard.style.border = '2px solid var(--accent)';
var ccActive = document.getElementById('blog-model-cc-active');
if (ccActive) ccActive.style.display = 'inline';
var ccSt = document.getElementById('blog-model-cc-status');
if (ccSt) {
ccSt.textContent = llm.ok ? '● Aktiv — claude-bridge erreichbar' : '⚠ claude-bridge nicht erreichbar: ' + (llm.error || '').slice(0, 60);
ccSt.style.color = llm.ok ? '#1a7a3a' : '#b45309';
ccSt.style.fontWeight = '600';
}
var clSt2 = document.getElementById('blog-model-claude-status');
if (clSt2) { clSt2.textContent = 'bereit (nicht aktiv)'; clSt2.style.color = 'var(--text-dim)'; clSt2.style.fontWeight = '400'; }
var foSt2 = document.getElementById('blog-model-fo-status');
if (foSt2) { foSt2.textContent = 'bereit (nicht aktiv)'; foSt2.style.color = 'var(--text-dim)'; foSt2.style.fontWeight = '400'; }
} else if (llm.provider === 'anthropic') {
var claudeCard = document.getElementById('blog-model-card-claude');
if (claudeCard) claudeCard.style.border = '2px solid var(--accent)';
var claudeActive = document.getElementById('blog-model-claude-active');
if (claudeActive) claudeActive.style.display = 'inline';
var claudeStatusEl = document.getElementById('blog-model-claude-status');
if (claudeStatusEl) {
claudeStatusEl.textContent = '● Aktiv — API-Key konfiguriert';
claudeStatusEl.style.color = '#1a7a3a';
claudeStatusEl.style.fontWeight = '600';
}
var foSt = document.getElementById('blog-model-fo-status');
if (foSt) { foSt.textContent = 'bereit (nicht aktiv)'; foSt.style.color = 'var(--text-dim)'; }
var ccSt2 = document.getElementById('blog-model-cc-status');
if (ccSt2) { ccSt2.textContent = 'bereit (nicht aktiv)'; ccSt2.style.color = 'var(--text-dim)'; ccSt2.style.fontWeight = '400'; }
var foSt3 = document.getElementById('blog-model-fo-status');
if (foSt3) { foSt3.textContent = 'bereit (nicht aktiv)'; foSt3.style.color = 'var(--text-dim)'; foSt3.style.fontWeight = '400'; }
} else {
if (foActive) foActive.style.display = 'inline';
// ollama
var foCard = document.getElementById('blog-model-card-fo');
if (foCard) foCard.style.border = '2px solid var(--accent)';
var foActive = document.getElementById('blog-model-fo-active');
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.style.color = llm.ok ? '#1a7a3a' : '#b45309';
foStatusEl.style.fontWeight = '600';
}
var clSt = document.getElementById('blog-model-claude-status');
if (clSt) {
clSt.textContent = 'bereit — BLOG_LLM_PROVIDER=anthropic + ANTHROPIC_API_KEY setzen';
clSt.style.color = 'var(--text-dim)';
}
var ccSt3 = document.getElementById('blog-model-cc-status');
if (ccSt3) { ccSt3.textContent = 'bereit — BLOG_LLM_PROVIDER=claude-code setzen'; ccSt3.style.color = 'var(--text-dim)'; ccSt3.style.fontWeight = '400'; }
var clSt3 = document.getElementById('blog-model-claude-status');
if (clSt3) { clSt3.textContent = 'bereit — BLOG_LLM_PROVIDER=anthropic + API-Key setzen'; clSt3.style.color = 'var(--text-dim)'; clSt3.style.fontWeight = '400'; }
}
} catch(e) {
var b = document.getElementById('blog-llm-status-badge');