- number-поля теперь рендерятся как text + inputMode=numeric, чтобы браузер не округлял значения через input type=number - пробелы при вставке в number-поля удаляются - бэкенд нормализует значения number-полей в строку перед сохранением - добавлен хелпер normalizeFieldValueForStorage Closes: искажение расчётного счёта и других длинных числовых полей
351 lines
14 KiB
TypeScript
351 lines
14 KiB
TypeScript
import { Router } from "express";
|
||
import { storage } from "../storage";
|
||
import { authenticateToken, requirePermission, type AuthenticatedRequest } from "../middleware/auth.middleware";
|
||
import { tenantIsolation } from "../middleware/tenant.middleware";
|
||
import { encrypt, decrypt } from "../crypto";
|
||
import { testOllamaConnection } from "../services/llm-provider";
|
||
|
||
const router = Router();
|
||
|
||
/**
|
||
* Validate a base URL for use as an AI provider endpoint.
|
||
* - Must use http:// or https:// scheme.
|
||
* - When ALLOW_PRIVATE_RAG_URLS env var is set to "true", private/loopback addresses
|
||
* are permitted (useful for self-hosted Ollama on the same server).
|
||
* - Otherwise, private RFC-1918 ranges and loopback are blocked to prevent SSRF.
|
||
*/
|
||
function validateBaseUrl(raw: string): { ok: true; url: string } | { ok: false; error: string } {
|
||
let parsed: URL;
|
||
try {
|
||
parsed = new URL(raw.trim());
|
||
} catch {
|
||
return { ok: false, error: "Некорректный URL (неверный формат)" };
|
||
}
|
||
|
||
if (!["http:", "https:"].includes(parsed.protocol)) {
|
||
return { ok: false, error: "URL должен начинаться с http:// или https://" };
|
||
}
|
||
|
||
// Normalize: remove trailing slash but preserve any path prefix (important for openai_compatible proxies)
|
||
const normalizedUrl = parsed.href.replace(/\/+$/, "");
|
||
if (process.env.ALLOW_PRIVATE_RAG_URLS === "true") {
|
||
return { ok: true, url: normalizedUrl };
|
||
}
|
||
|
||
const host = parsed.hostname.toLowerCase();
|
||
|
||
const privatePatterns = [
|
||
/^localhost$/,
|
||
/^127\./,
|
||
/^0\.0\.0\.0$/,
|
||
/^::1$/,
|
||
/^10\./,
|
||
/^172\.(1[6-9]|2\d|3[01])\./,
|
||
/^192\.168\./,
|
||
/^169\.254\./,
|
||
/^fc00:/i,
|
||
/^fd[0-9a-f]{2}:/i,
|
||
/^fe80:/i,
|
||
];
|
||
|
||
for (const re of privatePatterns) {
|
||
if (re.test(host)) {
|
||
return {
|
||
ok: false,
|
||
error: `Частные/локальные адреса запрещены (${host}). Установите переменную среды ALLOW_PRIVATE_RAG_URLS=true для локального Ollama.`,
|
||
};
|
||
}
|
||
}
|
||
|
||
return { ok: true, url: normalizedUrl };
|
||
}
|
||
|
||
function maskApiKey(key: string): string {
|
||
if (!key || key.length <= 4) return "****";
|
||
return `***${key.slice(-4)}`;
|
||
}
|
||
|
||
router.get(
|
||
"/api/rag/settings",
|
||
authenticateToken,
|
||
requirePermission('settings.manage'),
|
||
tenantIsolation,
|
||
async (req: AuthenticatedRequest, res) => {
|
||
try {
|
||
const settings = await storage.getRagSettings(req.organizationId!);
|
||
if (!settings) {
|
||
return res.json({
|
||
success: true,
|
||
settings: {
|
||
provider: "openai",
|
||
apiKey: null,
|
||
apiKeyMasked: null,
|
||
baseUrl: null,
|
||
embeddingModel: "text-embedding-3-small",
|
||
chatModel: null,
|
||
maxChunkChars: null,
|
||
summarizationEnabled: true,
|
||
embeddingProviderId: null,
|
||
chatProviderId: null,
|
||
},
|
||
});
|
||
}
|
||
return res.json({
|
||
success: true,
|
||
settings: {
|
||
provider: settings.provider,
|
||
apiKey: null,
|
||
apiKeyMasked: settings.apiKey ? maskApiKey(decrypt(settings.apiKey)) : null,
|
||
baseUrl: settings.baseUrl,
|
||
embeddingModel: settings.embeddingModel,
|
||
chatModel: settings.chatModel ?? null,
|
||
maxChunkChars: settings.maxChunkChars ?? null,
|
||
summarizationEnabled: settings.summarizationEnabled ?? true,
|
||
embeddingProviderId: settings.embeddingProviderId ?? null,
|
||
chatProviderId: settings.chatProviderId ?? null,
|
||
},
|
||
});
|
||
} catch (err) {
|
||
console.error("[RAG] GET settings error:", err);
|
||
return res.status(500).json({ success: false, error: "Ошибка получения настроек" });
|
||
}
|
||
}
|
||
);
|
||
|
||
router.patch(
|
||
"/api/rag/settings",
|
||
authenticateToken,
|
||
requirePermission('settings.manage'),
|
||
tenantIsolation,
|
||
async (req: AuthenticatedRequest, res) => {
|
||
try {
|
||
const { provider, apiKey, baseUrl, embeddingModel, chatModel, maxChunkChars, summarizationEnabled, embeddingProviderId, chatProviderId } = req.body;
|
||
const updates: Record<string, string | number | boolean | null | undefined> = {};
|
||
// Fetch existing settings for effective-state validation
|
||
const existingSettings = await storage.getRagSettings(req.organizationId!);
|
||
|
||
if (provider !== undefined) {
|
||
const allowed = ["openai", "openai_compatible", "ollama"];
|
||
if (!allowed.includes(provider)) {
|
||
return res.status(400).json({ success: false, error: "Недопустимый провайдер" });
|
||
}
|
||
updates.provider = provider;
|
||
}
|
||
|
||
if (apiKey !== undefined) {
|
||
updates.apiKey = apiKey && apiKey.trim() ? encrypt(apiKey.trim()) : null;
|
||
}
|
||
|
||
if (baseUrl !== undefined) {
|
||
if (baseUrl && baseUrl.trim()) {
|
||
const check = validateBaseUrl(baseUrl);
|
||
if (!check.ok) return res.status(400).json({ success: false, error: check.error });
|
||
updates.baseUrl = check.url;
|
||
} else {
|
||
updates.baseUrl = null;
|
||
}
|
||
}
|
||
|
||
if (embeddingModel !== undefined) {
|
||
if (!embeddingModel || !embeddingModel.trim()) {
|
||
// Allowed to be empty when effective embeddingProviderId is set (server resolves to first enabled model)
|
||
const effectiveEmbProvForEmpty = embeddingProviderId != null
|
||
? (embeddingProviderId || null)
|
||
: (existingSettings?.embeddingProviderId ?? null);
|
||
if (!effectiveEmbProvForEmpty) {
|
||
return res.status(400).json({ success: false, error: "Модель эмбеддингов не может быть пустой без провайдера из реестра" });
|
||
}
|
||
updates.embeddingModel = null;
|
||
} else {
|
||
updates.embeddingModel = embeddingModel.trim();
|
||
}
|
||
}
|
||
|
||
if (chatModel !== undefined) {
|
||
updates.chatModel = chatModel && chatModel.trim() ? chatModel.trim() : null;
|
||
}
|
||
|
||
if (summarizationEnabled !== undefined) {
|
||
updates.summarizationEnabled = Boolean(summarizationEnabled);
|
||
}
|
||
|
||
if (maxChunkChars !== undefined) {
|
||
if (maxChunkChars === null || maxChunkChars === "") {
|
||
updates.maxChunkChars = null;
|
||
} else {
|
||
const n = parseInt(String(maxChunkChars), 10);
|
||
if (isNaN(n) || n < 100 || n > 100000) {
|
||
return res.status(400).json({ success: false, error: "maxChunkChars должен быть от 100 до 100000" });
|
||
}
|
||
updates.maxChunkChars = n;
|
||
}
|
||
}
|
||
|
||
if (embeddingProviderId !== undefined) {
|
||
if (embeddingProviderId) {
|
||
const n = Number(embeddingProviderId);
|
||
if (!Number.isInteger(n) || n <= 0) return res.status(400).json({ success: false, error: "Некорректный embeddingProviderId" });
|
||
updates.embeddingProviderId = n;
|
||
} else {
|
||
updates.embeddingProviderId = null;
|
||
}
|
||
}
|
||
if (chatProviderId !== undefined) {
|
||
if (chatProviderId) {
|
||
const n = Number(chatProviderId);
|
||
if (!Number.isInteger(n) || n <= 0) return res.status(400).json({ success: false, error: "Некорректный chatProviderId" });
|
||
updates.chatProviderId = n;
|
||
} else {
|
||
updates.chatProviderId = null;
|
||
}
|
||
}
|
||
|
||
// Validate models against effective provider IDs (existing + incoming)
|
||
const effectiveEmbProvId = (updates.embeddingProviderId !== undefined
|
||
? updates.embeddingProviderId
|
||
: existingSettings?.embeddingProviderId) as number | null | undefined;
|
||
// Mirror service resolution: chatProviderId ?? embeddingProviderId (shared "same provider" mode)
|
||
const rawChatProvId = (updates.chatProviderId !== undefined
|
||
? updates.chatProviderId
|
||
: existingSettings?.chatProviderId) as number | null | undefined;
|
||
const effectiveChatProvId = (rawChatProvId ?? effectiveEmbProvId) as number | null | undefined;
|
||
const effectiveEmbModel = (updates.embeddingModel !== undefined
|
||
? updates.embeddingModel
|
||
: existingSettings?.embeddingModel) as string | null | undefined;
|
||
const effectiveChatModel = (updates.chatModel !== undefined
|
||
? updates.chatModel
|
||
: existingSettings?.chatModel) as string | null | undefined;
|
||
|
||
if (effectiveEmbProvId) {
|
||
const embProv = await storage.getLlmProvider(effectiveEmbProvId, req.organizationId!);
|
||
if (!embProv || !embProv.isActive) {
|
||
return res.status(400).json({ success: false, error: "Провайдер эмбеддингов не найден или неактивен" });
|
||
}
|
||
if (effectiveEmbModel && embProv.enabledModels && embProv.enabledModels.length > 0 && !embProv.enabledModels.includes(effectiveEmbModel)) {
|
||
return res.status(400).json({ success: false, error: `Модель "${effectiveEmbModel}" не входит в список разрешённых моделей провайдера` });
|
||
}
|
||
}
|
||
if (effectiveChatProvId) {
|
||
const chatProv = await storage.getLlmProvider(effectiveChatProvId, req.organizationId!);
|
||
if (!chatProv || !chatProv.isActive) {
|
||
return res.status(400).json({ success: false, error: "Провайдер суммаризации не найден или неактивен" });
|
||
}
|
||
if (effectiveChatModel && chatProv.enabledModels && chatProv.enabledModels.length > 0 && !chatProv.enabledModels.includes(effectiveChatModel)) {
|
||
return res.status(400).json({ success: false, error: `Модель "${effectiveChatModel}" не входит в список разрешённых моделей провайдера` });
|
||
}
|
||
}
|
||
|
||
const saved = await storage.upsertRagSettings(req.organizationId!, updates);
|
||
return res.json({
|
||
success: true,
|
||
settings: {
|
||
provider: saved.provider,
|
||
apiKey: null,
|
||
apiKeyMasked: saved.apiKey ? maskApiKey(decrypt(saved.apiKey)) : null,
|
||
baseUrl: saved.baseUrl,
|
||
embeddingModel: saved.embeddingModel,
|
||
chatModel: saved.chatModel ?? null,
|
||
maxChunkChars: saved.maxChunkChars ?? null,
|
||
summarizationEnabled: saved.summarizationEnabled ?? true,
|
||
embeddingProviderId: saved.embeddingProviderId ?? null,
|
||
chatProviderId: saved.chatProviderId ?? null,
|
||
},
|
||
});
|
||
} catch (err) {
|
||
console.error("[RAG] PATCH settings error:", err);
|
||
return res.status(500).json({ success: false, error: "Ошибка сохранения настроек" });
|
||
}
|
||
}
|
||
);
|
||
|
||
router.post(
|
||
"/api/rag/settings/test",
|
||
authenticateToken,
|
||
requirePermission('settings.manage'),
|
||
tenantIsolation,
|
||
async (req: AuthenticatedRequest, res) => {
|
||
try {
|
||
const { provider, apiKey, baseUrl, embeddingModel } = req.body;
|
||
|
||
let resolvedApiKey: string | null = apiKey && apiKey.trim() ? apiKey.trim() : null;
|
||
|
||
if (!resolvedApiKey) {
|
||
const saved = await storage.getRagSettings(req.organizationId!);
|
||
if (saved?.apiKey) resolvedApiKey = decrypt(saved.apiKey);
|
||
}
|
||
|
||
if (!resolvedApiKey) {
|
||
resolvedApiKey = process.env.OPENAI_API_KEY ?? null;
|
||
}
|
||
|
||
const resolvedProvider = provider ?? "openai";
|
||
const resolvedModel = (embeddingModel ?? "text-embedding-3-small").trim();
|
||
|
||
// Build and validate the API base URL
|
||
let apiBase: string;
|
||
if (resolvedProvider === "ollama") {
|
||
const rawBase = baseUrl?.trim() ?? "http://localhost:11434";
|
||
const check = validateBaseUrl(rawBase);
|
||
if (!check.ok) return res.json({ success: false, error: check.error });
|
||
apiBase = check.url;
|
||
} else if (resolvedProvider === "openai_compatible" && baseUrl?.trim()) {
|
||
const check = validateBaseUrl(baseUrl.trim());
|
||
if (!check.ok) return res.json({ success: false, error: check.error });
|
||
apiBase = check.url;
|
||
} else {
|
||
apiBase = "https://api.openai.com";
|
||
}
|
||
|
||
if (resolvedProvider === "ollama") {
|
||
// Check reachability and model availability via /api/tags, then do a test embedding
|
||
const tagsResult = await testOllamaConnection(apiBase, resolvedModel);
|
||
if (!tagsResult.ok) {
|
||
return res.json({ success: false, error: tagsResult.error });
|
||
}
|
||
|
||
// Do an actual embedding to confirm the model works
|
||
const url = `${apiBase}/api/embeddings`;
|
||
const response = await fetch(url, {
|
||
method: "POST",
|
||
headers: { "Content-Type": "application/json" },
|
||
body: JSON.stringify({ model: resolvedModel, prompt: "test" }),
|
||
signal: AbortSignal.timeout(15000),
|
||
});
|
||
if (!response.ok) {
|
||
const text = await response.text();
|
||
return res.json({ success: false, error: `Ошибка Ollama при генерации эмбеддинга (${response.status}): ${text.slice(0, 200)}` });
|
||
}
|
||
return res.json({ success: true, message: "Подключение успешно, модель доступна" });
|
||
}
|
||
|
||
if (!resolvedApiKey) {
|
||
return res.json({ success: false, error: "API ключ не задан и переменная OPENAI_API_KEY не установлена" });
|
||
}
|
||
|
||
const url = `${apiBase}/v1/embeddings`;
|
||
const response = await fetch(url, {
|
||
method: "POST",
|
||
headers: {
|
||
"Content-Type": "application/json",
|
||
Authorization: `Bearer ${resolvedApiKey}`,
|
||
},
|
||
body: JSON.stringify({ input: "test", model: resolvedModel }),
|
||
signal: AbortSignal.timeout(10000),
|
||
});
|
||
|
||
if (!response.ok) {
|
||
const text = await response.text();
|
||
return res.json({ success: false, error: `Ошибка API (${response.status}): ${text.slice(0, 300)}` });
|
||
}
|
||
|
||
return res.json({ success: true, message: "Подключение успешно" });
|
||
} catch (err: any) {
|
||
const msg = err?.message ?? String(err);
|
||
console.error("[RAG] test connection error:", msg);
|
||
return res.json({ success: false, error: `Ошибка соединения: ${msg.slice(0, 200)}` });
|
||
}
|
||
}
|
||
);
|
||
|
||
export default router;
|