feat(api): port remaining 12 modules to unified API server

Complete consolidation of all 15 app servers into one Hono/Bun process.

Modules added: chat, context, picture, storage, todo, planta, nutriphi,
guides, moodlit, news, traces, presi

Total: 15 modules, one server, one port (3050), ~2400 LOC.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
Till JS 2026-04-02 21:34:08 +02:00
parent eb97378438
commit 9363063cd7
14 changed files with 2014 additions and 0 deletions

View file

@ -0,0 +1,217 @@
/**
* Guides module Content import (URL/text/AI) + shareable links
* Ported from apps/guides/apps/server
*
* All CRUD is handled client-side via local-first + mana-sync.
* This module handles web import via mana-search + mana-llm, and share links.
*/
import { Hono } from 'hono';
const MANA_SEARCH_URL = process.env.MANA_SEARCH_URL ?? 'http://localhost:3021';
const MANA_LLM_URL = process.env.MANA_LLM_URL ?? 'http://localhost:3030';
const routes = new Hono();
// ─── Import: URL ────────────────────────────────────────────
routes.post('/import/url', async (c) => {
const body = await c.req.json<{ url: string }>();
const { url } = body;
if (!url || !URL.canParse(url)) {
return c.json({ error: 'Ungültige URL' }, 400);
}
// Extract content via mana-search
let extracted: { title?: string; content?: string; markdown?: string } = {};
try {
const res = await fetch(`${MANA_SEARCH_URL}/api/v1/extract`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ url, options: { includeMarkdown: true } }),
});
if (res.ok) {
extracted = await res.json();
}
} catch (e) {
console.error('mana-search extract failed:', e);
}
const content = extracted.markdown ?? extracted.content ?? '';
if (!content) {
return c.json({ error: 'Inhalt konnte nicht extrahiert werden' }, 422);
}
return await generateGuideFromText(c, {
title: extracted.title,
text: content,
sourceUrl: url,
});
});
// ─── Import: Text/Markdown ──────────────────────────────────
routes.post('/import/text', async (c) => {
const body = await c.req.json<{ text: string; title?: string }>();
const { text, title } = body;
if (!text?.trim()) {
return c.json({ error: 'Kein Text angegeben' }, 400);
}
return await generateGuideFromText(c, { text, title });
});
// ─── Import: AI Generation ──────────────────────────────────
routes.post('/import/ai', async (c) => {
const body = await c.req.json<{ prompt: string; title?: string }>();
const { prompt, title } = body;
if (!prompt?.trim()) {
return c.json({ error: 'Kein Prompt angegeben' }, 400);
}
return await generateGuideFromText(c, {
text: prompt,
title,
isAiPrompt: true,
});
});
// ─── Share: Create + Retrieve ───────────────────────────────
// In-memory store for shared guides (replace with DB later)
const sharedGuides = new Map<
string,
{ guide: unknown; sections: unknown[]; createdAt: string; expiresAt: string }
>();
routes.post('/share', async (c) => {
const body = await c.req.json<{ guide: unknown; sections: unknown[] }>();
if (!body.guide) {
return c.json({ error: 'Kein Guide-Inhalt angegeben' }, 400);
}
const token = crypto.randomUUID().replace(/-/g, '').slice(0, 12);
const expiresAt = new Date(Date.now() + 7 * 24 * 60 * 60 * 1000).toISOString(); // 7 days
sharedGuides.set(token, {
guide: body.guide,
sections: body.sections ?? [],
createdAt: new Date().toISOString(),
expiresAt,
});
const baseUrl = process.env.PUBLIC_BASE_URL ?? 'http://localhost:5200';
return c.json({ token, url: `${baseUrl}/shared/${token}`, expiresAt });
});
routes.get('/share/:token', (c) => {
const { token } = c.req.param();
const shared = sharedGuides.get(token);
if (!shared) {
return c.json({ error: 'Guide nicht gefunden oder Link abgelaufen' }, 404);
}
if (new Date(shared.expiresAt) < new Date()) {
sharedGuides.delete(token);
return c.json({ error: 'Dieser Link ist abgelaufen' }, 410);
}
return c.json(shared);
});
// ─── Shared: LLM guide generation ──────────────────────────
async function generateGuideFromText(
c: Parameters<Parameters<typeof Hono.prototype.post>[1]>[0],
opts: { text: string; title?: string; sourceUrl?: string; isAiPrompt?: boolean }
) {
const systemPrompt = `Du bist ein Experte für das Erstellen strukturierter Schritt-für-Schritt-Anleitungen.
Analysiere den folgenden Text und erstelle daraus eine strukturierte Anleitung im JSON-Format.
Antworte NUR mit einem validen JSON-Objekt in diesem exakten Format:
{
"title": "Titel der Anleitung",
"description": "Kurze Beschreibung (1-2 Sätze)",
"category": "Technik|Kochen|Sport|Lernen|Arbeit|Haushalt|Hobby|Allgemein",
"difficulty": "easy|medium|hard",
"estimatedMinutes": Zahl,
"tags": ["tag1", "tag2"],
"sections": [
{
"title": "Abschnitt-Titel (optional, leer lassen wenn keine Sections nötig)",
"steps": [
{
"title": "Schritt-Titel",
"content": "Optionale Details oder Code",
"type": "instruction|warning|tip|checkpoint|code"
}
]
}
]
}
Regeln:
- Maximal 3-4 Abschnitte, maximal 8-10 Schritte pro Abschnitt
- type "warning" nur bei wirklichen Warnungen/Gefahren
- type "tip" für hilfreiche Hinweise
- type "code" wenn der Inhalt Kommandos oder Code enthält
- type "checkpoint" für Überprüfungsschritte
- Wenn kein sinnvolles Abschnitt-System, eine leere Section mit title ""
- Schritt-Titel: prägnant, maximal 80 Zeichen
- Auf Deutsch antworten`;
const userMessage = opts.isAiPrompt
? `Erstelle eine Anleitung für: ${opts.text}`
: `Hier ist der Inhalt, den du in eine Anleitung umwandeln sollst:\n\n${opts.text.slice(0, 8000)}`;
try {
const llmRes = await fetch(`${MANA_LLM_URL}/api/v1/chat`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
messages: [
{ role: 'system', content: systemPrompt },
{ role: 'user', content: userMessage },
],
model: 'claude-haiku-4-5-20251001',
temperature: 0.3,
maxTokens: 4096,
}),
});
if (!llmRes.ok) {
throw new Error(`LLM error: ${llmRes.status}`);
}
const llmData = await llmRes.json<{ content: string }>();
const rawJson = llmData.content.trim();
// Extract JSON from potential markdown code fences
const jsonMatch = rawJson.match(/```(?:json)?\s*([\s\S]*?)\s*```/) ?? [null, rawJson];
const parsed = JSON.parse(jsonMatch[1] ?? rawJson);
return c.json({
guide: {
title: opts.title ?? parsed.title,
description: parsed.description,
category: parsed.category ?? 'Allgemein',
difficulty: parsed.difficulty ?? 'medium',
estimatedMinutes: parsed.estimatedMinutes,
tags: parsed.tags ?? [],
sourceUrl: opts.sourceUrl,
},
sections: parsed.sections ?? [],
});
} catch (e) {
console.error('Guide generation failed:', e);
return c.json({ error: 'Guide-Generierung fehlgeschlagen', details: String(e) }, 500);
}
}
export { routes as guidesRoutes };