Compare commits
4 Commits
1ee35efb8d
...
6a494319dd
| Author | SHA1 | Date | |
|---|---|---|---|
| 6a494319dd | |||
| f1cacb5c73 | |||
| 70585ea058 | |||
| b85d743c5a |
2
.gitignore
vendored
2
.gitignore
vendored
@ -8,3 +8,5 @@ dist/
|
||||
.vibe42-run.pid
|
||||
.DS_Store
|
||||
.mimo/
|
||||
.env
|
||||
kfu-agent-original-source.txt
|
||||
|
||||
39
README.md
39
README.md
@ -1,13 +1,36 @@
|
||||
# КФУ — финансовая устойчивость
|
||||
# КФУ-агент — расчёт К1–К3 по Политике финансовой устойчивости
|
||||
|
||||
Статическое приложение, созданное через чат Vibe AI. Запуск: откройте вкладку Превью проекта; сборка npm не требуется.
|
||||
Приложение с сервером (Node.js, `node:http`, без тяжёлых зависимостей) + статический фронт.
|
||||
Запуск — команда **`run`** (поставит зависимости по `package.json` и поднимет `server.js`).
|
||||
|
||||
Пилот работает с предоставленным шаблоном факта 2025: 15 компаний и одна консолидированная группа. Источники: ДОЛГ 4Q2025_24042026; EBITDA 2025_06042026; BS 4Q2025_06042026; Группа Фонда 4Q 2025. Контрольный свод: План 2026!G5:J20.
|
||||
## Что умеет
|
||||
1. **Данные** — три источника:
|
||||
- выгрузка СУО (SAP BPC): листы ДОЛГ / EBITDA / Баланс, распознавание структуры, подбор строк,
|
||||
EBITDA «снизу вверх» по статьям ОПиУ и готовая строкой, LTM-мостик для промежуточных периодов,
|
||||
структура долга по компонентам с контролем по сумме, блок «Консолидация» (TOTALADJ) для Группы Фонда;
|
||||
- отчётность МСФО — извлечение показателей ИИ (PDF распковывается сервером в текст, Excel в браузере,
|
||||
сканы страниц — как изображения); результат — редактируемая сводка с LTM и итогами;
|
||||
- ручной ввод.
|
||||
2. **Реестр показателей** — компании и периоды, долг/гарантии/EBITDA/проценты/капитал, редактирование и удаление.
|
||||
3. **Расчёт и зоны** — целевые и пороговые значения по компаниям (предзаполнены из сводного файла КФУ),
|
||||
свод «как в сводном файле», коэффициенты К1–К3 с зонами (зелёная/жёлтая/красная), EBITDA ≤ 0 → красная,
|
||||
выгрузка в Excel (листы: Свод, Долг Группы, КФУ, Параметры, Аудит) и CSV.
|
||||
4. **Агент** — чат с расчётом по методам: видит реестр, зоны и методы (реестр); подсказки:
|
||||
заключение по зонам, стресс-тест (долг +10%, EBITDA −15%, проценты +20%, капитал −5% — пересчитывается
|
||||
детерминированно в `stressRow`, агенту передаются готовые значения), запас до порогов, методологические риски.
|
||||
|
||||
Загрузите .xlsx, откройте строку компании, при необходимости измените прочие/гарантии в млн тенге. Другие показатели сценарий не редактирует. Кнопка Вернуть сбрасывает изменение. Выгрузить отчёт .xlsx сохраняет снимок текущего сценария. Листы отчёта: КФУ, Источники, Диагностика, Сверка, Пояснения.
|
||||
## Архитектура
|
||||
- `index.html` — всё приложение (вкладки 1–4), SheetJS из `vendor/xlsx.full.min.js`.
|
||||
- `monitor.html` + `kfu-client.js` — предыдущий экран «Мониторинг» (ссылка в шапке КФУ-агента).
|
||||
- `server.js` — `node:http`: статика (allowlist файлов) + `POST /api/ai` (прокси к
|
||||
`AI_BASE_URL/chat/completions` с `AI_API_KEY`/`AI_MODEL` из окружения) + `GET /api/health`.
|
||||
Ключи ИИ живут только на сервере; браузер ходит в `api/ai` (относительный путь).
|
||||
- PDF: клиент шлёт base64 в `/api/ai` (блок `file:pdf`), сервер распковывает `pdf-parse`;
|
||||
пустой PDF (скан) → честная ошибка. Лимит тела 40 МБ, таймаут ИИ 90 с.
|
||||
|
||||
Проверены 64/64 исходных показателя (16 объектов × 4 показателя), сценарий/сброс, фильтры, пропуски данных, отрицательные показатели и экспорт. Сверка всегда относится к исходным данным без сценария.
|
||||
## Запуск
|
||||
`run` → ссылка из вывода. После правок — ещё раз `run`.
|
||||
Публикация — кнопка «Опубликовать» (приложение: `https://code.vibe42.kz/apps/...`, не в pages).
|
||||
|
||||
Формулы Excel не пересчитываются: читаются сохранённые значения исходных ячеек, затем коэффициенты рассчитываются приложением. Перед загрузкой пересчитайте и сохраните книгу в Excel.
|
||||
|
||||
Пользовательский Excel читается в браузере, не отправляется приложением на сервер. Исходная книга и отчёты исключены из Git. Учебный пример содержит синтетические данные.
|
||||
Данные (Excel) читаются в браузере и не покидают машину, кроме отправки PDF/снимков в ИИ для извлечения показателей.
|
||||
Состояние (реестр, параметры, методика) сохраняется/загружается локальным JSON; исходные книги и отчёты исключены из Git.
|
||||
|
||||
1840
index.html
1840
index.html
File diff suppressed because it is too large
Load Diff
@ -1098,6 +1098,8 @@
|
||||
render();
|
||||
}
|
||||
|
||||
window.KfuWorkbook = { analyzeWorkbook: analyzeWorkbook };
|
||||
|
||||
/* ---------- инициализация ---------- */
|
||||
function init() {
|
||||
var fileInput = $("#fileInput");
|
||||
@ -1170,5 +1172,8 @@
|
||||
|
||||
render();
|
||||
}
|
||||
document.addEventListener("DOMContentLoaded", init);
|
||||
document.addEventListener("DOMContentLoaded", function () {
|
||||
if (!document.getElementById("uploadPanel")) return;
|
||||
init();
|
||||
});
|
||||
})();
|
||||
|
||||
444
monitor.html
Normal file
444
monitor.html
Normal file
@ -0,0 +1,444 @@
|
||||
<!DOCTYPE html>
|
||||
<!-- no-design-system -->
|
||||
<!-- Дизайн: Стандартный дизайн. Финансовый рабочий экран КФУ. Палитра задана пользователем. -->
|
||||
<html lang="ru" data-theme="light">
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<meta name="viewport" content="width=device-width,initial-scale=1">
|
||||
<title>КФУ — финансовая устойчивость</title>
|
||||
<link rel="stylesheet" href="design-system/kt-ai-fonts.css">
|
||||
<link rel="stylesheet" href="design-system/kt-ai-tokens.css">
|
||||
<link rel="stylesheet" href="design-system/kt-ai-components.css">
|
||||
<link rel="stylesheet" href="design-system/kt-ai-page.css">
|
||||
<link rel="stylesheet" href="design-system/vibe-theme.css">
|
||||
<script src="design-system/icons/kt-ai-icons.js"></script>
|
||||
<script src="vendor/xlsx.full.min.js"></script>
|
||||
<style>
|
||||
/* Финансовые токены — пользовательские, поверх ДС */
|
||||
:root {
|
||||
--kfu-bg: #F4F6F8;
|
||||
--kfu-surface: #FFFFFF;
|
||||
--kfu-ink: #1E2430;
|
||||
--kfu-ink-2: #5A6472;
|
||||
--kfu-ink-3: #8A93A3;
|
||||
--kfu-accent: #0D9AAA;
|
||||
--kfu-accent-hover: #0A7E8C;
|
||||
--kfu-accent-soft: #E4F4F5;
|
||||
--kfu-border: #E1E5EA;
|
||||
--kfu-border-2: #CBD2DB;
|
||||
--kfu-risk: #C03D32;
|
||||
--kfu-risk-bg: #FBEAE8;
|
||||
--kfu-warn: #A5701A;
|
||||
--kfu-warn-bg: #FDF4E3;
|
||||
--kfu-ok: #247A4B;
|
||||
--kfu-ok-bg: #E7F4EE;
|
||||
--kfu-neutral: #5A6472;
|
||||
--kfu-neutral-bg: #F0F2F5;
|
||||
--kfu-radius: 10px;
|
||||
--kfu-radius-sm: 6px;
|
||||
--kfu-shadow: 0 1px 3px rgba(30,36,48,.06), 0 1px 2px rgba(30,36,48,.04);
|
||||
--kfu-shadow-md: 0 4px 16px rgba(30,36,48,.08);
|
||||
--kfu-max: 1180px;
|
||||
}
|
||||
|
||||
@layer product {
|
||||
[hidden] { display: none !important; }
|
||||
*, *::before, *::after { box-sizing: border-box; }
|
||||
a.kt-ai-btn { text-decoration: none; }
|
||||
body { background: var(--kfu-bg); }
|
||||
.kt-ai-sidebar { display: none !important; }
|
||||
|
||||
/* ---------- верхняя полоса ---------- */
|
||||
.kfu-topbar { position: sticky; top: 0; z-index: var(--kt-ai-z-sticky);
|
||||
background: var(--kfu-surface); border-bottom: 1px solid var(--kfu-border);
|
||||
height: 56px; display: flex; align-items: center; padding: 0 24px; gap: 16px; }
|
||||
.kfu-brand { display: flex; align-items: center; gap: 10px; flex-shrink: 0; }
|
||||
.kfu-brand-mark { width: 32px; height: 32px; border-radius: 8px; background: var(--kfu-ink);
|
||||
color: #fff; display: flex; align-items: center; justify-content: center;
|
||||
font-size: 14px; font-weight: 700; letter-spacing: .3px; }
|
||||
.kfu-brand-text { line-height: 1.2; }
|
||||
.kfu-brand-text b { display: block; font-size: 14px; font-weight: 600; color: var(--kfu-ink); letter-spacing: -.01em; }
|
||||
.kfu-brand-text span { display: block; font-size: 11px; color: var(--kfu-ink-3); }
|
||||
.kfu-topbar-right { margin-left: auto; display: flex; align-items: center; gap: 12px; }
|
||||
.kfu-file-badge { font-size: 12px; color: var(--kfu-ink-2); display: flex; align-items: center; gap: 8px;
|
||||
background: var(--kfu-neutral-bg); padding: 5px 12px; border-radius: var(--kfu-radius-sm);
|
||||
max-width: 260px; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
|
||||
.kfu-file-badge .dot { width: 6px; height: 6px; border-radius: 50%; background: var(--kfu-accent); flex-shrink: 0; }
|
||||
.kfu-btn { display: inline-flex; align-items: center; gap: 6px; height: 32px; padding: 0 14px;
|
||||
border-radius: var(--kfu-radius-sm); font-size: 13px; font-weight: 500; cursor: pointer;
|
||||
border: 1px solid var(--kfu-border-2); background: var(--kfu-surface); color: var(--kfu-ink);
|
||||
transition: background .12s, border-color .12s, box-shadow .12s; white-space: nowrap; }
|
||||
.kfu-btn:hover { background: var(--kfu-bg); border-color: var(--kfu-accent); }
|
||||
.kfu-btn:focus-visible { outline: 2px solid var(--kfu-accent); outline-offset: 1px; }
|
||||
.kfu-btn.primary { background: var(--kfu-accent); color: #fff; border-color: var(--kfu-accent); }
|
||||
.kfu-btn.primary:hover { background: var(--kfu-accent-hover); }
|
||||
.kfu-btn svg { width: 15px; height: 15px; flex-shrink: 0; }
|
||||
|
||||
/* ---------- контент ---------- */
|
||||
.kfu-wrap { max-width: var(--kfu-max); margin: 0 auto; padding: 24px 24px 64px; }
|
||||
|
||||
/* ---------- заголовок ---------- */
|
||||
.kfu-header { display: flex; align-items: flex-end; justify-content: space-between; gap: 16px;
|
||||
margin-bottom: 20px; flex-wrap: wrap; }
|
||||
.kfu-header-left { min-width: 0; }
|
||||
.kfu-header h1 { font-size: 32px; font-weight: 600; color: var(--kfu-ink); letter-spacing: -.025em;
|
||||
line-height: 1.15; margin: 0 0 6px; }
|
||||
.kfu-header-sub { font-size: 15px; color: var(--kfu-ink-2); line-height: 1.4; margin: 0; }
|
||||
.kfu-header-sub b { color: var(--kfu-ink); font-weight: 600; }
|
||||
.kfu-recon-chip { display: inline-flex; align-items: center; gap: 6px; padding: 6px 12px;
|
||||
border-radius: var(--kfu-radius-sm); background: var(--kfu-ok-bg); color: var(--kfu-ok);
|
||||
font-size: 12px; font-weight: 500; white-space: nowrap; flex-shrink: 0; }
|
||||
.kfu-recon-chip svg { width: 14px; height: 14px; }
|
||||
|
||||
/* ---------- сводка (горизонтальная полоса) ---------- */
|
||||
.kfu-brief { display: grid; grid-template-columns: repeat(4, 1fr); gap: 1px;
|
||||
background: var(--kfu-border); border: 1px solid var(--kfu-border); border-radius: var(--kfu-radius);
|
||||
overflow: hidden; margin-bottom: 20px; box-shadow: var(--kfu-shadow); }
|
||||
.kfu-brief-item { background: var(--kfu-surface); padding: 16px 20px; display: flex; align-items: center; gap: 14px; }
|
||||
.kfu-brief-dot { width: 10px; height: 10px; border-radius: 50%; flex-shrink: 0; }
|
||||
.kfu-brief-dot.risk { background: var(--kfu-risk); }
|
||||
.kfu-brief-dot.warn { background: var(--kfu-warn); }
|
||||
.kfu-brief-dot.ok { background: var(--kfu-ok); }
|
||||
.kfu-brief-dot.neutral { background: var(--kfu-neutral); }
|
||||
.kfu-brief-num { font-size: 28px; font-weight: 600; letter-spacing: -.02em; font-variant-numeric: tabular-nums;
|
||||
line-height: 1; color: var(--kfu-ink); }
|
||||
.kfu-brief-num-b { font-size: 13px; font-weight: 500; color: var(--kfu-ink-3); margin-left: 6px; line-height: 1; }
|
||||
.kfu-brief-lbl { font-size: 12px; color: var(--kfu-ink-3); margin-top: 2px; }
|
||||
|
||||
/* ---------- поиск + фильтры ---------- */
|
||||
.kfu-toolbar { display: flex; align-items: center; gap: 12px; margin-bottom: 16px; flex-wrap: wrap; }
|
||||
.kfu-search { position: relative; flex: 0 1 300px; min-width: 200px; }
|
||||
.kfu-search svg { position: absolute; left: 10px; top: 50%; transform: translateY(-50%);
|
||||
width: 15px; height: 15px; color: var(--kfu-ink-3); pointer-events: none; }
|
||||
.kfu-search input { box-sizing: border-box; width: 100%; max-width: 100%; height: 34px; padding: 0 12px 0 32px;
|
||||
border-radius: var(--kfu-radius-sm); border: 1px solid var(--kfu-border-2); background: var(--kfu-surface);
|
||||
font-size: 13px; color: var(--kfu-ink); transition: border-color .12s, box-shadow .12s; }
|
||||
.kfu-search input:focus { outline: none; border-color: var(--kfu-accent); box-shadow: 0 0 0 3px var(--kfu-accent-soft); }
|
||||
.kfu-search input::placeholder { color: var(--kfu-ink-3); }
|
||||
.kfu-chip-filter { display: inline-flex; align-items: center; height: 32px; padding: 0 14px;
|
||||
border-radius: var(--kfu-radius-sm); border: 1px solid var(--kfu-border-2); background: var(--kfu-surface);
|
||||
font-size: 13px; font-weight: 500; color: var(--kfu-ink-2); cursor: pointer;
|
||||
transition: all .12s; white-space: nowrap; }
|
||||
.kfu-chip-filter:hover { border-color: var(--kfu-accent); color: var(--kfu-ink); }
|
||||
.kfu-chip-filter.active { background: var(--kfu-accent); color: #fff; border-color: var(--kfu-accent); }
|
||||
.kfu-table-count { font-size: 13px; color: var(--kfu-ink-3); margin-left: auto; white-space: nowrap; }
|
||||
|
||||
/* ---------- таблица ---------- */
|
||||
.kfu-table-card { background: var(--kfu-surface); border: 1px solid var(--kfu-border);
|
||||
border-radius: var(--kfu-radius); box-shadow: var(--kfu-shadow); overflow-x: auto; }
|
||||
.kfu-table { width: 100%; border-collapse: collapse; table-layout: fixed; }
|
||||
.kfu-table colgroup col:nth-child(1) { width: 26%; }
|
||||
.kfu-table colgroup col:nth-child(2) { width: 12%; }
|
||||
.kfu-table colgroup col:nth-child(3) { width: 10%; }
|
||||
.kfu-table colgroup col:nth-child(4) { width: 10%; }
|
||||
.kfu-table colgroup col:nth-child(5) { width: 10%; }
|
||||
.kfu-table colgroup col:nth-child(6) { width: 32%; }
|
||||
.kfu-table thead th { padding: 10px 14px; font-size: 11px; font-weight: 600; color: var(--kfu-ink-3);
|
||||
text-transform: uppercase; letter-spacing: .5px; border-bottom: 1px solid var(--kfu-border);
|
||||
background: var(--kfu-bg); text-align: right; white-space: nowrap; cursor: pointer; user-select: none;
|
||||
position: sticky; top: 0; z-index: 1; }
|
||||
.kfu-table thead th:last-child { text-align: left; white-space: nowrap; text-transform: none; font-weight: 500; }
|
||||
.kfu-table thead th:first-child { text-align: left; }
|
||||
.kfu-table thead th .th-sub { display: block; font-weight: 400; text-transform: none; letter-spacing: 0;
|
||||
font-size: 10px; color: var(--kfu-ink-3); opacity: .7; margin-top: 1px; }
|
||||
.kfu-table thead th.sorted-asc::after { content: " ↑"; color: var(--kfu-accent); }
|
||||
.kfu-table thead th.sorted-desc::after { content: " ↓"; color: var(--kfu-accent); }
|
||||
.kfu-table tbody tr { cursor: pointer; transition: background .1s; }
|
||||
.kfu-table tbody tr:hover { background: var(--kfu-bg); }
|
||||
.kfu-table tbody tr.is-group { background: var(--kfu-neutral-bg); }
|
||||
.kfu-table tbody tr.is-group:hover { background: #E8EBEF; }
|
||||
.kfu-table td { padding: 12px 14px; border-bottom: 1px solid var(--kfu-border); font-size: 14px;
|
||||
text-align: right; font-variant-numeric: tabular-nums; vertical-align: middle; }
|
||||
.kfu-table td:first-child { text-align: left; }
|
||||
.kfu-table td:first-child .co-name { font-weight: 500; color: var(--kfu-ink); line-height: 1.3;
|
||||
display: -webkit-box; -webkit-line-clamp: 2; -webkit-box-orient: vertical; overflow: hidden; word-break: break-word; }
|
||||
.kfu-table td:first-child .co-sub { font-size: 11px; color: var(--kfu-ink-3); margin-top: 1px; }
|
||||
|
||||
/* метка учебного режима */
|
||||
.kfu-mock-chip { display: inline-flex; align-items: center; gap: 6px; padding: 6px 12px;
|
||||
border-radius: var(--kfu-radius-sm); background: var(--kfu-warn-bg); color: var(--kfu-warn);
|
||||
font-size: 12px; font-weight: 600; letter-spacing: .3px; text-transform: uppercase; white-space: nowrap; flex-shrink: 0; }
|
||||
|
||||
/* статус-метка */
|
||||
.kfu-status { display: inline-flex; align-items: center; gap: 6px; font-size: 12px; font-weight: 500;
|
||||
padding: 3px 10px; border-radius: 5px; white-space: nowrap; }
|
||||
.kfu-status::before { content: ""; width: 6px; height: 6px; border-radius: 50%; background: currentColor; }
|
||||
.kfu-status.s-red { background: var(--kfu-risk-bg); color: var(--kfu-risk); }
|
||||
.kfu-status.s-yellow { background: var(--kfu-warn-bg); color: var(--kfu-warn); }
|
||||
.kfu-status.s-green { background: var(--kfu-ok-bg); color: var(--kfu-ok); }
|
||||
.kfu-status.s-nodefault { background: var(--kfu-neutral-bg); color: var(--kfu-neutral); }
|
||||
.kfu-status.s-nodata { background: var(--kfu-neutral-bg); color: var(--kfu-neutral); }
|
||||
|
||||
/* ячейка коэффициента: число + точка */
|
||||
.kfu-coef { display: inline-flex; align-items: baseline; gap: 6px; }
|
||||
.kfu-coef .cv { font-weight: 500; font-variant-numeric: tabular-nums; }
|
||||
.kfu-coef .cd { width: 6px; height: 6px; border-radius: 50%; align-self: center; flex-shrink: 0; }
|
||||
.kfu-coef .cd.c-green { background: var(--kfu-ok); }
|
||||
.kfu-coef .cd.c-yellow { background: var(--kfu-warn); }
|
||||
.kfu-coef .cd.c-red { background: var(--kfu-risk); }
|
||||
.kfu-coef .cd.c-nodefault { background: var(--kfu-neutral); }
|
||||
.kfu-coef .cd.c-nodata { background: var(--kfu-neutral); opacity: .4; }
|
||||
.kfu-coef .cv.cv-red { color: var(--kfu-risk); }
|
||||
.kfu-coef .cv.cv-yellow { color: var(--kfu-warn); }
|
||||
.kfu-coef .cv-none { color: var(--kfu-ink-3); font-size: 13px; }
|
||||
.kfu-cause { font-size: 13px; color: var(--kfu-ink-2); line-height: 1.45; }
|
||||
.kfu-table td:last-child { text-align: left; }
|
||||
.kfu-cause.caution { color: var(--kfu-warn); font-weight: 500; }
|
||||
.kfu-cause.danger { color: var(--kfu-risk); font-weight: 500; }
|
||||
.kfu-dash { color: var(--kfu-ink-3); }
|
||||
|
||||
/* ---------- блок группы ---------- */
|
||||
.kfu-group-block { margin-top: 16px; background: var(--kfu-surface); border: 1px solid var(--kfu-border);
|
||||
border-radius: var(--kfu-radius); box-shadow: var(--kfu-shadow); overflow: hidden; }
|
||||
.kfu-group-header { padding: 12px 16px; background: var(--kfu-neutral-bg); border-bottom: 1px solid var(--kfu-border);
|
||||
font-size: 12px; font-weight: 600; color: var(--kfu-ink-2); text-transform: uppercase; letter-spacing: .4px; }
|
||||
.kfu-group-block .kfu-table thead { display: none; }
|
||||
.kfu-group-block .kfu-table td:first-child { font-weight: 600; }
|
||||
|
||||
/* ---------- диагностика ---------- */
|
||||
.kfu-diag { margin-top: 20px; background: var(--kfu-surface); border: 1px solid var(--kfu-border);
|
||||
border-radius: var(--kfu-radius); box-shadow: var(--kfu-shadow); }
|
||||
.kfu-diag summary { padding: 12px 16px; font-size: 13px; font-weight: 500; color: var(--kfu-ink-2);
|
||||
cursor: pointer; list-style: none; display: flex; align-items: center; gap: 8px;
|
||||
font-variant-numeric: tabular-nums; }
|
||||
.kfu-diag summary::-webkit-details-marker { display: none; }
|
||||
.kfu-diag summary::before { content: ""; width: 6px; height: 6px; border-radius: 50%; background: var(--kfu-warn); }
|
||||
.kfu-diag[open] summary::before { background: var(--kfu-accent); }
|
||||
.kfu-diag-list { padding: 0 16px 12px; max-height: 200px; overflow-y: auto; list-style: none; margin: 0; }
|
||||
.kfu-diag-list li { padding: 6px 0; border-top: 1px solid var(--kfu-border); font-size: 12px;
|
||||
color: var(--kfu-ink-2); font-family: var(--kt-ai-font-mono); font-variant-numeric: tabular-nums; }
|
||||
.kfu-diag-list li:first-child { border-top: none; }
|
||||
|
||||
/* ---------- первая загрузка ---------- */
|
||||
.kfu-upload { display: flex; flex-direction: column; align-items: center; justify-content: center;
|
||||
text-align: center; padding: 72px 32px; background: var(--kfu-surface); border: 1px solid var(--kfu-border);
|
||||
border-radius: var(--kfu-radius); box-shadow: var(--kfu-shadow); }
|
||||
.kfu-upload-icon { width: 56px; height: 56px; border-radius: 14px; background: var(--kfu-accent-soft);
|
||||
color: var(--kfu-accent); display: flex; align-items: center; justify-content: center; margin-bottom: 20px; }
|
||||
.kfu-upload-icon svg { width: 28px; height: 28px; }
|
||||
.kfu-upload h2 { font-size: 22px; font-weight: 600; color: var(--kfu-ink); letter-spacing: -.015em; margin: 0 0 8px; }
|
||||
.kfu-upload p { font-size: 15px; color: var(--kfu-ink-2); max-width: 400px; line-height: 1.5; margin: 0 0 24px; }
|
||||
.kfu-upload-actions { display: flex; gap: 10px; flex-wrap: wrap; justify-content: center; }
|
||||
.kfu-upload-note { margin-top: 16px; font-size: 12px; color: var(--kfu-ink-3); }
|
||||
|
||||
/* ---------- drawer (карточка компании) ---------- */
|
||||
.kfu-drawer { position: fixed; top: 0; right: 0; bottom: 0; width: 520px; max-width: 94vw;
|
||||
background: var(--kfu-surface); border-left: 1px solid var(--kfu-border); box-shadow: var(--kfu-shadow-md);
|
||||
z-index: 100; padding: 24px; overflow-y: auto; display: none; }
|
||||
.kfu-drawer.open { display: block; animation: kfuSlide .18s ease-out; }
|
||||
@keyframes kfuSlide { from { transform: translateX(24px); opacity: .4; } to { transform: none; opacity: 1; } }
|
||||
.kfu-drawer-head { display: flex; align-items: flex-start; gap: 12px; margin-bottom: 20px; }
|
||||
.kfu-drawer-head h2 { font-size: 22px; font-weight: 600; color: var(--kfu-ink); letter-spacing: -.02em;
|
||||
line-height: 1.2; margin: 0 0 4px; flex: 1; }
|
||||
.kfu-drawer-head .close-btn { margin-left: auto; flex-shrink: 0; width: 32px; height: 32px;
|
||||
border-radius: var(--kfu-radius-sm); border: 1px solid var(--kfu-border); background: var(--kfu-surface);
|
||||
cursor: pointer; display: flex; align-items: center; justify-content: center; color: var(--kfu-ink-2); }
|
||||
.kfu-drawer-head .close-btn:hover { background: var(--kfu-bg); }
|
||||
.kfu-drawer-head .close-btn svg { width: 16px; height: 16px; }
|
||||
.kfu-drawer-section { margin-bottom: 20px; }
|
||||
.kfu-drawer-section h3 { font-size: 11px; font-weight: 600; text-transform: uppercase; letter-spacing: .6px;
|
||||
color: var(--kfu-ink-3); margin: 0 0 10px; }
|
||||
|
||||
/* коэффициент в drawer */
|
||||
.kfu-metrics { display: grid; grid-template-columns: repeat(3, 1fr); gap: 8px; }
|
||||
.kfu-metric { border: 1px solid var(--kfu-border); border-radius: var(--kfu-radius-sm); padding: 12px;
|
||||
background: var(--kfu-bg); }
|
||||
.kfu-metric .mlabel { font-size: 11px; font-weight: 600; color: var(--kfu-ink-3);
|
||||
text-transform: uppercase; letter-spacing: .3px; margin-bottom: 6px; display: flex; align-items: center; gap: 5px; }
|
||||
.kfu-metric .mlabel .sdot { width: 6px; height: 6px; border-radius: 50%; }
|
||||
.kfu-metric .mval { font-size: 22px; font-weight: 600; font-variant-numeric: tabular-nums; letter-spacing: -.01em;
|
||||
color: var(--kfu-ink); }
|
||||
.kfu-metric .mval.mval-red { color: var(--kfu-risk); }
|
||||
.kfu-metric .mval.mval-yellow { color: var(--kfu-warn); }
|
||||
.kfu-metric .mval.mval-none { color: var(--kfu-ink-3); font-size: 14px; font-weight: 500; }
|
||||
.kfu-metric .mhint { font-size: 11px; color: var(--kfu-ink-3); margin-top: 4px; font-variant-numeric: tabular-nums; }
|
||||
.kfu-metric .mwhy { font-size: 11px; color: var(--kfu-warn); margin-top: 3px; font-weight: 500; }
|
||||
.kfu-metric .mformula { font-size: 10px; color: var(--kfu-ink-3); margin-top: 6px;
|
||||
padding-top: 6px; border-top: 1px solid var(--kfu-border); font-family: var(--kt-ai-font-mono); }
|
||||
|
||||
/* исходные данные */
|
||||
.kfu-sources { width: 100%; border-collapse: collapse; font-size: 13px; }
|
||||
.kfu-sources td { padding: 7px 0; border-bottom: 1px solid var(--kfu-border); vertical-align: top; }
|
||||
.kfu-sources td:last-child { text-align: right; font-family: var(--kt-ai-font-mono); font-size: 12px;
|
||||
color: var(--kfu-ink-2); white-space: nowrap; padding-left: 12px; font-variant-numeric: tabular-nums; }
|
||||
.kfu-sources .src-addr { font-size: 11px; color: var(--kfu-ink-3); font-family: var(--kt-ai-font-mono); }
|
||||
|
||||
/* сценарий */
|
||||
.kfu-scenario-lbl { font-size: 13px; color: var(--kfu-ink-2); margin-bottom: 8px; line-height: 1.4; }
|
||||
.kfu-scenario-lbl b { color: var(--kfu-ink); font-weight: 600; font-variant-numeric: tabular-nums; }
|
||||
.kfu-scenario-row { display: flex; gap: 8px; align-items: stretch; }
|
||||
.kfu-scenario-row .kfu-input { flex: 1; font-variant-numeric: tabular-nums; font-size: 14px;
|
||||
height: 38px; border-radius: var(--kfu-radius-sm); padding: 0 12px; border: 1px solid var(--kfu-border-2);
|
||||
background: var(--kfu-surface); color: var(--kfu-ink); }
|
||||
.kfu-scenario-row .kfu-input:focus { outline: none; border-color: var(--kfu-accent); box-shadow: 0 0 0 3px var(--kfu-accent-soft); }
|
||||
.kfu-scenario-hint { font-size: 12px; color: var(--kfu-ink-3); margin-top: 8px; line-height: 1.4; }
|
||||
|
||||
/* раскрываемый раздел источников */
|
||||
.kfu-sources-block summary { font-size: 12px; font-weight: 500; color: var(--kfu-ink-2); cursor: pointer;
|
||||
padding: 8px 0; list-style: none; display: flex; align-items: center; gap: 6px; }
|
||||
.kfu-sources-block summary::-webkit-details-marker { display: none; }
|
||||
.kfu-sources-block summary::after { content: " →"; font-size: 12px; transition: transform .15s; }
|
||||
.kfu-sources-block[open] summary::after { transform: rotate(90deg); }
|
||||
|
||||
/* ---------- toast ---------- */
|
||||
.kfu-toast-stack { position: fixed; bottom: 20px; right: 20px; z-index: 200; display: flex; flex-direction: column; gap: 8px; }
|
||||
.kfu-toast { padding: 10px 16px; border-radius: 8px; background: var(--kfu-ink); color: #fff;
|
||||
font-size: 13px; box-shadow: var(--kfu-shadow-md); display: flex; align-items: center; gap: 8px;
|
||||
animation: kfuToastIn .15s ease-out; max-width: 400px; }
|
||||
.kfu-toast.err { background: var(--kfu-risk); }
|
||||
@keyframes kfuToastIn { from { transform: translateY(8px); opacity: 0; } to { transform: none; opacity: 1; } }
|
||||
|
||||
/* ---------- empty state ---------- */
|
||||
.kfu-empty { padding: 40px 24px; text-align: center; color: var(--kfu-ink-3); font-size: 14px; }
|
||||
.kfu-empty svg { width: 28px; height: 28px; margin-bottom: 8px; opacity: .4; }
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body class="kt-ai-app">
|
||||
|
||||
<!-- верхняя полоса -->
|
||||
<header class="kfu-topbar">
|
||||
<div class="kfu-brand">
|
||||
<div class="kfu-brand-mark">КФУ</div>
|
||||
<div class="kfu-brand-text">
|
||||
<b>Финансовый мониторинг</b>
|
||||
<span>Факт 2025</span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="kfu-topbar-right">
|
||||
<span class="kfu-file-badge" id="fileBadge" hidden>
|
||||
<span class="dot"></span>
|
||||
<span id="fileBadgeText"></span>
|
||||
</span>
|
||||
<button class="kfu-btn" id="btnReplace" hidden>
|
||||
<svg class="kt-icon" aria-hidden="true"><use href="#upload"></use></svg>
|
||||
Заменить файл
|
||||
</button>
|
||||
<button class="kfu-btn primary" id="btnExport" hidden>
|
||||
<svg class="kt-icon" aria-hidden="true"><use href="#download"></use></svg>
|
||||
Выгрузить отчёт
|
||||
</button>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<div class="kfu-wrap">
|
||||
|
||||
<!-- заголовок -->
|
||||
<div class="kfu-header" id="headerBlock">
|
||||
<div class="kfu-header-left">
|
||||
<h1>Финансовая устойчивость</h1>
|
||||
<p class="kfu-header-sub" id="headerSub"></p>
|
||||
</div>
|
||||
<div style="display:flex;gap:8px;align-items:center">
|
||||
<span class="kfu-mock-chip" id="mockChip" hidden>Учебный пример · синтетические данные</span>
|
||||
<span class="kfu-recon-chip" id="reconChip" hidden>
|
||||
<svg class="kt-icon" aria-hidden="true"><use href="#check"></use></svg>
|
||||
<span id="reconChipText"></span>
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- сводка -->
|
||||
<div class="kfu-brief" id="brief" hidden>
|
||||
<div class="kfu-brief-item">
|
||||
<span class="kfu-brief-dot risk"></span>
|
||||
<div><span class="kfu-brief-num" id="briefRed">0</span><div class="kfu-brief-lbl">Нарушения</div></div>
|
||||
</div>
|
||||
<div class="kfu-brief-item">
|
||||
<span class="kfu-brief-dot warn"></span>
|
||||
<div><span class="kfu-brief-num" id="briefYellow">0</span><div class="kfu-brief-lbl">Под наблюдением</div></div>
|
||||
</div>
|
||||
<div class="kfu-brief-item">
|
||||
<span class="kfu-brief-dot ok"></span>
|
||||
<div><span class="kfu-brief-num" id="briefGreen">0</span><span class="kfu-brief-num-b" id="briefGreenSub"></span><div class="kfu-brief-lbl">В норме</div></div>
|
||||
</div>
|
||||
<div class="kfu-brief-item">
|
||||
<span class="kfu-brief-dot neutral"></span>
|
||||
<div><span class="kfu-brief-num" id="briefNoNorm">0</span><div class="kfu-brief-lbl">Без нормативов</div></div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- поиск + фильтры -->
|
||||
<div class="kfu-toolbar" id="toolbar" hidden>
|
||||
<div class="kfu-search">
|
||||
<svg class="kt-icon" aria-hidden="true"><use href="#search"></use></svg>
|
||||
<input type="text" id="q" placeholder="Поиск по названию…">
|
||||
</div>
|
||||
<button class="kfu-chip-filter active" data-zone="" id="zoneAll">Все</button>
|
||||
<button class="kfu-chip-filter" data-zone="red" id="zoneRed">Нарушения</button>
|
||||
<button class="kfu-chip-filter" data-zone="yellow" id="zoneYellow">Под наблюдением</button>
|
||||
<button class="kfu-chip-filter" data-zone="nodefault" id="zoneNoNorm">Без нормативов</button>
|
||||
<span class="kfu-table-count" id="tableCount"></span>
|
||||
</div>
|
||||
|
||||
<!-- таблица компаний -->
|
||||
<div class="kfu-table-card" id="tableWrap" hidden>
|
||||
<table class="kfu-table">
|
||||
<colgroup><col><col><col><col><col><col></colgroup>
|
||||
<thead>
|
||||
<tr>
|
||||
<th data-k="name">Компания</th>
|
||||
<th>Статус</th>
|
||||
<th data-k="k1">К1<span class="th-sub">Debt/EBITDA</span></th>
|
||||
<th data-k="k2">К2<span class="th-sub">EBITDA/Int</span></th>
|
||||
<th data-k="k3">К3<span class="th-sub">Debt/Equity</span></th>
|
||||
<th>Причина</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody id="tbody"></tbody>
|
||||
</table>
|
||||
<div class="kfu-empty" id="emptyState" hidden>
|
||||
<svg class="kt-icon" aria-hidden="true"><use href="#search"></use></svg>
|
||||
<div>Ничего не найдено. Измените фильтры.</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- блок группы -->
|
||||
<div class="kfu-group-block" id="groupBlock" hidden>
|
||||
<div class="kfu-group-header">Консолидированная группа</div>
|
||||
<table class="kfu-table" style="border-radius:0">
|
||||
<colgroup><col><col><col><col><col><col></colgroup>
|
||||
<tbody id="groupTbody"></tbody>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
<!-- первая загрузка -->
|
||||
<div class="kfu-upload" id="uploadPanel">
|
||||
<div class="kfu-upload-icon">
|
||||
<svg class="kt-icon" aria-hidden="true"><use href="#upload"></use></svg>
|
||||
</div>
|
||||
<h2>Загрузите сводную книгу</h2>
|
||||
<p>Получите коэффициенты, зоны риска и отчёт в .xlsx</p>
|
||||
<div class="kfu-upload-actions">
|
||||
<button class="kfu-btn primary" id="btnUpload" style="height:40px;padding:0 24px;font-size:14px">
|
||||
<svg class="kt-icon" aria-hidden="true"><use href="#upload"></use></svg>
|
||||
Загрузить .xlsx
|
||||
</button>
|
||||
<button class="kfu-btn" id="btnSample" style="height:40px;padding:0 20px;font-size:14px">
|
||||
<svg class="kt-icon" aria-hidden="true"><use href="#spreadsheet"></use></svg>
|
||||
Учебный пример
|
||||
</button>
|
||||
</div>
|
||||
<p style="font-size:12px;color:var(--kfu-ink-3);margin:16px 0 0">Файл обрабатывается в браузере и не отправляется на сервер</p>
|
||||
</div>
|
||||
|
||||
<!-- диагностика -->
|
||||
<details class="kfu-diag" id="diagBlock" hidden>
|
||||
<summary id="diagSummary"></summary>
|
||||
<ul class="kfu-diag-list" id="diagList"></ul>
|
||||
</details>
|
||||
|
||||
</div>
|
||||
|
||||
<!-- drawer -->
|
||||
<aside class="kfu-drawer" id="drawer" role="dialog" aria-modal="false" aria-labelledby="drawerTitle">
|
||||
<div id="drawerBody"></div>
|
||||
</aside>
|
||||
|
||||
<div class="kfu-toast-stack" id="toasts"></div>
|
||||
|
||||
<input type="file" id="fileInput" accept=".xlsx,.xls" style="display:none">
|
||||
|
||||
<script src="kfu-client.js"></script>
|
||||
</body>
|
||||
</html>
|
||||
35
package-lock.json
generated
Normal file
35
package-lock.json
generated
Normal file
@ -0,0 +1,35 @@
|
||||
{
|
||||
"name": "kfu-agent",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "kfu-agent",
|
||||
"dependencies": {
|
||||
"pdf-parse": "^1.1.1"
|
||||
}
|
||||
},
|
||||
"node_modules/node-ensure": {
|
||||
"version": "0.0.0",
|
||||
"resolved": "https://registry.npmjs.org/node-ensure/-/node-ensure-0.0.0.tgz",
|
||||
"integrity": "sha512-DRI60hzo2oKN1ma0ckc6nQWlHU69RH6xN0sjQTjMpChPfTYvKZdcQFfdYK2RWbJcKyUizSIy/l8OTGxMAM1QDw==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/pdf-parse": {
|
||||
"version": "1.1.4",
|
||||
"resolved": "https://registry.npmjs.org/pdf-parse/-/pdf-parse-1.1.4.tgz",
|
||||
"integrity": "sha512-XRIRcLgk6ZnUbsHsYXExMw+krrPE81hJ6FQPLdBNhhBefqIQKXu/WeTgNBGSwPrfU0v+UCEwn7AoAUOsVKHFvQ==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"node-ensure": "^0.0.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=6.8.1"
|
||||
},
|
||||
"funding": {
|
||||
"type": "github",
|
||||
"url": "https://github.com/sponsors/mehmet-kozan"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
7
package.json
Normal file
7
package.json
Normal file
@ -0,0 +1,7 @@
|
||||
{
|
||||
"name": "kfu-agent",
|
||||
"private": true,
|
||||
"type": "commonjs",
|
||||
"scripts": { "start": "node server.js" },
|
||||
"dependencies": { "pdf-parse": "^1.1.1" }
|
||||
}
|
||||
160
server.js
Normal file
160
server.js
Normal file
@ -0,0 +1,160 @@
|
||||
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));
|
||||
Loading…
Reference in New Issue
Block a user