From e624756d66dcbd07dffc0bb4ea55bc028d9fc4c4 Mon Sep 17 00:00:00 2001 From: Till JS Date: Tue, 31 Mar 2026 21:29:59 +0200 Subject: [PATCH] =?UTF-8?q?feat(guides):=20Phase=203=20=E2=80=94=20Hono/Bu?= =?UTF-8?q?n=20server=20for=20web=20import=20and=20guide=20sharing?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - POST /api/v1/import/url — extract URL via mana-search, generate guide with mana-llm - POST /api/v1/import/text — convert raw text/markdown to structured guide - POST /api/v1/import/ai — generate guide from AI prompt - POST /api/v1/share + GET /api/v1/share/:token — shareable guide links (7-day TTL, in-memory MVP) - Uses Claude Haiku via mana-llm for structured JSON guide generation Co-Authored-By: Claude Sonnet 4.6 --- apps/guides/apps/server/package.json | 20 +++ apps/guides/apps/server/src/index.ts | 49 ++++++ apps/guides/apps/server/src/routes/import.ts | 171 +++++++++++++++++++ apps/guides/apps/server/src/routes/share.ts | 50 ++++++ apps/guides/apps/server/tsconfig.json | 16 ++ 5 files changed, 306 insertions(+) create mode 100644 apps/guides/apps/server/package.json create mode 100644 apps/guides/apps/server/src/index.ts create mode 100644 apps/guides/apps/server/src/routes/import.ts create mode 100644 apps/guides/apps/server/src/routes/share.ts create mode 100644 apps/guides/apps/server/tsconfig.json diff --git a/apps/guides/apps/server/package.json b/apps/guides/apps/server/package.json new file mode 100644 index 000000000..1f2ef0327 --- /dev/null +++ b/apps/guides/apps/server/package.json @@ -0,0 +1,20 @@ +{ + "name": "@guides/server", + "version": "0.1.0", + "private": true, + "description": "Guides server (Hono + Bun) — web import, guide sharing, AI generation", + "type": "module", + "scripts": { + "dev": "bun run --watch src/index.ts", + "start": "bun run src/index.ts", + "type-check": "bun x tsc --noEmit" + }, + "dependencies": { + "@manacore/shared-hono": "workspace:*", + "hono": "^4.7.0" + }, + "devDependencies": { + "@types/bun": "^1.2.0", + "typescript": "^5.9.3" + } +} diff --git a/apps/guides/apps/server/src/index.ts b/apps/guides/apps/server/src/index.ts new file mode 100644 index 000000000..c5235ecb3 --- /dev/null +++ b/apps/guides/apps/server/src/index.ts @@ -0,0 +1,49 @@ +/** + * Guides Server — Hono + Bun + * + * Compute-only server for features that need server-side logic: + * - Web import: URL → structured guide via mana-search + * - AI generation: text/paste → guide via mana-llm + * - Guide sharing: public guide links + * + * All CRUD is handled client-side via local-first + mana-sync. + */ + +import { Hono } from 'hono'; +import { cors } from 'hono/cors'; +import { logger } from 'hono/logger'; +import { importRoutes } from './routes/import.js'; +import { shareRoutes } from './routes/share.js'; + +const app = new Hono(); + +// Middleware +app.use('*', logger()); +app.use( + '*', + cors({ + origin: (process.env.CORS_ORIGINS ?? 'http://localhost:5200').split(','), + allowMethods: ['GET', 'POST', 'OPTIONS'], + allowHeaders: ['Authorization', 'Content-Type'], + credentials: true, + }) +); + +// Routes +app.route('/api/v1/import', importRoutes); +app.route('/api/v1/share', shareRoutes); + +// Health check +app.get('/health', (c) => + c.json({ + status: 'ok', + service: 'guides-server', + runtime: 'bun', + timestamp: new Date().toISOString(), + }) +); + +const port = Number(process.env.PORT ?? 3025); +console.log(`🚀 Guides server (Hono + Bun) starting on port ${port}`); + +export default { port, fetch: app.fetch }; diff --git a/apps/guides/apps/server/src/routes/import.ts b/apps/guides/apps/server/src/routes/import.ts new file mode 100644 index 000000000..91a8facbb --- /dev/null +++ b/apps/guides/apps/server/src/routes/import.ts @@ -0,0 +1,171 @@ +/** + * Import routes — convert URLs or raw text into structured guide data. + * + * POST /api/v1/import/url → fetch URL via mana-search extract, return guide draft + * POST /api/v1/import/text → parse plain text / markdown into guide steps + * POST /api/v1/import/ai → send to mana-llm to generate structured guide + */ + +import { Hono } from 'hono'; + +export const importRoutes = new 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'; + +// ─── URL Import ───────────────────────────────────────────────────────────── + +importRoutes.post('/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); + } + + // Use mana-llm to turn raw content into structured guide + return await generateGuideFromText(c, { + title: extracted.title, + text: content, + sourceUrl: url, + }); +}); + +// ─── Text/Markdown Import ──────────────────────────────────────────────────── + +importRoutes.post('/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 }); +}); + +// ─── AI Generation ─────────────────────────────────────────────────────────── + +importRoutes.post('/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, + }); +}); + +// ─── Shared: LLM guide generation ─────────────────────────────────────────── + +async function generateGuideFromText( + c: Parameters[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); + } +} diff --git a/apps/guides/apps/server/src/routes/share.ts b/apps/guides/apps/server/src/routes/share.ts new file mode 100644 index 000000000..69fedcd32 --- /dev/null +++ b/apps/guides/apps/server/src/routes/share.ts @@ -0,0 +1,50 @@ +/** + * Share routes — public guide links (Phase 3, in-memory store for MVP) + * + * POST /api/v1/share → create shareable link for a guide snapshot + * GET /api/v1/share/:token → retrieve shared guide by token + */ + +import { Hono } from 'hono'; + +export const shareRoutes = new Hono(); + +// In-memory store for shared guides (replace with DB in Phase 4) +const sharedGuides = new Map(); + +shareRoutes.post('/', 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 }); +}); + +shareRoutes.get('/: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); +}); diff --git a/apps/guides/apps/server/tsconfig.json b/apps/guides/apps/server/tsconfig.json new file mode 100644 index 000000000..4f2959bb9 --- /dev/null +++ b/apps/guides/apps/server/tsconfig.json @@ -0,0 +1,16 @@ +{ + "compilerOptions": { + "target": "ES2022", + "module": "ESNext", + "moduleResolution": "bundler", + "lib": ["ES2022"], + "types": ["bun-types"], + "strict": true, + "esModuleInterop": true, + "skipLibCheck": true, + "forceConsistentCasingInFileNames": true, + "noEmit": true + }, + "include": ["src/**/*"], + "exclude": ["node_modules"] +}