sozday-rabochee-veb-prilozhe/server.js

161 lines
8.0 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

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));