Initial commit: IT Nexus Web-App
This commit is contained in:
357
backend/src/controllers/ai.controller.js
Normal file
357
backend/src/controllers/ai.controller.js
Normal file
@@ -0,0 +1,357 @@
|
||||
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: "<base64>", 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);
|
||||
|
||||
// Only allow http/https
|
||||
if (!/^https?:\/\//i.test(url)) throw new AppError('Nur HTTP/HTTPS URLs erlaubt', 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(/<script[^>]*>[\s\S]*?<\/script>/gi, '')
|
||||
.replace(/<style[^>]*>[\s\S]*?<\/style>/gi, '')
|
||||
.replace(/<nav[^>]*>[\s\S]*?<\/nav>/gi, '')
|
||||
.replace(/<header[^>]*>[\s\S]*?<\/header>/gi, '')
|
||||
.replace(/<footer[^>]*>[\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);
|
||||
if (!/^https?:\/\//i.test(url)) throw new AppError('Nur HTTP/HTTPS URLs erlaubt', 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(/<script[^>]*>[\s\S]*?<\/script>/gi, '')
|
||||
.replace(/<style[^>]*>[\s\S]*?<\/style>/gi, '')
|
||||
.replace(/<nav[^>]*>[\s\S]*?<\/nav>/gi, '')
|
||||
.replace(/<header[^>]*>[\s\S]*?<\/header>/gi, '')
|
||||
.replace(/<footer[^>]*>[\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 {
|
||||
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: "<base64>", 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;
|
||||
Reference in New Issue
Block a user