mirror of
https://github.com/Memo-2023/mana-monorepo.git
synced 2026-05-14 19:41:09 +02:00
feat(guides): Phase 3 — Hono/Bun server for web import and guide sharing
- 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 <noreply@anthropic.com>
This commit is contained in:
parent
7f1c83f8b6
commit
e624756d66
5 changed files with 306 additions and 0 deletions
20
apps/guides/apps/server/package.json
Normal file
20
apps/guides/apps/server/package.json
Normal file
|
|
@ -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"
|
||||||
|
}
|
||||||
|
}
|
||||||
49
apps/guides/apps/server/src/index.ts
Normal file
49
apps/guides/apps/server/src/index.ts
Normal file
|
|
@ -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 };
|
||||||
171
apps/guides/apps/server/src/routes/import.ts
Normal file
171
apps/guides/apps/server/src/routes/import.ts
Normal file
|
|
@ -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<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);
|
||||||
|
}
|
||||||
|
}
|
||||||
50
apps/guides/apps/server/src/routes/share.ts
Normal file
50
apps/guides/apps/server/src/routes/share.ts
Normal file
|
|
@ -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<string, { guide: unknown; sections: unknown[]; createdAt: string; expiresAt: string }>();
|
||||||
|
|
||||||
|
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);
|
||||||
|
});
|
||||||
16
apps/guides/apps/server/tsconfig.json
Normal file
16
apps/guides/apps/server/tsconfig.json
Normal file
|
|
@ -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"]
|
||||||
|
}
|
||||||
Loading…
Add table
Add a link
Reference in a new issue