const http = require("http"); const fs = require("fs"); const path = require("path"); const PORT = process.env.PORT || 3000; const MAX_BODY = 40 * 1024 * 1024; const AI_TIMEOUT_MS = 90000; const ALLOWED = { "/": "index.html", "/index.html": "index.html", "/monitor.html": "monitor.html", "/kfu-client.js": "kfu-client.js", "/vendor/xlsx.full.min.js": "vendor/xlsx.full.min.js", "/design-system/kt-ai-fonts.css": "design-system/kt-ai-fonts.css", "/design-system/kt-ai-tokens.css": "design-system/kt-ai-tokens.css", "/design-system/kt-ai-components.css": "design-system/kt-ai-components.css", "/design-system/kt-ai-page.css": "design-system/kt-ai-page.css", "/design-system/kt-ai-print.css": "design-system/kt-ai-print.css", "/design-system/vibe-theme.css": "design-system/vibe-theme.css", "/design-system/kt-ai-chart-tip.js": "design-system/kt-ai-chart-tip.js", "/design-system/kt-ai-composer.js": "design-system/kt-ai-composer.js", "/design-system/kt-ai-feedback.js": "design-system/kt-ai-feedback.js", "/design-system/kt-ai-orb.js": "design-system/kt-ai-orb.js", "/design-system/icons/kt-ai-icons.js": "design-system/icons/kt-ai-icons.js", "/design-system/icons/kt-ai-lucide-sprite.svg": "design-system/icons/kt-ai-lucide-sprite.svg", "/design-system/fonts/InterVariable-subset.woff2": "design-system/fonts/InterVariable-subset.woff2", "/design-system/fonts/JetBrainsMono-subset.woff2": "design-system/fonts/JetBrainsMono-subset.woff2" }; const MIME = { ".html": "text/html", ".css": "text/css", ".js": "application/javascript", ".json": "application/json", ".svg": "image/svg+xml", ".png": "image/png", ".ico": "image/x-icon", ".woff2": "font/woff2" }; function loadPdfParse() { try { return require("pdf-parse"); } catch (e) {} if (process.env.NODE_PATH) { for (const p of String(process.env.NODE_PATH).split(path.delimiter)) { if (p) { try { return require(path.join(p, "pdf-parse")); } catch (e) {} } } } return null; } function readBody(req) { return new Promise((resolve, reject) => { let s = ""; req.setEncoding("utf8"); req.on("data", (c) => { s += c; if (s.length > MAX_BODY) { reject(new Error("body too large")); req.destroy(); } }); req.on("end", () => { try { resolve(s ? JSON.parse(s) : {}); } catch (e) { reject(new Error("invalid JSON body")); } }); req.on("error", reject); }); } function send(res, code, obj) { res.writeHead(code, { "Content-Type": "application/json; charset=utf-8" }); res.end(JSON.stringify(obj)); } // Anthropic blocks -> OpenAI user content parts async function toOpenAIContent(content, pdfParse) { if (typeof content === "string") return content; if (!Array.isArray(content)) return JSON.stringify(content); const parts = []; for (const b of content) { if (!b || typeof b !== "object") continue; if (b.type === "text" && typeof b.text === "string") { parts.push({ type: "text", text: b.text }); } else if (b.type === "file" && b.file === "image" && b.data) { parts.push({ type: "image_url", image_url: { url: "data:" + (b.media || "image/png") + ";base64," + b.data } }); } else if (b.type === "file" && b.file === "pdf" && b.data) { if (!pdfParse) throw new Error("PDF-парсер недоступен на сервере"); const buf = Buffer.from(b.data, "base64"); if (buf.length > 32 * 1024 * 1024) throw new Error("PDF больше 32 МБ"); let t; try { t = (await pdfParse(buf)).text; } catch (e) { throw new Error("не удалось разобрать PDF: " + (e.message || e)); } t = String(t || "").trim(); if (!t) throw new Error("из PDF не удалось извлечь текст (скан?) — добавьте фрагменты текстом"); parts.push({ type: "text", text: (b.name ? "PDF «" + b.name + "»:\n" : "PDF:\n") + t.slice(0, 400000) }); } } return parts.length ? parts : ""; } async function handleAI(req, res) { const body = await readBody(req); const model = process.env.AI_MODEL; if (!process.env.AI_BASE_URL || !process.env.AI_API_KEY || !model) { return send(res, 503, { error: "ИИ не настроен на сервере (нет AI_BASE_URL/AI_API_KEY/AI_MODEL). Перезапустите проект (run), чтобы обновить окружение." }); } const pdfParse = loadPdfParse(); const out = []; if (typeof body.system === "string" && body.system) out.push({ role: "system", content: body.system }); const msgs = Array.isArray(body.messages) ? body.messages : []; if (!msgs.length) return send(res, 400, { error: "нет сообщений" }); for (const m of msgs) { if (!m || (m.role !== "user" && m.role !== "assistant")) continue; const c = await toOpenAIContent(m.content, pdfParse); if (c === "" && m.role === "user") continue; out.push({ role: m.role, content: c }); } const maxTok = Math.min(Math.max(parseInt(body.max_tokens, 10) || 4096, 256), 8192); const ctrl = new AbortController(); const t = setTimeout(() => ctrl.abort(), AI_TIMEOUT_MS); try { const r = await fetch(String(process.env.AI_BASE_URL).replace(/\/+$/, "") + "/chat/completions", { method: "POST", headers: { "Content-Type": "application/json", Authorization: "Bearer " + process.env.AI_API_KEY }, body: JSON.stringify({ model, messages: out, max_tokens: maxTok }), signal: ctrl.signal }); const j = await r.json().catch(() => ({})); if (!r.ok) { const msg = (j && (j.error && (j.error.message || j.error) || j.message)) || ("сервер ИИ вернул " + r.status); const msgStr = typeof msg === "string" ? msg : JSON.stringify(msg); return send(res, 502, { error: "ИИ: " + msgStr.slice(0, 500) }); } const choice = (j.choices && j.choices[0]) || {}; const text = (choice.message && choice.message.content) || ""; return send(res, 200, { content: String(text), truncated: choice.finish_reason === "length" }); } catch (e) { const what = e.name === "AbortError" ? "таймаут (90 с) — запрос слишком большой" : (e.message || String(e)); return send(res, 502, { error: "ИИ недоступен: " + what.slice(0, 300) }); } finally { clearTimeout(t); } } http.createServer(async (req, res) => { const url = (req.url || "/").split("?")[0]; try { if (req.method === "POST" && url === "/api/ai") return await handleAI(req, res); if ((req.method === "GET" || req.method === "POST") && url === "/api/health") { return send(res, 200, { ok: true, version: "1.3", ai: !!(process.env.AI_BASE_URL && process.env.AI_API_KEY && process.env.AI_MODEL) }); } if (req.method === "GET") { let file = ALLOWED[url]; if (!file && (url === "/" || url.endsWith("/"))) file = "index.html"; if (!file && url.startsWith("/design-system/")) { let rel = path.normalize(decodeURIComponent(url.replace(/%20/g, " "))); if (!rel.startsWith("../") && !path.isAbsolute(rel)) file = rel; } if (file) { const full = path.join(__dirname, file); if (full.startsWith(__dirname) && fs.existsSync(full) && fs.statSync(full).isFile()) { const ext = path.extname(full); res.writeHead(200, { "Content-Type": (MIME[ext] || "application/octet-stream") + "; charset=utf-8", "Cache-Control": "no-cache" }); return res.end(fs.readFileSync(full)); } } res.writeHead(404, { "Content-Type": "text/plain; charset=utf-8" }); return res.end("Not found"); } res.writeHead(404, { "Content-Type": "application/json" }); return res.end(JSON.stringify({ error: "not found" })); } catch (e) { try { send(res, 500, { error: (e && e.message) || "ошибка сервера" }); } catch (_) {} } }).listen(PORT, "0.0.0.0", () => console.log("КФУ-агент сервер на порту " + PORT));