const { asyncHandler, AppError } = require('../middleware/errorHandler'); const AiService = require('../services/ai.service'); const { getDatabase } = require('../config/database'); const path = require('path'); const fs = require('fs'); class AiController { /** * GET /api/ai/status * Prüft ob AI konfiguriert ist */ static getStatus = asyncHandler(async (req, res) => { res.json({ status: 'success', data: { configured: AiService.isConfigured() } }); }); /** * POST /api/ai/chat * Body: { messages: [{role, content}] } */ static chat = asyncHandler(async (req, res) => { if (!AiService.isConfigured()) { throw new AppError('KI-Integration ist nicht konfiguriert (ANTHROPIC_API_KEY fehlt)', 503); } const { messages, userMode } = req.body; if (!messages || !Array.isArray(messages) || messages.length === 0) { throw new AppError('messages Array ist erforderlich', 400); } // Nur erlaubte Rollen: user und assistant const cleaned = messages .filter(m => ['user', 'assistant'].includes(m.role) && typeof m.content === 'string') .map(m => ({ role: m.role, content: m.content.slice(0, 4000) })); if (cleaned.length === 0) { throw new AppError('Keine gültigen Nachrichten', 400); } const reply = await AiService.chat(cleaned, !!userMode); res.json({ status: 'success', data: { reply } }); }); /** * GET /api/ai/knowledge-base */ static getKnowledgeBase = asyncHandler(async (req, res) => { const db = getDatabase(); const entries = db.prepare(` SELECT kb.*, u.username as created_by_username FROM knowledge_base kb LEFT JOIN users u ON kb.created_by_user_id = u.id ORDER BY kb.created_at DESC `).all(); res.json({ status: 'success', data: entries }); }); /** * POST /api/ai/knowledge-base * Body: { problem, solution, category, tags } */ static addKnowledgeEntry = asyncHandler(async (req, res) => { const { problem, solution, category, tags } = req.body; if (!problem?.trim() || !solution?.trim()) { throw new AppError('problem und solution sind erforderlich', 400); } const db = getDatabase(); const result = db.prepare(` INSERT INTO knowledge_base (problem, solution, category, tags, created_by_user_id) VALUES (?, ?, ?, ?, ?) `).run( problem.trim(), solution.trim(), category || 'Allgemein', tags?.trim() || null, req.user.id ); const entry = db.prepare('SELECT * FROM knowledge_base WHERE id = ?').get(result.lastInsertRowid); res.status(201).json({ status: 'success', data: entry }); }); /** * PUT /api/ai/knowledge-base/:id * Body: { problem, solution, category, tags } */ static updateKnowledgeEntry = asyncHandler(async (req, res) => { const db = getDatabase(); const entry = db.prepare('SELECT id FROM knowledge_base WHERE id = ?').get(parseInt(req.params.id)); if (!entry) throw new AppError('Eintrag nicht gefunden', 404); const { problem, solution, category, tags } = req.body; if (!problem?.trim() || !solution?.trim()) { throw new AppError('problem und solution sind erforderlich', 400); } db.prepare(` UPDATE knowledge_base SET problem = ?, solution = ?, category = ?, tags = ? WHERE id = ? `).run(problem.trim(), solution.trim(), category || 'Allgemein', tags?.trim() || null, parseInt(req.params.id)); const updated = db.prepare(` SELECT kb.*, u.username as created_by_username FROM knowledge_base kb LEFT JOIN users u ON kb.created_by_user_id = u.id WHERE kb.id = ? `).get(parseInt(req.params.id)); res.json({ status: 'success', data: updated }); }); /** * POST /api/ai/knowledge-base/import-text * Body: { text } OR { file: "", filename: "doc.pdf" } */ static importTextToKb = asyncHandler(async (req, res) => { const AiService = require('../services/ai.service'); let text = req.body.text; // Handle file upload (base64 encoded) if (!text && req.body.file && req.body.filename) { const { file, filename } = req.body; const ext = filename.split('.').pop().toLowerCase(); const match = file.match(/^data:[^;]+;base64,(.+)$/); const buf = Buffer.from(match ? match[1] : file, 'base64'); if (ext === 'pdf') { const pdfParse = require('pdf-parse'); const parsed = await pdfParse(buf); text = parsed.text; } else if (ext === 'docx' || ext === 'doc') { const mammoth = require('mammoth'); const result = await mammoth.extractRawText({ buffer: buf }); text = result.value; } else if (ext === 'eml' || ext === 'msg') { const { simpleParser } = require('mailparser'); const parsed = await simpleParser(buf); text = `Betreff: ${parsed.subject || ''}\nVon: ${parsed.from?.text || ''}\n\n${parsed.text || parsed.html || ''}`; } else if (ext === 'txt') { text = buf.toString('utf-8'); } else { throw new AppError('Unterstützte Formate: PDF, DOCX, EML, TXT', 400); } } if (!text?.trim()) throw new AppError('text oder Datei ist erforderlich', 400); const ids = await AiService.importTextToKb(text, req.user.id); res.json({ status: 'success', data: { created: ids.length, ids } }); }); /** * POST /api/ai/knowledge-base/import-url * Body: { url } * Fetches a web page, strips HTML, extracts text, and imports it via AI */ static importUrlToKb = asyncHandler(async (req, res) => { const AiService = require('../services/ai.service'); const { url } = req.body; if (!url?.trim()) throw new AppError('URL ist erforderlich', 400); const { assertPublicUrl } = require('../utils/ssrfGuard'); try { await assertPublicUrl(url); } catch (e) { throw new AppError(e.message, 400); } let html; try { const response = await fetch(url, { headers: { 'User-Agent': 'Mozilla/5.0 IT-Nexus KnowledgeBase Importer' }, signal: AbortSignal.timeout(15000), }); if (!response.ok) throw new AppError(`Seite nicht erreichbar: HTTP ${response.status}`, 400); html = await response.text(); } catch (err) { if (err instanceof AppError) throw err; throw new AppError(`Fehler beim Abrufen der URL: ${err.message}`, 400); } // Strip scripts, styles, nav, header, footer then all HTML tags const text = html .replace(/]*>[\s\S]*?<\/script>/gi, '') .replace(/]*>[\s\S]*?<\/style>/gi, '') .replace(/]*>[\s\S]*?<\/nav>/gi, '') .replace(/]*>[\s\S]*?<\/header>/gi, '') .replace(/]*>[\s\S]*?<\/footer>/gi, '') .replace(/<[^>]+>/g, ' ') .replace(/ /g, ' ').replace(/&/g, '&').replace(/</g, '<') .replace(/>/g, '>').replace(/"/g, '"').replace(/'/g, "'") .replace(/\s+/g, ' ') .trim(); if (text.length < 100) throw new AppError('Zu wenig Inhalt auf der Seite gefunden', 400); const ids = await AiService.importTextToKb(text, req.user.id); res.json({ status: 'success', data: { created: ids.length, ids } }); }); /** * POST /api/ai/knowledge-base/import-crawl * Body: { url, maxPages } * Crawls a website (BFS within same domain+path) and imports all pages via AI */ static importCrawlToKb = asyncHandler(async (req, res) => { const AiService = require('../services/ai.service'); const { url, maxPages = 20 } = req.body; if (!url?.trim()) throw new AppError('URL ist erforderlich', 400); const { assertPublicUrl } = require('../utils/ssrfGuard'); try { await assertPublicUrl(url); } catch (e) { throw new AppError(e.message, 400); } const limit = Math.min(Math.max(1, parseInt(maxPages) || 20), 100); const baseUrl = new URL(url); const baseDomain = baseUrl.hostname; // basePath: bei index-Seiten oder explizit deaktiviertem Pfad-Filter nur nach Hostname filtern let basePath = baseUrl.pathname.substring(0, baseUrl.pathname.lastIndexOf('/') + 1); if (/\/index\.(html?|htm)$/i.test(baseUrl.pathname)) { basePath = '/'; // Gesamte Domain crawlen wenn Index-Seite } const stripHtml = (html) => html .replace(/]*>[\s\S]*?<\/script>/gi, '') .replace(/]*>[\s\S]*?<\/style>/gi, '') .replace(/]*>[\s\S]*?<\/nav>/gi, '') .replace(/]*>[\s\S]*?<\/header>/gi, '') .replace(/]*>[\s\S]*?<\/footer>/gi, '') .replace(/<[^>]+>/g, ' ') .replace(/ /g, ' ').replace(/&/g, '&').replace(/</g, '<') .replace(/>/g, '>').replace(/"/g, '"').replace(/'/g, "'") .replace(/\s+/g, ' ').trim(); const visited = new Set(); const queue = [url]; const texts = []; while (queue.length > 0 && visited.size < limit) { const currentUrl = queue.shift(); if (visited.has(currentUrl)) continue; visited.add(currentUrl); try { try { await assertPublicUrl(currentUrl); } catch { continue; } // DNS-Rebinding-Schutz const response = await fetch(currentUrl, { headers: { 'User-Agent': 'Mozilla/5.0 IT-Nexus KnowledgeBase Importer' }, signal: AbortSignal.timeout(10000), }); if (!response.ok) continue; const html = await response.text(); const text = stripHtml(html); if (text.length > 100) texts.push(text); // Extract links within same domain + path prefix const linkRegex = /href="([^"#?]+)"/gi; let match; while ((match = linkRegex.exec(html)) !== null) { try { const linkUrl = new URL(match[1], currentUrl); if ( linkUrl.hostname === baseDomain && linkUrl.pathname.startsWith(basePath) && !visited.has(linkUrl.href) && !queue.includes(linkUrl.href) ) { queue.push(linkUrl.href); } } catch { /* ignore invalid URLs */ } } } catch { /* ignore fetch errors for individual pages */ } } if (texts.length === 0) throw new AppError('Keine Inhalte gefunden', 400); // Process in chunks of 40k chars to stay within AI context limits const CHUNK = 40000; const combined = texts.join('\n\n---\n\n'); let allIds = []; for (let i = 0; i < combined.length; i += CHUNK) { const chunk = combined.slice(i, i + CHUNK); if (chunk.trim().length > 100) { const ids = await AiService.importTextToKb(chunk, req.user.id); allIds = allIds.concat(ids); } } res.json({ status: 'success', data: { created: allIds.length, pages: visited.size, ids: allIds } }); }); /** * POST /api/ai/knowledge-base/:id/images * Body: { image: "", filename: "foto.png" } */ static uploadKbImage = asyncHandler(async (req, res) => { const db = getDatabase(); const id = parseInt(req.params.id); const entry = db.prepare('SELECT id, images FROM knowledge_base WHERE id = ?').get(id); if (!entry) throw new AppError('Eintrag nicht gefunden', 404); const { image, filename } = req.body; if (!image || !filename) throw new AppError('image und filename erforderlich', 400); // Validate base64 image const match = image.match(/^data:(image\/(png|jpe?g|gif|webp));base64,(.+)$/); if (!match) throw new AppError('Ungültiges Bildformat (PNG, JPG, GIF, WEBP erlaubt)', 400); const ext = match[2].replace('jpeg', 'jpg'); const safeName = `kb-${id}-${Date.now()}.${ext}`; const uploadDir = path.join(__dirname, '../../uploads/kb'); fs.mkdirSync(uploadDir, { recursive: true }); fs.writeFileSync(path.join(uploadDir, safeName), Buffer.from(match[3], 'base64')); const images = JSON.parse(entry.images || '[]'); images.push(safeName); db.prepare('UPDATE knowledge_base SET images = ?, updated_at = CURRENT_TIMESTAMP WHERE id = ?') .run(JSON.stringify(images), id); res.json({ status: 'success', data: { filename: safeName, url: `/uploads/kb/${safeName}` } }); }); /** * DELETE /api/ai/knowledge-base/:id/images/:filename */ static deleteKbImage = asyncHandler(async (req, res) => { const db = getDatabase(); const id = parseInt(req.params.id); const { filename } = req.params; const entry = db.prepare('SELECT id, images FROM knowledge_base WHERE id = ?').get(id); if (!entry) throw new AppError('Eintrag nicht gefunden', 404); const images = JSON.parse(entry.images || '[]').filter(f => f !== filename); db.prepare('UPDATE knowledge_base SET images = ?, updated_at = CURRENT_TIMESTAMP WHERE id = ?') .run(JSON.stringify(images), id); // Delete file const filePath = path.join(__dirname, '../../uploads/kb', filename); if (fs.existsSync(filePath)) fs.unlinkSync(filePath); res.json({ status: 'success', message: 'Bild gelöscht' }); }); /** * DELETE /api/ai/knowledge-base/:id */ static deleteKnowledgeEntry = asyncHandler(async (req, res) => { const db = getDatabase(); const entry = db.prepare('SELECT id, images FROM knowledge_base WHERE id = ?').get(parseInt(req.params.id)); if (!entry) throw new AppError('Eintrag nicht gefunden', 404); // Delete all associated images const images = JSON.parse(entry.images || '[]'); for (const f of images) { const fp = path.join(__dirname, '../../uploads/kb', f); if (fs.existsSync(fp)) fs.unlinkSync(fp); } db.prepare('DELETE FROM knowledge_base WHERE id = ?').run(parseInt(req.params.id)); res.json({ status: 'success', message: 'Eintrag gelöscht' }); }); } module.exports = AiController;