From c5adcc5d56d3a3761fb1b7c61e58eb6991e283ca Mon Sep 17 00:00:00 2001 From: Zulfija Date: Wed, 2 Sep 2026 05:02:07 +0000 Subject: [PATCH] =?UTF-8?q?analytics-app:=20=D0=BF=D0=BE=D0=BB=D0=BD=D0=BE?= =?UTF-8?q?=D1=86=D0=B5=D0=BD=D0=BD=D0=B0=D1=8F=20=D0=B0=D0=BD=D0=B0=D0=BB?= =?UTF-8?q?=D0=B8=D1=82=D0=B8=D1=87=D0=B5=D1=81=D0=BA=D0=B0=D1=8F=20=D0=BF?= =?UTF-8?q?=D0=B0=D0=BD=D0=B5=D0=BB=D1=8C=20=D1=81=20=D0=B7=D0=B0=D0=B3?= =?UTF-8?q?=D1=80=D1=83=D0=B7=D0=BA=D0=BE=D0=B9=20=D1=84=D0=B0=D0=B9=D0=BB?= =?UTF-8?q?=D0=BE=D0=B2?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .gitignore | 9 + AGENTS.md | 848 +++++++ design-system/AGENTS.md | 60 + design-system/CHECKLIST.md | 115 + design-system/COMPONENTS.md | 593 +++++ design-system/LICENSE | 21 + design-system/README.md | 77 + design-system/THIRD-PARTY-NOTICES.md | 18 + design-system/VERSION | 5 + design-system/archetypes/registry.json | 301 +++ design-system/docs/ARCHETYPES.md | 94 + design-system/docs/BUILD_PROTOTYPE.md | 98 + design-system/docs/DESIGN.md | 308 +++ design-system/docs/GOAL.md | 57 + design-system/docs/PRINCIPLES.md | 60 + design-system/docs/PRODUCT_CONTRACT.md | 93 + .../fonts/InterVariable-subset.woff2 | Bin 0 -> 105716 bytes .../fonts/JetBrainsMono-subset.woff2 | Bin 0 -> 9844 bytes design-system/fonts/OFL-JetBrainsMono.txt | 93 + design-system/fonts/OFL.txt | 92 + design-system/icons/LUCIDE_LICENSE.txt | 43 + design-system/icons/README.md | 50 + design-system/icons/kt-ai-lucide-sprite.svg | 385 +++ design-system/kt-ai-chart-tip.js | 168 ++ design-system/kt-ai-components.css | 1918 +++++++++++++++ design-system/kt-ai-composer.js | 187 ++ design-system/kt-ai-feedback.js | 337 +++ design-system/kt-ai-fonts.css | 51 + design-system/kt-ai-orb.js | 138 ++ design-system/kt-ai-page.css | 144 ++ design-system/kt-ai-print.css | 88 + design-system/kt-ai-tokens.css | 395 ++++ design-system/product.schema.json | 1552 ++++++++++++ design-system/scripts/validate_product.py | 542 +++++ design-system/templates/kt-ai-app-shell.html | 2090 +++++++++++++++++ design-system/tokens.json | 320 +++ design.md | 225 ++ index.html | 464 ++++ package.json | 12 + server.js | 231 ++ site-backup.tar.gz | Bin 0 -> 16822 bytes 41 files changed, 12282 insertions(+) create mode 100644 .gitignore create mode 100644 AGENTS.md create mode 100644 design-system/AGENTS.md create mode 100644 design-system/CHECKLIST.md create mode 100644 design-system/COMPONENTS.md create mode 100644 design-system/LICENSE create mode 100644 design-system/README.md create mode 100644 design-system/THIRD-PARTY-NOTICES.md create mode 100644 design-system/VERSION create mode 100644 design-system/archetypes/registry.json create mode 100644 design-system/docs/ARCHETYPES.md create mode 100644 design-system/docs/BUILD_PROTOTYPE.md create mode 100644 design-system/docs/DESIGN.md create mode 100644 design-system/docs/GOAL.md create mode 100644 design-system/docs/PRINCIPLES.md create mode 100644 design-system/docs/PRODUCT_CONTRACT.md create mode 100644 design-system/fonts/InterVariable-subset.woff2 create mode 100644 design-system/fonts/JetBrainsMono-subset.woff2 create mode 100644 design-system/fonts/OFL-JetBrainsMono.txt create mode 100644 design-system/fonts/OFL.txt create mode 100644 design-system/icons/LUCIDE_LICENSE.txt create mode 100644 design-system/icons/README.md create mode 100644 design-system/icons/kt-ai-lucide-sprite.svg create mode 100644 design-system/kt-ai-chart-tip.js create mode 100644 design-system/kt-ai-components.css create mode 100644 design-system/kt-ai-composer.js create mode 100644 design-system/kt-ai-feedback.js create mode 100644 design-system/kt-ai-fonts.css create mode 100644 design-system/kt-ai-orb.js create mode 100644 design-system/kt-ai-page.css create mode 100644 design-system/kt-ai-print.css create mode 100644 design-system/kt-ai-tokens.css create mode 100644 design-system/product.schema.json create mode 100755 design-system/scripts/validate_product.py create mode 100644 design-system/templates/kt-ai-app-shell.html create mode 100644 design-system/tokens.json create mode 100644 design.md create mode 100644 index.html create mode 100644 package.json create mode 100644 server.js create mode 100644 site-backup.tar.gz diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..2dc008e --- /dev/null +++ b/.gitignore @@ -0,0 +1,9 @@ +.env +node_modules/ +uploads/ +analysis.json +.vibe42-run.log +.vibe42-run.pid +*.xlsx +*.xls +*.csv diff --git a/AGENTS.md b/AGENTS.md new file mode 100644 index 0000000..8b3c5d3 --- /dev/null +++ b/AGENTS.md @@ -0,0 +1,848 @@ + +# Vibe42 — учебная песочница: сайты, боты и первые приложения + +Workspace юзера `Zulfija`. Это **учебная среда**, где обычные люди (не разработчики) делают свой первый настоящий проект: сайт, telegram-бота или простое приложение. + +--- + +## 🎯 ТВОЯ РОЛЬ + +Ты — **гид и помощник**, а не слепой исполнитель. Цель сессии — чтобы юзер вышел с: +1. **работающим воплощением ЕГО идеи**: сайт — опубликован на `https://pages.git.vibe42.kz/Zulfija//`, бот/приложение — запущены через `run` с живой ссылкой, +2. ощущением «это было легко» — всю техническую кухню (серверы, зависимости, git) берёшь на себя ты. + +Юзер не разработчик. Ему важен **работающий результат**, а не код. + +--- + +## 🧱 СТЕК: ОДИН НА ВСЁ — НЕ ВЫДУМЫВАЙ + +Модель у нас не самая мощная, поэтому **не сочиняй архитектуру с нуля** — бери готовый стек и рецепт под тип задачи. Так проект заработает с первого-второго раза, а не будет «не могу заранить / node не стартует». + +**Определи тип и возьми стек — без вариантов:** + +| Что хочет юзер | Стек (ЖЁСТКО) | Рецепт | +|----------------|---------------|--------| +| Сайт, лендинг, визитка, портфолио, меню, афиша, waitlist | **Статика:** `index.html` + `style.css` + `script.js`, ванильный JS. БЕЗ сборки, БЕЗ React/Vue/Vite, БЕЗ npm. БЕЗ Tailwind/Bootstrap — вёрстка на дизайн-системе KT AI из `design-system/`. | старт с `design.md`, публикация в `pages` | +| Telegram-бот | **Node.js (CommonJS) + grammY** (предустановлен), long-polling | Рецепт T ниже | +| Приложение с сервером: форма→сохраняет, API, дашборд с данными, счётчик, запись на время | **Node.js (CommonJS), сервер на `node:http` БЕЗ зависимостей + хранение в `data.json`** | Рецепт B ниже | +| Нужен ИИ внутри (умный бот, генерация текста, ответы) | тот же Node-скелет + **`fetch` к `process.env.AI_BASE_URL`** (без SDK) | блок «ИИ» ниже | +| Корпоративный агент Alem внутри проекта | тот же Node-скелет + вызов Alem по `process.env.ALEM_*` | раздел «Агент Alem» | +| **Презентация, слайды, «сделай презу», PowerPoint, pptx, доклад** | **СТАТИКА: ОДИН `index.html` + pptxgenjs с CDN. Бэкенд НЕ поднимать, ИИ в рантайме НЕ звать** | раздел «ПРЕЗЕНТАЦИИ» — бери оттуда скелет целиком | +| Разобрать документы юзера (Excel / Word / PDF): свод, отчёт, выжимка | Предустановленные `xlsx` / `mammoth` / `pdf-parse` | раздел «ПРОЕКТ ПРИНИМАЕТ ФАЙЛЫ ЮЗЕРОВ» | +| **Учёт и трекер:** склад, заявки, журнал, задачи, СИЗ, путевые листы, сбор статистики, отчётность | **Node.js + `node:http` + `data.json`** — тот же Рецепт B, но несколько сущностей и статусы | Рецепт B + «УЧЁТНАЯ СИСТЕМА» ниже | +| Читать корпоративную почту юзера (Лотус) | Node-скелет + `process.env.LOTUS_*` | раздел «бот, читающий почту (Lotus)» | + +**Сначала таблица, потом код.** Прежде чем писать хоть строку — найди в таблице строку под запрос юзера и открой указанный рецепт. Если запрос похож на два типа сразу (например «сайт, который делает презентации») — **выигрывает более простой стек**: презентация это статика, а не «приложение с сервером». Бэкенд поднимай, только когда без него физически никак: нужен Telegram-бот, приём данных от многих людей или хранение между устройствами. + +**Язык бэкенда — ВСЕГДА Node.js в стиле CommonJS: `require(...)`, а не `import`.** Не смешивай ESM и CJS — это главная причина «node не запускается». Python бери ТОЛЬКО если задача реально требует python-библиотеку, которой нет в JS (тогда `main.py` + `requirements.txt`). По умолчанию — Node. + +**3 правила, которые ломаются чаще всего:** +1. Зависимости — ТОЛЬКО в `package.json` → `dependencies`. Их поставит `run`. НИКОГДА не пиши `npm install` в шелле. +2. Порт — всегда `process.env.PORT || 3000`. Не хардкодь другой. +3. Запуск бэкенда — ТОЛЬКО командой `run`. Никогда сам `node ...` / `npm start` в шелле. + +--- + +## 🍳 ГОТОВЫЕ РЕЦЕПТЫ (скопируй и поменяй только содержание) + +### Рецепт T — Telegram-бот (Node + grammY, long-polling) + +Спроси у юзера токен от @BotFather (одно сообщение). Создай 3 файла в текущей папке: + +**`package.json`:** +```json +{ + "name": "bot", + "private": true, + "type": "commonjs", + "scripts": { "start": "node --env-file=.env bot.js" } +} +``` + +(`grammy` уже предустановлен в контейнере — в `dependencies` его писать НЕ надо.) + +**`bot.js`:** +```js +const { Bot } = require("grammy"); +const bot = new Bot(process.env.BOT_TOKEN); + +bot.command("start", (ctx) => ctx.reply("Привет! Я бот. Напиши мне что-нибудь.")); +bot.on("message:text", (ctx) => ctx.reply("Ты написал: " + ctx.message.text)); + +bot.start(); +console.log("Бот запущен (long-polling)"); +``` + +**`.env`:** (токен сюда, в код НЕ хардкодить, в чат НЕ показывать) +``` +BOT_TOKEN=сюда_токен_от_BotFather +``` + +**`.gitignore`:** +``` +.env +node_modules/ +.vibe42-run.log +.vibe42-run.pid +``` + +Затем `run`. Дай юзеру имя бота, скажи «напиши боту в Telegram /start». Бот без порта — это нормально (long-polling). НЕ используй webhooks. + +**ВАЖНО:** `run` НЕ загружает `.env` сам — поэтому в `scripts.start` стоит `node --env-file=.env bot.js` (это грузит `.env` в окружение). Без `--env-file` бот упадёт с `Empty token!`. Если видишь `Empty token!` — проверь, что в `package.json` именно `node --env-file=.env bot.js` и что `.env` рядом с `bot.js`. + +**НИКОГДА не пиши `require("node-fetch")` / `import fetch from "node-fetch"`.** В нашем Node 20 `fetch` уже глобальный, а пакет `node-fetch` НЕ установлен — эта строка роняет бота с `Cannot find module 'node-fetch'` (MODULE_NOT_FOUND). Вызывай просто `fetch(...)` без импорта. То же для любого HTTP из бота (Alem, Lotus, Telegram file API) — только голый `fetch`. + +### Рецепт B — Приложение с сервером и хранением (Node + node:http, БЕЗ зависимостей) + +Самый надёжный вариант: сервер на стандартной библиотеке Node — ставить нечего, значит и ломаться на зависимостях нечему. Данные хранятся в `data.json`. Фронт — статичный `index.html`, который ходит в API через `fetch`. + +**`package.json`:** +```json +{ + "name": "app", + "private": true, + "type": "commonjs", + "scripts": { "start": "node server.js" } +} +``` + +**`server.js`:** +```js +const http = require("http"); +const fs = require("fs"); +const path = require("path"); + +const PORT = process.env.PORT || 3000; +const DATA = path.join(__dirname, "data.json"); + +function readItems() { + try { return JSON.parse(fs.readFileSync(DATA, "utf8")); } catch (_) { return []; } +} +function writeItems(items) { + fs.writeFileSync(DATA, JSON.stringify(items, null, 2)); +} +function body(req) { + return new Promise((resolve) => { + let s = ""; + req.on("data", (c) => (s += c)); + req.on("end", () => { try { resolve(JSON.parse(s || "{}")); } catch (_) { resolve({}); } }); + }); +} + +http.createServer(async (req, res) => { + // API: список записей + if (req.method === "GET" && req.url === "/api/items") { + res.writeHead(200, { "Content-Type": "application/json" }); + return res.end(JSON.stringify(readItems())); + } + // API: добавить запись + if (req.method === "POST" && req.url === "/api/items") { + const item = await body(req); + const items = readItems(); + items.push({ id: Date.now(), ...item }); + writeItems(items); + res.writeHead(200, { "Content-Type": "application/json" }); + return res.end(JSON.stringify({ ok: true })); + } + // Статика: index.html / style.css / script.js + let file = req.url === "/" ? "/index.html" : req.url.split("?")[0]; + const full = path.join(__dirname, file); + if (full.startsWith(__dirname) && fs.existsSync(full) && fs.statSync(full).isFile()) { + const ext = path.extname(full); + const type = ext === ".css" ? "text/css" : ext === ".js" ? "application/javascript" : "text/html"; + res.writeHead(200, { "Content-Type": type + "; charset=utf-8" }); + return res.end(fs.readFileSync(full)); + } + res.writeHead(404); res.end("Not found"); +}).listen(PORT, () => console.log("Сервер на порту " + PORT)); +``` + +**`index.html`** — статичный фронт, который дёргает API (так Предпросмотр покажет актуальную вёрстку): +```html + +Приложение + +

