161 lines
8.0 KiB
JavaScript
161 lines
8.0 KiB
JavaScript
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) || 1200, 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, stream: false, chat_template_kwargs: { enable_thinking: false } }),
|
||
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));
|