fix(number-fields): избегаем потери точности длинных чисел

- number-поля теперь рендерятся как text + inputMode=numeric,
  чтобы браузер не округлял значения через input type=number
- пробелы при вставке в number-поля удаляются
- бэкенд нормализует значения number-полей в строку перед сохранением
- добавлен хелпер normalizeFieldValueForStorage

Closes: искажение расчётного счёта и других длинных числовых полей
This commit is contained in:
2026-07-07 21:03:40 +03:00
commit 1f5ecb6da4
1089 changed files with 237546 additions and 0 deletions

350
server/routes/rag.routes.ts Normal file
View File

@@ -0,0 +1,350 @@
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;