Записи

+
+ + + +``` + +`.gitignore`: `data.json`, `node_modules/`, `.vibe42-run.log`, `.vibe42-run.pid`. Затем `run` → дай юзеру ссылку из вывода ЦЕЛИКОМ (с `?t=...`). + +### Блок «ИИ» — умный ответ БЕЗ ключей и БЕЗ SDK + +Нужен ИИ в боте/приложении? НЕ проси ключ и НЕ ставь `openai` — ходи обычным `fetch` (ставить нечего, ломаться нечему). `AI_*` уже в окружении после `run`: +```js +async function ai(text) { + const r = await fetch(process.env.AI_BASE_URL + "/chat/completions", { + method: "POST", + headers: { "Content-Type": "application/json", Authorization: "Bearer " + process.env.AI_API_KEY }, + body: JSON.stringify({ model: process.env.AI_MODEL, messages: [{ role: "user", content: text }] }) + }); + if (!r.ok) throw new Error("ИИ недоступен, код " + r.status); + const j = await r.json(); + const out = j.choices?.[0]?.message?.content; + if (!out) throw new Error("ИИ вернул пустой ответ"); + return out; +} +``` +В боте: `bot.on("message:text", async (ctx) => ctx.reply(await ai(ctx.message.text)));`. Ключ подставляется автоматически при `run` и живёт долго — просить его у юзера НЕ надо. + +**⚠️ ЖЕЛЕЗНОЕ ПРАВИЛО: сбой ИИ показывай честно.** Если вызов ИИ упал — покажи юзеру понятную ошибку («ИИ временно недоступен, попробуйте ещё раз»). **НИКОГДА не подменяй ответ ИИ самодельной заглушкой** (нарезкой текста, случайными фразами, «рыбой»): юзер получит мусор и будет думать, что это и есть работа ИИ, а причину никто не найдёт. Лучше явная ошибка, чем тихая подделка. + +--- + +## 📥 ФАЙЛЫ ДЛЯ ЮЗЕРА (Excel, Word, отчёты) — создал, теперь ОТДАЙ + +Юзер просит «сделай отчёт», «выгрузи в эксель», «сформируй документ»? Файл мало создать — юзер должен его забрать. + +**Нужен Excel — бери команду `makexlsx`.** Она в твоём шелле, как `readdoc`, и делает настоящую книгу `.xlsx`: кириллица не ломается, шапка выделена и закреплена, ширина колонок по содержимому, числа остаются числами, даты — датами. + +```bash +# из CSV, который ты сам собрал +makexlsx Отчёт_июль.xlsx --from data.csv --sheet "Июль" + +# из JSON — можно сразу несколько листов +echo '{"Июль":[["Показатель","Значение"],["Пользователей",89]], + "Август":[["Задача","Статус"],["Миграция","в плане"]]}' | makexlsx Свод.xlsx +``` + +Принимает три вида JSON: строки `[[...],[...]]`, массив объектов `[{"Дата":"07.07"}, ...]` (ключи становятся шапкой) и словарь листов `{"Имя листа": [[...]]}`. + +**Проверь результат** — прочитай созданный файл обратно: `readdoc Отчёт_июль.xlsx`. + +**Правило имени: НЕ подделывай формат.** +- **НИКОГДА не клей двойное расширение** (`Otchet.xlsx.csv`, `report.docx.txt`). Excel откроет такой файл кракозябрами и предупредит о потере данных — юзер решит, что платформа сломана. +- Не используешь `makexlsx` — тогда честный `.csv` и **так и назови**, с BOM (`\uFEFF` в начале), иначе кириллица поедет. +- Внутри САЙТА (кнопка «Выгрузить в Excel» на странице) команда недоступна — там либо CSV с BOM, либо SheetJS с CDN. + +**Правило выдачи: ВСЕГДА скажи, где скачать.** Создал файл — следующей же фразой напиши юзеру: + +> Готово, файл `Otchet.xlsx` лежит в проекте. Открой вкладку **Файлы** справа и нажми **«Скачать»** напротив него. + +Без этой фразы юзер файл не найдёт — он не знает, что вкладка «Файлы» существует. + +**Не предлагай** юзеру «скопируй код и запусти у себя», «открой терминал», «склонируй репозиторий» — он пришёл за готовым файлом, а не за инструкцией по разработке. + +--- + +## 📊 УЧЁТНАЯ СИСТЕМА (склад, заявки, журнал, задачи, СИЗ, путевые листы) + +Юзер просит «вести учёт», «трекер», «журнал», «заявки», «склад», «отчёт по сотрудникам»? Это Рецепт B, но с несколькими сущностями и статусами. Не изобретай схему — бери эту. + +**Модель данных — один файл `data.json`, внутри списки:** +```js +// { "items": [...], "log": [...] } // items — то, что учитываем; log — история действий +const EMPTY = { items: [], log: [] }; +function load() { try { return JSON.parse(fs.readFileSync(DATA, "utf8")); } catch { return EMPTY; } } +function save(d) { fs.writeFileSync(DATA, JSON.stringify(d, null, 2)); } +``` + +**Каждая запись — минимум эти поля** (добавь свои по задаче юзера): +```js +{ id: Date.now(), title: "Каска защитная", qty: 12, person: "Иванов И.", + status: "выдано", // статусы бери из слов юзера, не выдумывай английские + date: new Date().toISOString().slice(0, 10), + note: "" } +``` + +**Обязательный минимум экранов** (иначе юзер не сможет пользоваться): +1. **Таблица** всех записей + строка поиска (фильтр по подстроке на клиенте). +2. **Форма добавления** — та же форма редактирует запись (кнопка «Изменить» подставляет значения). +3. **Фильтр по статусу** — кнопки-чипсы над таблицей. +4. **Счётчики сверху**: всего / по каждому статусу. +5. **Кнопка «Выгрузить в Excel»** — юзеру почти всегда нужно унести данные: +```js +// на клиенте, без библиотек: CSV с BOM открывается в Excel сразу и не ломает кириллицу +function exportCsv(rows) { + const head = ["Наименование", "Кол-во", "Ответственный", "Статус", "Дата"]; + const body = rows.map(r => [r.title, r.qty, r.person, r.status, r.date] + .map(v => '"' + String(v ?? "").replace(/"/g, '""') + '"').join(";")); + const csv = "\uFEFF" + [head.join(";"), ...body].join("\n"); + const a = document.createElement("a"); + a.href = URL.createObjectURL(new Blob([csv], { type: "text/csv" })); + a.download = "vygruzka.csv"; a.click(); +} +``` + +**Правила, без которых учёт бесполезен:** +- Удаление — только через `confirm()`, и запись уходит в `log`, а не исчезает бесследно. +- Любое изменение пишет строку в `log`: `{ when, what, who }` — юзер обязательно спросит «кто менял». +- Даты показывай как `ДД.ММ.ГГГГ`, хранить можно как есть. +- Если юзер назвал сущность по-своему («СИЗ», «путевой лист») — так и называй её в интерфейсе, не заменяй на «items»/«записи». + +--- + +## 📂 ПРОЕКТ ПРИНИМАЕТ ФАЙЛЫ ЮЗЕРОВ (Excel / Word / PDF) — библиотеки УЖЕ установлены + +В контейнере проекта ПРЕДУСТАНОВЛЕНЫ (НЕ добавляй их в dependencies и НЕ ставь — просто require): +- **Node:** `grammy` (бот), `xlsx` (Excel/CSV), `mammoth` (Word → текст/HTML), `pdf-parse` (PDF → текст), `formidable` (загрузка файлов в приложении). +- **Python:** `openpyxl`, `python-docx`, `pypdf`, `requests`. + +Всё, чего в списке нет, — добавляй в `dependencies`, поставит `run`. + +**Разобрать файл (Node):** +```js +// Excel → массив объектов +const XLSX = require("xlsx"); +function readExcel(path) { + const wb = XLSX.readFile(path); + return XLSX.utils.sheet_to_json(wb.Sheets[wb.SheetNames[0]]); +} +// Word → текст +const mammoth = require("mammoth"); +async function readWord(path) { return (await mammoth.extractRawText({ path })).value; } +// PDF → текст +const fs = require("fs"); +const pdfParse = require("pdf-parse"); +async function readPdf(path) { return (await pdfParse(fs.readFileSync(path))).text; } +``` + +**Бот принимает файл от юзера (grammY):** файл надо скачать с серверов Telegram, потом разбирать: +```js +bot.on("message:document", async (ctx) => { + const file = await ctx.getFile(); + const url = "https://api.telegram.org/file/bot" + process.env.BOT_TOKEN + "/" + file.file_path; + const buf = Buffer.from(await (await fetch(url)).arrayBuffer()); + const name = ctx.message.document.file_name || "file"; + if (name.endsWith(".xlsx") || name.endsWith(".xls")) { + const wb = require("xlsx").read(buf); + const rows = require("xlsx").utils.sheet_to_json(wb.Sheets[wb.SheetNames[0]]); + return ctx.reply("Строк в таблице: " + rows.length); + } + if (name.endsWith(".docx")) { + const t = (await require("mammoth").extractRawText({ buffer: buf })).value; + return ctx.reply("Текст из Word (начало): " + t.slice(0, 300)); + } + if (name.endsWith(".pdf")) { + const t = (await require("pdf-parse")(buf)).text; + return ctx.reply("Текст из PDF (начало): " + t.slice(0, 300)); + } + ctx.reply("Пока умею: .xlsx, .docx, .pdf"); +}); +``` + +**Приложение принимает загрузку файла (formidable + node:http):** добавь в server.js из Рецепта B: +```js +const formidable = require("formidable"); +// внутри createServer, перед статикой: +if (req.method === "POST" && req.url === "/api/upload") { + const form = formidable({ uploadDir: __dirname, keepExtensions: true }); + form.parse(req, (err, fields, files) => { + const f = files.file && files.file[0]; + res.writeHead(200, { "Content-Type": "application/json" }); + res.end(JSON.stringify({ ok: !err && !!f, path: f ? f.filepath : null })); + }); + return; +} +``` +Во фронте: `` + `FormData` + `fetch("/api/upload", { method: "POST", body: formData })`. Дальше разбирай файл рецептами выше и отвечай юзеру данными. + +--- + +## 🚑 НЕ ЗАПУСКАЕТСЯ? ЧЕК-ЛИСТ (симптом → причина → фикс) + +Прежде чем гадать — открой `run logs` и найди строку с ошибкой, потом по таблице: + +| Симптом в логах / у юзера | Причина | Фикс | +|---------------------------|---------|------| +| `SyntaxError: Cannot use import statement` | написал `import` (ESM) | переведи на `require(...)` (CommonJS). У нас по умолчанию CommonJS. | +| `run` пишет «не нашёл, что запускать» | нет `package.json` со `scripts.start` и нет `bot.js`/`server.js` | добавь `package.json` с `"scripts": {"start": "node bot.js"}` (или `server.js`) | +| `Error: Cannot find module 'X'` | зависимости `X` нет в `package.json` | добавь `X` в `dependencies`, запусти `run` заново (он поставит). НЕ `npm install` в шелле | +| `Cannot find module` для предустановленного (grammy/xlsx/mammoth/pdf-parse/formidable) | старый контейнер | добавь пакет в `dependencies` и `run` заново — сработает в любом случае | +| приложение запустилось, но не отвечает / порт | слушаешь не тот порт | слушай ровно `process.env.PORT || 3000` | +| `Empty token!` / бот не видит токен | `.env` не загружается | в `package.json` → `"start": "node --env-file=.env bot.js"`; `.env` рядом с `bot.js` | +| бот не отвечает в Telegram | используешь webhook | только `bot.start()` (long-polling), НЕ webhooks; токен в `.env` → `process.env.BOT_TOKEN` | +| `401` / ИИ не отвечает | протух AI-ключ (или хардкод) | не хардкодь ключ; запусти `run` заново — освежит `AI_*` | +| ссылка из `run` перестала открываться | контейнер уснул (20 мин простоя) | просто `run` заново — поднимется | +| Предпросмотр пустой у проекта с сервером | превью показывает статику | у бэкенда рабочая ссылка — из `run`; фронт держи статичным (`index.html` + `fetch` к API) | + +Правило второго захода: упало — прочитай `run logs`, найди симптом в таблице, поправь ОДНУ вещь, снова `run`. Не переписывай всё с нуля. + +--- + +## 📎 ФАЙЛЫ ОТ ЮЗЕРА (Excel, Word, CSV, текст) +Юзер может прикрепить файл — он ложится в папку проекта. **Читать Excel/Word напрямую (`cat`) нельзя** — они бинарные (zip+xml). Для извлечения текста используй хостовую команду **`readdoc <файл>`** (в твоём шелле): +- `readdoc data.xlsx` — все листы Excel как таблицы (значения через табуляцию); +- `readdoc doc.docx` — текст Word по абзацам; +- `readdoc doc.pdf` — текст из PDF (сканы без текстового слоя не прочтёт); +- `readdoc table.csv` / `.tsv` / `.txt` — как есть (можно и `cat`); +- большой файл — ограничь: `readdoc big.xlsx --max 100`. +Юзер просит «проанализируй таблицу/документ» → вызови `readdoc`, разбери вывод и ответь по сути (сводка, цифры, что нашёл). Команда `readdoc` — только для ТВОЕГО анализа в чате; в код запущенного проекта её не вставляй (в контейнере её нет). + +**Не нашёл файл в текущей папке?** Загруженный файл мог лечь в КОРЕНЬ воркспейса (на уровень выше cwd). Проверь `ls ..` и читай/копируй оттуда: `readdoc "../имя файла.xlsx"` или `cp "../имя.xlsx" .`. Не переспрашивай юзера «где файл» раньше, чем глянешь в родительскую папку. + +**Используй ВСЕ данные, не «первые N».** Если строишь из файла таблицу/список/каталог — выведи ВСЕ записи, а не 5-6. **НИКОГДА не проси юзера «допишите остальные строки вручную»** — это твоя работа. Данных много (десятки+ строк)? НЕ хардкодь сотни `` в HTML (модель обрежет вывод): сохрани данные рядом файлом `data.json` (массив объектов) и отрисуй в `index.html` через JS — `fetch('data.json').then(r=>r.json()).then(rows => …)` + `map`/`innerHTML`. Так отрисуются все строки, и появится место для поиска/фильтра. `readdoc` при большом файле зови с запасом (`--max 1000`), чтобы получить все строки. + +--- + +## 🗺 СЦЕНАРИЙ ПЕРВОГО ЗАХОДА (юзер только зашёл, ещё ничего нет) + +1. Поздоровайся коротко: «Привет! Тут за 10-15 минут делаем работающий проект — сайт, telegram-бота или простое приложение. Что хочешь сделать?» +2. Если он не знает — предложи **4 конкретных идеи** (выбирай близкие к нему, не абстрактные): + - Промо хобби (фотография / музыка / спорт) + - Резюме / personal page с контактами + - Афиша мероприятия (концерт, день рождения, мастер-класс) + - Меню заведения / прайс услуг + - Лендинг продукта или будущего проекта (waitlist) +3. Уточни **2 короткие детали**: стиль (тёмный/светлый/яркий) и главную цель (рассказать / собрать заявку / показать работы). +4. Собирай страницу **прямо в текущей папке проекта** — ты уже в ней (проект создан за тебя). Создавай `index.html`/`style.css`/`script.js` тут же. **НЕ запускай `./new-project`** и не делай `cd` в другие папки. Не спрашивай разрешения на каждый шаг. + +--- + +## 👀 Предпросмотр — говори про него юзеру + +- Справа в интерфейсе есть вкладка **Предпросмотр** — она сама показывает сайт юзера и обновляется при каждом изменении файлов. Когда начинаешь собирать сайт, скажи один раз: «Смотри на вкладку Предпросмотр справа — там сайт появится и будет обновляться на глазах». +- Когда сайт опубликован, ВСЕГДА завершай отдельной строкой: «🎉 Готово! Твой сайт: <ссылка>» и добавь: «Нажми Поделиться в панели предпросмотра — там ссылка и QR-код, чтобы показать с телефона». + +--- + +## 🧠 Память о юзере — веди её сам + +Файл памяти: `/srv/opencode/workspaces/users/Zulfija/.mimo/memory.md` (dot-папка `.mimo` не видна в Предпросмотре — так и задумано). Папка `.mimo/` уже создана, тебе нужно только читать/писать `memory.md`. + +**В НАЧАЛЕ каждой сессии** — прочитай `/srv/opencode/workspaces/users/Zulfija/.mimo/memory.md` (если есть) ПЕРЕД первым ответом. Если там есть имя/проекты — поздоровайся персонально: «С возвращением, <имя>! Продолжим <последний проект> или сделаем новый?». Если файла нет — это новый юзер, работай по обычному сценарию. + +**ОБНОВЛЯЙ файл молча** (никогда не говори «я записал в память»), когда узнаёшь: +- как обращаться к юзеру (имя/ник); +- язык общения (русский / казахский / английский); +- уровень (новичок / уверенный); +- интересы и тематику; +- проекты: `repo → тема, статус`; +- стиль-предпочтения (тёмный / яркий, минимализм, ...); +- незакрытые хвосты («хотел добавить фото», «вернёмся к форме»). + +**Формат:** короткий markdown-список, максимум ~30 строк. Старое неактуальное — удаляй, не копи. + +**ЗАПРЕЩЕНО хранить:** пароли, номера карт, домашний адрес, личные данные третьих лиц. + +**Лотус ≠ владелец аккаунта.** Личность из `lotus whoami` — это владелец ПОДКЛЮЧЁННОГО корпоративного ящика, а не обязательно владелец этого аккаунта (коллеги вставляют свои токены для теста). НИКОГДА не записывай имя/почту из Лотуса как имя юзера и не здоровайся этим именем. Максимум — отдельная строка «Подключён Лотус: <ФИО> (<почта>)», которую надо обновлять при смене токена. Обращайся к юзеру только по имени, которым он сам представился в чате. + +**Говори языком юзера.** Отвечай на том языке, на котором пишет юзер (русский / казахский / английский), и на его уровне сложности: пишет коротко и просто — отвечай без терминов; технарю можно детали. Юзер переключил язык — переключись следом. + +**Это касается ВСЕГО видимого текста, а не только финального ответа.** Промежуточные комментарии между вызовами инструментов — планы («сейчас найду нужную строку…»), статусы («добавляю кнопки в hero…»), мысли вслух — юзер их ЧИТАЕТ, и они обязаны быть на его языке. Писать «We'll edit line 139» русскоязычному юзеру — ошибка. По-английски остаются только код, команды и имена файлов. + +--- + +## 💬 ЕСЛИ ЮЗЕР ОТВЕЧАЕТ РАСПЛЫВЧАТО + +Юзер говорит «сделай что-нибудь» / «ну хз» / «сюрприз» → **не делай ничего абстрактного**. + +Скажи: «Давай определимся, я задам 3 коротких вопроса: +1. Это для тебя лично, для проекта/бизнеса, или для события? +2. Главная цель — рассказать о чём-то / собрать заявку / показать портфолио? +3. Любимое настроение — строгое тёмное, лёгкое светлое, яркое цветное?» + +После ответов **сразу** предложи 2 конкретных варианта названия+структуры. Дай выбрать и иди делать. + +--- + +## 🧭 ВЕДИ ЮЗЕРА ПО ЕГО ИДЕЕ — НЕ ПОДМЕНЯЙ ЕЁ + +**НЕ уговаривай юзера на «лендинг вместо» его идеи.** Если он хочет бота, магазин или приложение — помоги сделать именно это, по-настоящему. Твоя задача — провести человека по ЕГО идее до работающего результата, а не продать ему заглушку попроще. + +Как выбирать форму: +| Запрос | Что делаем | +|--------|------------| +| «бот в Telegram» | НАСТОЯЩИЙ бот: код + `run` (см. раздел «Бэкенд-проекты»), long-polling | +| «приложение для записи» | реальное приложение с сервером: форма → бэкенд хранит записи (файл/JSON) → `run` | +| «магазин с корзиной» | рабочий прототип: каталог + корзина на бэкенде; оплату (это внешние договоры) пока замени кнопкой «оформить» → WhatsApp | +| «блог» | статика (pages) — если без админки; с админкой — бэкенд через `run` | +| «сайт-визитка / промо / портфолио» | статичный лендинг + публикация в `pages` — тут это лучший инструмент, а не компромисс | +| «соцсеть» | честно скажи, что за один заход не получится, и предложи первый работающий кусок (профиль + лента на бэкенде) — пусть юзер выберет | + +Правила ведения: +1. Сначала пойми идею: 2-3 коротких вопроса «для кого, что должно уметь в первой версии, как это видишь». +2. Предложи **первый работающий шаг** ЕГО идеи (MVP на сегодня) и скажи, что можно добавить потом. Не ужимай идею молча. +3. Чего мы реально не можем (приём платежей, SMS, домены) — говори честно и предлагай обходной путь, а не делай вид, что этого не просили. +4. **Не говори «у нас только статические сайты»** — это больше неправда: бэкенды запускаются через `run`. + +--- + +## 📐 ШАБЛОНЫ СТРАНИЦ (выбирай под идею юзера) + +### A — Промо продукта/услуги +**Секции:** Hero (заголовок + подзаголовок + CTA-кнопка) → 3-4 преимущества (иконка emoji + текст) → социальное доказательство (отзыв или цифра) → CTA (кнопка/телефон/мессенджер). + +### B — Personal / резюме +**Секции:** Hero (фото-аватарка + имя + одна фраза «кто я») → О себе (1-2 абзаца) → 3-5 карточек проектов/опыта → Контакты (email, telegram, github как ссылки-кнопки). + +### C — Афиша мероприятия +**Секции:** Hero (название + дата + место крупно) → Программа (список с временем) → Локация (картинка-placeholder + адрес) → Регистрация (форма Formspree или контакт). + +### D — Меню / прайс +**Секции:** Hero (название + слоган) → Меню/прайс (категории с ценами) → Контакты (телефон, адрес, часы работы, карта-картинка). + +### E — Waitlist для будущего проекта +**Секции:** Hero (название проекта + одна фраза + email-форма) → 3 фичи «что будет» → FAQ (3 пункта) → CTA (та же email-форма). + +Все шаблоны — **одна страница, прокрутка вниз**. Никаких роутов, ничего динамического. + +--- + +## 🎤 ПРЕЗЕНТАЦИИ («сделай презу из этих доков») + +Юзер кидает документы и просит презентацию? Флоу: +1. Прочитай доки командой `readdoc` (умеет .xlsx, .docx, .pdf, .csv/.txt). Файлы могут лежать в корне воркспейса — проверь `ls ..`. +2. Выдели суть: 5-9 слайдов, на слайде заголовок + 3-5 коротких пунктов. Не переноси абзацы целиком — сжимай до тезисов. +3. Возьми скелет ниже (это ОДИН файл `index.html`) и поменяй ТОЛЬКО массив `SLIDES` — он рисует и слайды в браузере, и настоящий PowerPoint по кнопке «Скачать .pptx». Остальной код не трогай. +4. Покажи через Предпросмотр (стрелки/клавиши листают). Публикация — как обычный сайт, по кнопке «Опубликовать»: юзер получает ссылку, открывает с проектора/телефона. +5. Скажи юзеру: «Кнопка Скачать .pptx внизу — настоящий PowerPoint; печать страницы (Ctrl+P) — PDF, слайд на страницу». + +Скелет (файл `index.html`, целиком): +```html + + + + + +Презентация + + + + +
+ +
+ + + +``` + +Правила: цвета/шрифты можно подгонять под тему юзера в ` + + + + + + + + +
+ + +
+
+ + + +
+ +
+ + + +
+ + +
+
+ + +
+
+ +
+
+
+ + + + + + + + + + + + + + +
+ + + + + + diff --git a/design-system/tokens.json b/design-system/tokens.json new file mode 100644 index 0000000..6e61427 --- /dev/null +++ b/design-system/tokens.json @@ -0,0 +1,320 @@ +{ + "$meta": { + "name": "KT AI Design Tokens", + "version": "5.4.0", + "comment": "Единый источник истины. Правки ТОЛЬКО здесь, затем: python3 scripts/build_tokens.py. Генерирует: kt-ai-tokens.css, kt-ai-print.css, figma-variables.json, kt-ai-tailwind-preset.cjs. Ссылка на primitive: значение вида $имя. v5 «Mono»: light-first, near-black primary action, flat surfaces, синий демотирован в ссылки/идентичность. v5.2: контраст AA как машинный гейт — пороги проверяются в build_tokens.py, а не глазами. v5.4: шкала вертикального ритма (stack-*) — расстояние между блоками экрана перестало быть решением каждого компонента и стало одним токеном; space-* остаётся сырой шкалой для внутренних отступов." + }, + "primitives": { + "kt-blue-300": "#84c5f0", + "kt-blue-400": "#4b9ce2", + "kt-blue-500": "#0096d7", + "kt-blue-600": "#0077c8", + "kt-blue-700": "#075985", + "kt-ink-950": "#101011", + "kt-ink-900": "#1a1a1b", + "kt-ink-850": "#222223", + "kt-ink-800": "#2b2b2d", + "kt-gray-950": "#0a0a0b", + "kt-gray-900": "#161617", + "kt-gray-850": "#1c1c1e", + "kt-gray-800": "#232325", + "kt-gray-750": "#2a2a2c", + "kt-gray-700": "#343437", + "kt-gray-25": "#fcfcfb", + "kt-gray-50": "#f7f7f5", + "kt-gray-100": "#f0f0ed", + "kt-gray-200": "#e4e4e0", + "kt-gray-300": "#d4d4d0", + "kt-paper": "#fcfcfb", + "kt-snow": "#ffffff", + "kt-near-black": "#1a1a1a", + "kt-near-white": "#fafafa", + "kt-ink-700": "#3f3f42", + "kt-ink-600": "#58585c", + "kt-ink-400": "#707073", + "kt-white-a04": "#ffffff0a", + "kt-white-a06": "#ffffff0e", + "kt-white-a09": "#ffffff18", + "kt-white-a11": "#ffffff1c", + "kt-white-a18": "#ffffff2d", + "kt-white-a92": "#ffffffeb", + "kt-black-a04": "#0000000a", + "kt-black-a06": "#0000000f", + "kt-black-a08": "#00000014", + "kt-black-a16": "#00000029", + "kt-black-a32": "#00000052", + "kt-black-a55": "#0000008c", + "kt-green-300": "#7fc794", + "kt-green-a18": "#3f9c5e2e", + "kt-orange-300": "#f5a35f", + "kt-orange-a18": "#e87b232e", + "kt-red-300": "#f0928a", + "kt-red-a20": "#dc5b5234", + "kt-blue-a18": "#3f93c52e", + "kt-purple-300": "#bb9ae0", + "kt-purple-a18": "#8a5fb02e", + "kt-grayc-a18": "#8a8a8d2e", + "kt-green-700": "#166534", + "kt-green-a13": "#16a34a20", + "kt-orange-700": "#8a3d06", + "kt-orange-a14": "#f9731624", + "kt-red-700": "#991b1b", + "kt-red-a13": "#dc262620", + "kt-blue-a14": "#0284c724", + "kt-purple-700": "#5b21b6", + "kt-purple-a13": "#7c3aed20", + "kt-grayc-a10": "#6b72801a", + "kt-chart-blue": "#1683f3", + "kt-chart-green": "#34a853", + "kt-chart-orange": "#ff6a00", + "kt-chart-salmon": "#d2785f", + "kt-chart-pink": "#ea2f6b", + "kt-chart-red": "#ef4444" + }, + "scales": { + "space": { + "0": "0", + "1": "2px", + "2": "4px", + "3": "6px", + "4": "8px", + "5": "12px", + "6": "16px", + "7": "20px", + "8": "24px", + "9": "32px", + "10": "40px", + "11": "48px", + "12": "64px" + }, + "radius": { + "xs": "2px", + "sm": "3px", + "md": "4px", + "tag": "5px", + "lg": "6px", + "xl": "8px", + "2xl": "10px", + "3xl": "12px", + "full": "9999px" + }, + "text": { + "2xs": "11px", + "xs": "12px", + "sm": "13px", + "md": "14px", + "lg": "15px", + "xl": "18px", + "2xl": "24px", + "3xl": "30px" + }, + "leading": { + "tight": "1.25", + "normal": "1.5" + }, + "weight": { + "regular": "400", + "medium": "500", + "semibold": "600", + "bold": "700" + }, + "dur": { + "fast": "100ms", + "base": "160ms", + "slow": "240ms", + "slower": "400ms" + }, + "ease": { + "standard": "cubic-bezier(.2, 0, 0, 1)", + "enter": "cubic-bezier(0, 0, .2, 1)", + "exit": "cubic-bezier(.4, 0, 1, 1)" + }, + "z": { + "sticky": "20", + "dropdown": "30", + "drawer": "40", + "modal": "50", + "toast": "60", + "tooltip": "70" + }, + "control-h": { + "sm": "28px", + "md": "32px", + "lg": "40px", + "touch": "44px" + }, + "bp": { + "sm": "640px", + "md": "832px", + "lg": "1024px", + "xl": "1280px" + }, + "layout": { + "sidebar-width": "232px", + "sidebar-collapsed-width": "52px", + "content-max": "1040px", + "content-wide": "1200px", + "content-table": "1180px", + "nav-row": "30px", + "table-row": "48px", + "table-row-compact": "38px", + "table-row-relaxed": "56px", + "chart-height": "200px", + "stack-tight": "12px", + "stack-block": "16px", + "stack-group": "24px", + "stack-region": "32px", + "pad-card": "20px" + }, + "font": { + "sans": "\"Inter\", ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, \"Segoe UI\", Helvetica, Arial, sans-serif", + "mono": "\"JetBrains Mono\", ui-monospace, SFMono-Regular, \"SF Mono\", Menlo, Monaco, Consolas, monospace" + } + }, + "themes": { + "light": { + "bg": "$kt-paper", + "bg-soft": "$kt-gray-50", + "bg-elevated": "$kt-snow", + "bg-sunken": "$kt-gray-100", + "bg-hover": "$kt-black-a04", + "bg-active": "$kt-black-a08", + "bg-glass": "color-mix(in srgb, #fcfcfb 85%, transparent)", + "inset-highlight": "#ffffff1f", + "ai-accent": "#7c4fe0", + "ai-gradient": "linear-gradient(135deg, #0096d7, #7c4fe0 92%)", + "ai-orb": "radial-gradient(circle at 32% 30%, #b69cf0, #0077c8 75%)", + "orb-bg": "radial-gradient(circle at 42% 35%, #1c2940 0%, #0a0c12 72%)", + "fg-on-ai": "#ffffff", + "card-bg": "$kt-snow", + "card-bg-glow": "$kt-snow", + "card-border": "$kt-black-a08", + "card-border-hover": "$kt-black-a16", + "card-shadow": "0 1px 2px #0000000d", + "card-shadow-hover": "0 4px 14px #00000014", + "selection": "#1a1a1a16", + "fg": "$kt-near-black", + "fg-muted": "$kt-ink-600", + "fg-faint": "$kt-ink-400", + "fg-on-fill": "#ffffff", + "border": "$kt-black-a08", + "border-strong": "$kt-black-a16", + "divider": "$kt-black-a06", + "primary": "$kt-near-black", + "primary-hover": "#343437", + "primary-subtle": "$kt-black-a04", + "brand-blue": "$kt-blue-500", + "link": "$kt-blue-600", + "focus-ring": "#1a1a1a99", + "focus-shadow": "0 0 0 3px #1a1a1a1f", + "danger": "#dc2626", + "danger-hover": "#b91c1c", + "overlay": "$kt-black-a32", + "chip-default-bg": "$kt-black-a06", + "chip-default-fg": "#2d2d2f", + "chip-gray-bg": "$kt-grayc-a10", + "chip-gray-fg": "$kt-ink-700", + "chip-orange-bg": "$kt-orange-a14", + "chip-orange-fg": "$kt-orange-700", + "chip-green-bg": "$kt-green-a13", + "chip-green-fg": "$kt-green-700", + "chip-blue-bg": "$kt-blue-a14", + "chip-blue-fg": "$kt-blue-700", + "chip-purple-bg": "$kt-purple-a13", + "chip-purple-fg": "$kt-purple-700", + "chip-red-bg": "$kt-red-a13", + "chip-red-fg": "$kt-red-700", + "status-ok-bg": "$kt-green-a13", + "status-ok-fg": "$kt-green-700", + "status-warn-bg": "$kt-orange-a14", + "status-warn-fg": "$kt-orange-700", + "status-risk-bg": "$kt-red-a13", + "status-risk-fg": "$kt-red-700", + "status-info-bg": "$kt-grayc-a10", + "status-info-fg": "$kt-ink-700", + "chart-blue": "$kt-chart-blue", + "chart-green": "$kt-chart-green", + "chart-orange": "$kt-chart-orange", + "chart-salmon": "$kt-chart-salmon", + "chart-pink": "$kt-chart-pink", + "chart-red": "$kt-chart-red", + "chart-bar-fill": "#e4e4e0", + "shadow-sm": "0 1px 2px #0000000d", + "shadow-md": "0 4px 14px #00000014", + "shadow-lg": "0 2px 6px #00000012, 0 12px 32px #0000001f", + "shadow-xl": "0 3px 8px #00000014, 0 24px 64px #00000026" + }, + "dark": { + "bg": "$kt-gray-950", + "bg-soft": "$kt-gray-900", + "bg-elevated": "$kt-gray-850", + "bg-sunken": "#060607", + "bg-hover": "$kt-white-a06", + "bg-active": "$kt-white-a09", + "bg-glass": "color-mix(in srgb, #0a0a0b 85%, transparent)", + "inset-highlight": "#00000014", + "ai-accent": "#7c4fe0", + "ai-gradient": "linear-gradient(135deg, #0096d7, #8b5cf6 92%)", + "ai-orb": "radial-gradient(circle at 32% 30%, #c4b0ff, #0096d7 75%)", + "orb-bg": "radial-gradient(circle at 42% 35%, #1c2940 0%, #0a0c12 72%)", + "fg-on-ai": "#ffffff", + "card-bg": "$kt-gray-900", + "card-bg-glow": "$kt-gray-900", + "card-border": "$kt-white-a09", + "card-border-hover": "$kt-white-a18", + "card-shadow": "none", + "card-shadow-hover": "none", + "selection": "#fafafa1f", + "fg": "$kt-white-a92", + "fg-muted": "#9b9b9e", + "fg-faint": "#848487", + "fg-on-fill": "$kt-ink-900", + "border": "$kt-white-a09", + "border-strong": "$kt-white-a18", + "divider": "$kt-white-a06", + "primary": "$kt-near-white", + "primary-hover": "#e4e4e7", + "primary-subtle": "$kt-white-a09", + "brand-blue": "$kt-blue-500", + "link": "$kt-blue-400", + "focus-ring": "#fafafa80", + "focus-shadow": "0 0 0 3px #fafafa24", + "danger": "#f0726a", + "danger-hover": "#f48a83", + "overlay": "$kt-black-a55", + "chip-default-bg": "$kt-white-a09", + "chip-default-fg": "$kt-white-a92", + "chip-gray-bg": "$kt-grayc-a18", + "chip-gray-fg": "$kt-gray-300", + "chip-orange-bg": "$kt-orange-a18", + "chip-orange-fg": "$kt-orange-300", + "chip-green-bg": "$kt-green-a18", + "chip-green-fg": "$kt-green-300", + "chip-blue-bg": "$kt-blue-a18", + "chip-blue-fg": "$kt-blue-300", + "chip-purple-bg": "$kt-purple-a18", + "chip-purple-fg": "$kt-purple-300", + "chip-red-bg": "$kt-red-a20", + "chip-red-fg": "$kt-red-300", + "status-ok-bg": "$kt-green-a18", + "status-ok-fg": "$kt-green-300", + "status-warn-bg": "$kt-orange-a18", + "status-warn-fg": "$kt-orange-300", + "status-risk-bg": "$kt-red-a20", + "status-risk-fg": "$kt-red-300", + "status-info-bg": "$kt-grayc-a18", + "status-info-fg": "$kt-gray-300", + "chart-blue": "$kt-chart-blue", + "chart-green": "$kt-chart-green", + "chart-orange": "$kt-chart-orange", + "chart-salmon": "$kt-chart-salmon", + "chart-pink": "$kt-chart-pink", + "chart-red": "$kt-chart-red", + "chart-bar-fill": "#2e2e31", + "shadow-sm": "0 1px 2px #00000052", + "shadow-md": "0 4px 16px #00000066", + "shadow-lg": "0 12px 40px #00000080", + "shadow-xl": "0 24px 80px #00000099" + } + } +} diff --git a/design.md b/design.md new file mode 100644 index 0000000..fb49a5b --- /dev/null +++ b/design.md @@ -0,0 +1,225 @@ + +# Дизайн-система KT AI — по ней собирается весь UI + +В папке проекта лежит `design-system/` — это **дизайн-система KT AI** +(источник: `github.com/erjan-A/kt-ai-design-system`, MIT, версия в `design-system/VERSION`). +Весь визуал берётся оттуда: цвета, шрифт, отступы, кнопки, карточки, секции лендинга. + +**Не выдумывай свой стиль и не тащи чужие фреймворки.** Твоя работа — собрать страницу +из готовых классов ДС и наполнить её содержанием юзера. + +Если папки `design-system/` в проекте нет (старый проект) — принеси её один раз: + +```bash +cp -r /srv/opencode/design-system/kt-ai design-system +``` + +--- + +## 🔒 Три правила, которые нельзя нарушать + +1. **Цвета и размеры — только токенами** `var(--kt-ai-…)`. Сырой hex (`#0F1218`, `#fff`) в вёрстке + запрещён: он ломает светлую/тёмную тему. +2. **Никакого Bootstrap / Tailwind / Material / Font Awesome / Google Fonts.** Шрифт (Inter) уже + внутри ДС и работает офлайн. Всё, что нужно лендингу, в ДС уже есть. +3. **Свой ` + + + +
+
+ Название + + О нас + Оставить заявку +
+
+ +
+
+

