From ba6dbf16c49e0510ba81149e28cfbbb7439b84ab Mon Sep 17 00:00:00 2001 From: Till JS Date: Sat, 28 Mar 2026 16:23:00 +0100 Subject: [PATCH] feat(apps): create Hono compute servers for Context, ManaDeck, Questions Context (Port 3020): AI text generation with document context ManaDeck (Port 3009): AI deck/card generation + image-to-cards Questions (Port 3011): Web research via mana-search (3 depth levels) All use @manacore/shared-hono for auth and credits. ~100-140 LOC each. Co-Authored-By: Claude Opus 4.6 (1M context) --- apps/context/apps/server/package.json | 19 ++++ apps/context/apps/server/src/index.ts | 94 ++++++++++++++++ apps/context/apps/server/tsconfig.json | 11 ++ apps/manadeck/apps/server/package.json | 17 +++ apps/manadeck/apps/server/src/index.ts | 130 +++++++++++++++++++++++ apps/manadeck/apps/server/tsconfig.json | 11 ++ apps/questions/apps/server/package.json | 17 +++ apps/questions/apps/server/src/index.ts | 121 +++++++++++++++++++++ apps/questions/apps/server/tsconfig.json | 11 ++ 9 files changed, 431 insertions(+) create mode 100644 apps/context/apps/server/package.json create mode 100644 apps/context/apps/server/src/index.ts create mode 100644 apps/context/apps/server/tsconfig.json create mode 100644 apps/manadeck/apps/server/package.json create mode 100644 apps/manadeck/apps/server/src/index.ts create mode 100644 apps/manadeck/apps/server/tsconfig.json create mode 100644 apps/questions/apps/server/package.json create mode 100644 apps/questions/apps/server/src/index.ts create mode 100644 apps/questions/apps/server/tsconfig.json diff --git a/apps/context/apps/server/package.json b/apps/context/apps/server/package.json new file mode 100644 index 000000000..0dab53d83 --- /dev/null +++ b/apps/context/apps/server/package.json @@ -0,0 +1,19 @@ +{ + "name": "@context/server", + "version": "0.1.0", + "private": true, + "type": "module", + "scripts": { + "dev": "bun run --watch src/index.ts", + "start": "bun run src/index.ts" + }, + "dependencies": { + "@manacore/shared-hono": "workspace:*", + "hono": "^4.7.0", + "drizzle-orm": "^0.38.3", + "postgres": "^3.4.5" + }, + "devDependencies": { + "typescript": "^5.9.3" + } +} diff --git a/apps/context/apps/server/src/index.ts b/apps/context/apps/server/src/index.ts new file mode 100644 index 000000000..5d53ca195 --- /dev/null +++ b/apps/context/apps/server/src/index.ts @@ -0,0 +1,94 @@ +/** + * Context Hono Server — AI text generation + token management + * + * CRUD for spaces/documents handled by mana-sync. + */ + +import { Hono } from 'hono'; +import { cors } from 'hono/cors'; +import { authMiddleware, healthRoute, errorHandler, notFoundHandler } from '@manacore/shared-hono'; +import { consumeCredits, validateCredits } from '@manacore/shared-hono/credits'; + +const PORT = parseInt(process.env.PORT || '3020', 10); +const LLM_URL = process.env.MANA_LLM_URL || 'http://localhost:3025'; +const CORS_ORIGINS = (process.env.CORS_ORIGINS || 'http://localhost:5192').split(','); + +const app = new Hono(); + +app.onError(errorHandler); +app.notFound(notFoundHandler); +app.use('*', cors({ origin: CORS_ORIGINS, credentials: true })); +app.route('/health', healthRoute('context-server')); +app.use('/api/*', authMiddleware()); + +// ─── AI Generation (server-only: mana-llm) ────────────────── + +app.post('/api/v1/ai/generate', async (c) => { + const userId = c.get('userId'); + const { prompt, documents, model, maxTokens } = await c.req.json(); + + if (!prompt) return c.json({ error: 'prompt required' }, 400); + + // Validate credits + const validation = await validateCredits(userId, 'AI_CONTEXT_GENERATE', 5); + if (!validation.hasCredits) { + return c.json( + { error: 'Insufficient credits', required: 5, available: validation.availableCredits }, + 402 + ); + } + + try { + // Build messages with document context + const messages: Array<{ role: string; content: string }> = []; + + if (documents?.length) { + const contextText = documents + .map((d: { title: string; content: string }) => `--- ${d.title} ---\n${d.content}`) + .join('\n\n'); + messages.push({ + role: 'system', + content: `Verwende diese Dokumente als Kontext:\n\n${contextText}`, + }); + } + + messages.push({ role: 'user', content: prompt }); + + const res = await fetch(`${LLM_URL}/api/v1/chat/completions`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ + messages, + model: model || 'gemma3:4b', + max_tokens: maxTokens || 2000, + }), + }); + + if (!res.ok) return c.json({ error: 'AI generation failed' }, 502); + + const data = await res.json(); + const content = data.choices?.[0]?.message?.content || ''; + const tokensUsed = data.usage?.total_tokens || 0; + + // Consume credits + await consumeCredits(userId, 'AI_CONTEXT_GENERATE', 5, `AI generation (${tokensUsed} tokens)`); + + return c.json({ content, tokensUsed, model: model || 'gemma3:4b' }); + } catch (_err) { + return c.json({ error: 'Generation failed' }, 500); + } +}); + +app.post('/api/v1/ai/estimate', async (c) => { + const { prompt, documents } = await c.req.json(); + const charCount = + (prompt?.length || 0) + + (documents || []).reduce( + (sum: number, d: { content: string }) => sum + (d.content?.length || 0), + 0 + ); + const estimatedTokens = Math.ceil(charCount / 4); + return c.json({ estimatedTokens, estimatedCost: 5 }); +}); + +export default { port: PORT, fetch: app.fetch }; diff --git a/apps/context/apps/server/tsconfig.json b/apps/context/apps/server/tsconfig.json new file mode 100644 index 000000000..9c7e5fa56 --- /dev/null +++ b/apps/context/apps/server/tsconfig.json @@ -0,0 +1,11 @@ +{ + "compilerOptions": { + "target": "ESNext", + "module": "ESNext", + "moduleResolution": "bundler", + "strict": true, + "esModuleInterop": true, + "skipLibCheck": true + }, + "include": ["src/**/*.ts"] +} diff --git a/apps/manadeck/apps/server/package.json b/apps/manadeck/apps/server/package.json new file mode 100644 index 000000000..16d297249 --- /dev/null +++ b/apps/manadeck/apps/server/package.json @@ -0,0 +1,17 @@ +{ + "name": "@manadeck/server", + "version": "0.1.0", + "private": true, + "type": "module", + "scripts": { + "dev": "bun run --watch src/index.ts", + "start": "bun run src/index.ts" + }, + "dependencies": { + "@manacore/shared-hono": "workspace:*", + "hono": "^4.7.0" + }, + "devDependencies": { + "typescript": "^5.9.3" + } +} diff --git a/apps/manadeck/apps/server/src/index.ts b/apps/manadeck/apps/server/src/index.ts new file mode 100644 index 000000000..d22586870 --- /dev/null +++ b/apps/manadeck/apps/server/src/index.ts @@ -0,0 +1,130 @@ +/** + * ManaDeck Hono Server — AI card/deck generation + * + * CRUD for decks/cards handled by mana-sync. + */ + +import { Hono } from 'hono'; +import { cors } from 'hono/cors'; +import { authMiddleware, healthRoute, errorHandler, notFoundHandler } from '@manacore/shared-hono'; +import { consumeCredits, validateCredits } from '@manacore/shared-hono/credits'; + +const PORT = parseInt(process.env.PORT || '3009', 10); +const LLM_URL = process.env.MANA_LLM_URL || 'http://localhost:3025'; +const CORS_ORIGINS = (process.env.CORS_ORIGINS || 'http://localhost:5173').split(','); + +const app = new Hono(); + +app.onError(errorHandler); +app.notFound(notFoundHandler); +app.use('*', cors({ origin: CORS_ORIGINS, credentials: true })); +app.route('/health', healthRoute('manadeck-server')); +app.use('/api/*', authMiddleware()); + +// ─── AI Deck Generation (server-only: mana-llm + credits) ─── + +app.post('/api/v1/decks/generate', async (c) => { + const userId = c.get('userId'); + const { topic, cardCount, language } = await c.req.json(); + + if (!topic) return c.json({ error: 'topic required' }, 400); + const count = Math.min(cardCount || 10, 50); + + const cost = 20; // Credits per deck generation + const validation = await validateCredits(userId, 'AI_DECK_GENERATION', cost); + if (!validation.hasCredits) { + return c.json( + { error: 'Insufficient credits', required: cost, available: validation.availableCredits }, + 402 + ); + } + + try { + const res = await fetch(`${LLM_URL}/api/v1/chat/completions`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ + messages: [ + { + role: 'system', + content: `Erstelle genau ${count} Karteikarten zum Thema. Gib JSON zurück: {"cards": [{"front": "Frage", "back": "Antwort"}]}. Sprache: ${language || 'Deutsch'}.`, + }, + { role: 'user', content: topic }, + ], + model: 'gemma3:4b', + response_format: { type: 'json_object' }, + }), + }); + + if (!res.ok) return c.json({ error: 'AI generation failed' }, 502); + + const data = await res.json(); + const content = data.choices?.[0]?.message?.content; + const parsed = typeof content === 'string' ? JSON.parse(content) : content; + + await consumeCredits(userId, 'AI_DECK_GENERATION', cost, `Deck: ${topic} (${count} cards)`); + + return c.json({ cards: parsed.cards || [], topic, cardCount: count }); + } catch (_err) { + return c.json({ error: 'Generation failed' }, 500); + } +}); + +// ─── AI Card Generation from Image (server-only: vision) ──── + +app.post('/api/v1/ai/generate-from-image', async (c) => { + const userId = c.get('userId'); + const { imageUrl, imageBase64, mimeType } = await c.req.json(); + + const cost = 2; + const validation = await validateCredits(userId, 'AI_CARD_GENERATION', cost); + if (!validation.hasCredits) { + return c.json({ error: 'Insufficient credits' }, 402); + } + + try { + const imageContent = imageUrl + ? { type: 'image_url', image_url: { url: imageUrl } } + : { + type: 'image_url', + image_url: { url: `data:${mimeType || 'image/jpeg'};base64,${imageBase64}` }, + }; + + const res = await fetch(`${LLM_URL}/api/v1/chat/completions`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ + messages: [ + { + role: 'system', + content: + 'Erstelle Karteikarten aus dem Bildinhalt. JSON: {"cards": [{"front": "...", "back": "..."}]}', + }, + { + role: 'user', + content: [ + { type: 'text', text: 'Erstelle Karteikarten aus diesem Bild.' }, + imageContent, + ], + }, + ], + model: 'gemini-2.0-flash', + response_format: { type: 'json_object' }, + }), + }); + + if (!res.ok) return c.json({ error: 'AI failed' }, 502); + + const data = await res.json(); + const content = data.choices?.[0]?.message?.content; + const parsed = typeof content === 'string' ? JSON.parse(content) : content; + + await consumeCredits(userId, 'AI_CARD_GENERATION', cost, 'Cards from image'); + + return c.json({ cards: parsed.cards || [] }); + } catch (_err) { + return c.json({ error: 'Generation failed' }, 500); + } +}); + +export default { port: PORT, fetch: app.fetch }; diff --git a/apps/manadeck/apps/server/tsconfig.json b/apps/manadeck/apps/server/tsconfig.json new file mode 100644 index 000000000..9c7e5fa56 --- /dev/null +++ b/apps/manadeck/apps/server/tsconfig.json @@ -0,0 +1,11 @@ +{ + "compilerOptions": { + "target": "ESNext", + "module": "ESNext", + "moduleResolution": "bundler", + "strict": true, + "esModuleInterop": true, + "skipLibCheck": true + }, + "include": ["src/**/*.ts"] +} diff --git a/apps/questions/apps/server/package.json b/apps/questions/apps/server/package.json new file mode 100644 index 000000000..a51a15a38 --- /dev/null +++ b/apps/questions/apps/server/package.json @@ -0,0 +1,17 @@ +{ + "name": "@questions/server", + "version": "0.1.0", + "private": true, + "type": "module", + "scripts": { + "dev": "bun run --watch src/index.ts", + "start": "bun run src/index.ts" + }, + "dependencies": { + "@manacore/shared-hono": "workspace:*", + "hono": "^4.7.0" + }, + "devDependencies": { + "typescript": "^5.9.3" + } +} diff --git a/apps/questions/apps/server/src/index.ts b/apps/questions/apps/server/src/index.ts new file mode 100644 index 000000000..7f31ef2c7 --- /dev/null +++ b/apps/questions/apps/server/src/index.ts @@ -0,0 +1,121 @@ +/** + * Questions Hono Server — Research via mana-search + * + * CRUD for questions/collections/answers handled by mana-sync. + */ + +import { Hono } from 'hono'; +import { cors } from 'hono/cors'; +import { authMiddleware, healthRoute, errorHandler, notFoundHandler } from '@manacore/shared-hono'; +import { consumeCredits, validateCredits } from '@manacore/shared-hono/credits'; + +const PORT = parseInt(process.env.PORT || '3011', 10); +const SEARCH_URL = process.env.MANA_SEARCH_URL || 'http://localhost:3021'; +const CORS_ORIGINS = (process.env.CORS_ORIGINS || 'http://localhost:5111').split(','); + +const DEPTH_CONFIG = { + quick: { limit: 5, extract: false, categories: ['general'] }, + standard: { limit: 15, extract: true, categories: ['general', 'news'] }, + deep: { limit: 30, extract: true, categories: ['general', 'news', 'science', 'it'] }, +} as const; + +const app = new Hono(); + +app.onError(errorHandler); +app.notFound(notFoundHandler); +app.use('*', cors({ origin: CORS_ORIGINS, credentials: true })); +app.route('/health', healthRoute('questions-server')); +app.use('/api/*', authMiddleware()); + +// ─── Research (server-only: mana-search) ───────────────────── + +app.post('/api/v1/research/start', async (c) => { + const userId = c.get('userId'); + const { questionId, query, depth } = await c.req.json(); + + if (!query) return c.json({ error: 'query required' }, 400); + + const config = DEPTH_CONFIG[depth as keyof typeof DEPTH_CONFIG] || DEPTH_CONFIG.standard; + const cost = depth === 'deep' ? 25 : depth === 'quick' ? 5 : 10; + + const validation = await validateCredits(userId, 'RESEARCH', cost); + if (!validation.hasCredits) { + return c.json({ error: 'Insufficient credits', required: cost }, 402); + } + + try { + // 1. Search via mana-search + const searchRes = await fetch(`${SEARCH_URL}/api/v1/search`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ + query, + options: { categories: config.categories, limit: config.limit }, + }), + }); + + if (!searchRes.ok) return c.json({ error: 'Search failed' }, 502); + const searchData = await searchRes.json(); + const results = searchData.results || []; + + // 2. Extract content if standard/deep + let sources = results.map((r: { url: string; title: string; content?: string }) => ({ + url: r.url, + title: r.title, + snippet: r.content?.slice(0, 300), + })); + + if (config.extract && results.length > 0) { + const urls = results + .slice(0, 5) + .map((r: { url: string; title: string; content?: string }) => r.url) + .filter(Boolean); + if (urls.length > 0) { + const extractRes = await fetch(`${SEARCH_URL}/api/v1/extract/bulk`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ urls }), + }); + if (extractRes.ok) { + const extracted = await extractRes.json(); + sources = sources.map((s: { url: string }) => { + const ext = extracted.results?.find( + (e: { url: string; content?: string }) => e.url === s.url + ); + return ext ? { ...s, extractedContent: ext.content?.slice(0, 2000) } : s; + }); + } + } + } + + // 3. Build summary + const summary = `Gefunden: ${results.length} Quellen für "${query}"`; + const keyPoints = results + .slice(0, 3) + .map((r: { url: string; title: string; content?: string }) => r.title); + + await consumeCredits(userId, 'RESEARCH', cost, `Research: ${query} (${depth})`); + + return c.json({ + questionId, + summary, + keyPoints, + sources, + depth, + sourceCount: results.length, + }); + } catch (_err) { + return c.json({ error: 'Research failed' }, 500); + } +}); + +app.get('/api/v1/research/health/search', async (c) => { + try { + const res = await fetch(`${SEARCH_URL}/health`); + return c.json({ searchService: res.ok ? 'healthy' : 'unhealthy' }); + } catch { + return c.json({ searchService: 'unreachable' }); + } +}); + +export default { port: PORT, fetch: app.fetch }; diff --git a/apps/questions/apps/server/tsconfig.json b/apps/questions/apps/server/tsconfig.json new file mode 100644 index 000000000..9c7e5fa56 --- /dev/null +++ b/apps/questions/apps/server/tsconfig.json @@ -0,0 +1,11 @@ +{ + "compilerOptions": { + "target": "ESNext", + "module": "ESNext", + "moduleResolution": "bundler", + "strict": true, + "esModuleInterop": true, + "skipLibCheck": true + }, + "include": ["src/**/*.ts"] +}