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