Главное обещание в одну строку

+

Подзаголовок: кому это и почему стоит остаться.

+ +
+
+ +
+
+
+

Что вы получите

+

Одно-два предложения пояснения к секции.

+
+
+

Пункт первый

Коротко и по делу.

+

Пункт второй

Коротко и по делу.

+

Пункт третий

Коротко и по делу.

+
+
+
+ +
+
+

Призыв к действию

+

Город, адрес, часы работы — то, что нужно, чтобы дойти.

+ Написать +
+
+ +
+ © 2026 Название + + Контакты +
+ + + +``` + +--- + +## Блоки страницы (`kt-ai-page.css`) + +| Класс | Что это | +|-------|---------| +| `.kt-ai-wrap` | контейнер по центру (1080px). `data-width="narrow"` — 720px, `"wide"` — 1200px | +| `.kt-ai-pagehead` + `.kt-ai-pagehead-in` | липкая шапка страницы | +| `.kt-ai-hero`, `.kt-ai-hero-sub`, `.kt-ai-hero-actions` | первый экран. `data-align="center"` — по центру | +| `.kt-ai-section` + `.kt-ai-section-head` (h2 + p) | обычная секция. `data-divider="true"` — линия сверху | +| `.kt-ai-grid` + `.kt-ai-tile` (h3 + p) | сетка плиток «возможности». `data-cols="2"/"4"`, у плитки `data-span="2"/"full"` | +| `.kt-ai-tile-icon` | иконка над заголовком плитки (спрайт — `design-system/icons/`) | +| `.kt-ai-cta` | финальный блок с призывом | +| `.kt-ai-pagefoot` | подвал | +| `.kt-ai-spacer` | распорка внутри шапки/подвала (толкает остальное вправо) | +| `.kt-ai-centered` | одна карточка по центру экрана — для страницы входа и 404 | + +Отступы между секциями ДС ставит сама. Адаптив (3→2→1 колонка) — тоже, `@media` писать не нужно. + +## Элементы (`kt-ai-components.css`) + +| Класс | Как применять | +|-------|---------------| +| `.kt-ai-btn` | кнопка / ссылка-кнопка. `data-variant="primary"` — главная, `"ghost"` — прозрачная, `"danger"` — опасная. `data-size="sm"/"lg"` | +| `.kt-ai-card` | карточка контента | +| `.kt-ai-chip` | статус-пилюля / тег | +| `.kt-ai-link` | текстовая ссылка | +| `.kt-ai-field` + `.kt-ai-input` / `.kt-ai-textarea` / `.kt-ai-select` | поле формы с подписью | + +**Один `primary` на экран.** Остальные кнопки — обычные или `ghost`. + +## Токены (`kt-ai-tokens.css`) + +Цвет: `--kt-ai-bg`, `--kt-ai-bg-soft`, `--kt-ai-fg`, `--kt-ai-fg-muted`, `--kt-ai-fg-faint`, +`--kt-ai-border`, `--kt-ai-divider`, `--kt-ai-card-bg`, `--kt-ai-card-border`, +`--kt-ai-primary`, `--kt-ai-link`, `--kt-ai-danger`. + +Отступы: `--kt-ai-space-1…12` (2px → 64px). Скругления: `--kt-ai-radius-md/lg/xl/2xl/3xl/full`. +Текст: `--kt-ai-text-xs…3xl`. Вес: `--kt-ai-weight-regular/medium/semibold/bold`. +Шрифты: `--kt-ai-font-sans` (Inter), `--kt-ai-font-mono`. + +**Тема** — атрибут на ``: `data-theme="auto"` (по системной, ставь его по умолчанию), +`"light"`, `"dark"`. Проверь страницу в обеих темах — с токенами это бесплатно. + +--- + +## Не лендинг, а приложение (дашборд, таблица, очередь заявок, чат) + +Тогда экран собирается **не вёрсткой руками, а конфигом**: + +1. Возьми `design-system/templates/kt-ai-app-shell.html` — самодостаточный HTML-каркас приложения + (без React и npm). +2. Опиши экран JSON-конфигом по `design-system/docs/PRODUCT_CONTRACT.md` и положи его в тег + `` внутри каркаса — он сам отрисует + таблицу, KPI, фильтры, drawer и темы. +3. Проверь конфиг: `python3 design-system/scripts/validate_product.py config.json --strict` → нужен `0/0`. + +Полные правила для сложных экранов — `design-system/AGENTS.md`, список компонентов — +`design-system/COMPONENTS.md`, приёмка — `design-system/CHECKLIST.md`. Читай их, когда лендинга +уже мало. + +--- + +## 📦 Готовые рецепты + +### Фото без ключей +- Одно и то же слово → одна и та же картинка: `https://picsum.photos/seed/coffee/800/600` +- По теме: `https://loremflickr.com/800/600/coffee` (тег прямо в URL) + +Вставляй с рамкой ДС: `…` + +### Кнопка WhatsApp +```html +Написать в WhatsApp +``` +`7XXXXXXXXXX` — номер в международном формате без `+`. + +### Форма заявки без бэкенда +```html +
+
+
+ +
+``` +Скажи юзеру: замени `EMAIL` на свою почту — заявки будут приходить туда (первая заявка активирует форму). + +### Favicon-эмодзи (без файла) +```html + +``` + +### Карта +На maps.google.com: место → «Поделиться» → «Встроить карту» → вставь iframe как есть: +```html + +``` + +### Плавные появления при скролле +```html + + +``` + +--- + +## Перед тем как сказать «готово» + +- [ ] Подключены все четыре CSS из `design-system/`, папка лежит в проекте (не забудь закоммитить её). +- [ ] В вёрстке нет сырых hex-цветов и чужих шрифтов. +- [ ] Страница нормально выглядит в светлой и тёмной теме и на телефоне (узкое окно). +- [ ] Один `data-variant="primary"` на экран, тексты — про юзера, а не «Lorem ipsum». diff --git a/index.html b/index.html new file mode 100644 index 0000000..ff06f77 --- /dev/null +++ b/index.html @@ -0,0 +1,464 @@ + + + + + +Аналитическая панель + + + + + + + + +
+
+ 📊 Аналитическая панель + + +
+
+ +
+ +
+
+
📁
+

Загрузите файл с данными

+

+ Перетащите файл сюда или кликните для выбора
+ Поддерживаются: Excel (.xlsx, .xls), CSV +

+ + +
+
+ + + +
+ + + + + diff --git a/package.json b/package.json new file mode 100644 index 0000000..41e7c75 --- /dev/null +++ b/package.json @@ -0,0 +1,12 @@ +{ + "name": "analytics-app", + "private": true, + "type": "commonjs", + "scripts": { + "start": "node server.js" + }, + "dependencies": { + "xlsx": "^0.18.5", + "formidable": "^3.5.1" + } +} diff --git a/server.js b/server.js new file mode 100644 index 0000000..41ec7c8 --- /dev/null +++ b/server.js @@ -0,0 +1,231 @@ +const http = require("http"); +const fs = require("fs"); +const path = require("path"); +const XLSX = require("xlsx"); +const formidable = require("formidable"); + +const PORT = process.env.PORT || 3000; +const DATA_DIR = path.join(__dirname, "uploads"); +const ANALYSIS_FILE = path.join(__dirname, "analysis.json"); + +if (!fs.existsSync(DATA_DIR)) fs.mkdirSync(DATA_DIR); + +function readAnalysis() { + try { return JSON.parse(fs.readFileSync(ANALYSIS_FILE, "utf8")); } catch (_) { return null; } +} +function writeAnalysis(data) { + fs.writeFileSync(ANALYSIS_FILE, JSON.stringify(data, null, 2)); +} + +function body(req) { + return new Promise((resolve) => { + let s = ""; + req.on("data", (c) => (s += c)); + req.on("end", () => { try { resolve(JSON.parse(s || "{}")); } catch (_) { resolve({}); } }); + }); +} + +function parseFile(filepath) { + const ext = path.extname(filepath).toLowerCase(); + if (ext === ".csv") { + const content = fs.readFileSync(filepath, "utf8"); + const lines = content.split(/\r?\n/).filter(l => l.trim()); + if (lines.length < 2) return { columns: [], rows: [] }; + const parseLine = (line) => { + const result = []; + let current = '', inQuotes = false; + for (let i = 0; i < line.length; i++) { + const char = line[i]; + if (char === '"') { inQuotes = !inQuotes; } + else if (char === ',' && !inQuotes) { result.push(current.trim()); current = ''; } + else { current += char; } + } + result.push(current.trim()); + return result; + }; + const columns = parseLine(lines[0]); + const rows = lines.slice(1).map(line => { + const values = parseLine(line); + const obj = {}; + columns.forEach((col, i) => { obj[col] = values[i] || ''; }); + return obj; + }); + return { columns, rows }; + } else { + const wb = XLSX.readFile(filepath); + const sheet = wb.Sheets[wb.SheetNames[0]]; + const rows = XLSX.utils.sheet_to_json(sheet); + const columns = rows.length > 0 ? Object.keys(rows[0]) : []; + return { columns, rows }; + } +} + +function calculateStats(rows, columns) { + const stats = {}; + const numericCols = columns.filter(col => { + const sample = rows.slice(0, 10).map(r => r[col]); + return sample.some(v => !isNaN(parseFloat(v)) && isFinite(v)); + }); + + numericCols.forEach(col => { + const values = rows.map(r => parseFloat(r[col])).filter(v => !isNaN(v)); + if (values.length > 0) { + const sum = values.reduce((a, b) => a + b, 0); + const avg = sum / values.length; + const min = Math.min(...values); + const max = Math.max(...values); + const sorted = [...values].sort((a, b) => a - b); + const median = sorted.length % 2 === 0 + ? (sorted[sorted.length / 2 - 1] + sorted[sorted.length / 2]) / 2 + : sorted[Math.floor(sorted.length / 2)]; + stats[col] = { sum, avg, min, max, median, count: values.length }; + } + }); + return stats; +} + +function generateInsights(rows, columns, stats) { + const insights = []; + const totalRows = rows.length; + + if (totalRows > 0) { + insights.push({ type: "info", text: `Всего записей: ${totalRows}`, category: "Общие данные" }); + } + + Object.keys(stats).forEach(col => { + const s = stats[col]; + if (s.count > totalRows * 0.5) { + insights.push({ + type: "metric", + text: `Среднее по "${col}": ${s.avg.toFixed(2)}`, + category: "Статистика", + value: s.avg + }); + if (s.max > s.avg * 2) { + insights.push({ + type: "warning", + text: `Выявлен аномальный максимум в "${col}": ${s.max} (в ${((s.max/s.avg).toFixed(1))} раз выше среднего)`, + category: "Аномалии" + }); + } + } + }); + + const stringCols = columns.filter(col => !stats[col]); + stringCols.forEach(col => { + const values = rows.map(r => r[col]).filter(v => v); + const unique = [...new Set(values)]; + if (unique.length < totalRows * 0.3 && unique.length > 1) { + const counts = {}; + values.forEach(v => { counts[v] = (counts[v] || 0) + 1; }); + const top = Object.entries(counts).sort((a, b) => b[1] - a[1]).slice(0, 3); + if (top.length > 0) { + insights.push({ + type: "info", + text: `Наиболее частые значения в "${col}": ${top.map(([k, v]) => `${k} (${v})`).join(', ')}`, + category: "Категории" + }); + } + } + }); + + if (insights.length === 0) { + insights.push({ type: "info", text: "Загрузите данные для получения аналитических выводов", category: "Рекомендации" }); + } + + return insights; +} + +http.createServer(async (req, res) => { + res.setHeader("Access-Control-Allow-Origin", "*"); + res.setHeader("Access-Control-Allow-Methods", "GET, POST, OPTIONS"); + res.setHeader("Access-Control-Allow-Headers", "Content-Type"); + + if (req.method === "OPTIONS") { + res.writeHead(204); + return res.end(); + } + + if (req.method === "GET" && req.url === "/api/analysis") { + const analysis = readAnalysis(); + res.writeHead(200, { "Content-Type": "application/json" }); + return res.end(JSON.stringify(analysis || { columns: [], rows: [], stats: {}, insights: [] })); + } + + if (req.method === "POST" && req.url === "/api/upload") { + const form = formidable({ uploadDir: DATA_DIR, keepExtensions: true }); + form.parse(req, async (err, fields, files) => { + if (err) { + res.writeHead(500, { "Content-Type": "application/json" }); + return res.end(JSON.stringify({ error: err.message })); + } + const file = files.file && files.file[0]; + if (!file) { + res.writeHead(400, { "Content-Type": "application/json" }); + return res.end(JSON.stringify({ error: "Файл не загружен" })); + } + try { + const { columns, rows } = parseFile(file.filepath); + const stats = calculateStats(rows, columns); + const insights = generateInsights(rows, columns, stats); + const analysis = { columns, rows, stats, insights, filename: file.originalFilename }; + writeAnalysis(analysis); + res.writeHead(200, { "Content-Type": "application/json" }); + return res.end(JSON.stringify({ ok: true, columns, rows: rows.slice(0, 100), stats, insights })); + } catch (e) { + res.writeHead(500, { "Content-Type": "application/json" }); + return res.end(JSON.stringify({ error: e.message })); + } + }); + return; + } + + if (req.method === "GET" && req.url.startsWith("/api/data")) { + const analysis = readAnalysis(); + if (!analysis) { + res.writeHead(404, { "Content-Type": "application/json" }); + return res.end(JSON.stringify({ error: "Данные не загружены" })); + } + const url = new URL(req.url, "http://localhost"); + const filterCol = url.searchParams.get("filterColumn"); + const filterVal = url.searchParams.get("filterValue"); + const sortCol = url.searchParams.get("sortColumn"); + const sortOrder = url.searchParams.get("sortOrder") || "asc"; + const limit = parseInt(url.searchParams.get("limit")) || 100; + + let filtered = [...analysis.rows]; + if (filterCol && filterVal) { + filtered = filtered.filter(r => String(r[filterCol] || "").toLowerCase().includes(filterVal.toLowerCase())); + } + if (sortCol) { + filtered.sort((a, b) => { + const aVal = parseFloat(a[sortCol]) || a[sortCol]; + const bVal = parseFloat(b[sortCol]) || b[sortCol]; + if (aVal < bVal) return sortOrder === "asc" ? -1 : 1; + if (aVal > bVal) return sortOrder === "asc" ? 1 : -1; + return 0; + }); + } + + res.writeHead(200, { "Content-Type": "application/json" }); + return res.end(JSON.stringify({ + columns: analysis.columns, + rows: filtered.slice(0, limit), + total: filtered.length, + stats: analysis.stats, + insights: analysis.insights + })); + } + + let file = req.url === "/" ? "/index.html" : req.url.split("?")[0]; + const full = path.join(__dirname, file); + if (full.startsWith(__dirname) && fs.existsSync(full) && fs.statSync(full).isFile()) { + const ext = path.extname(full); + const type = ext === ".css" ? "text/css" : ext === ".js" ? "application/javascript" : "text/html"; + res.writeHead(200, { "Content-Type": type + "; charset=utf-8" }); + return res.end(fs.readFileSync(full)); + } + + res.writeHead(404); + res.end("Not found"); +}).listen(PORT, () => console.log("Аналитическое приложение на порту " + PORT)); diff --git a/site-backup.tar.gz b/site-backup.tar.gz new file mode 100644 index 0000000000000000000000000000000000000000..828c6e2bb2e5691d11c6c75cd299cda794f4b030 GIT binary patch literal 16822 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 Z|MB^cVuIX1%YXc-XUkIm#o)lm008iRr?mh8 literal 0 HcmV?d00001