// AI Assistant chat page — multiple conversations with separate context,
// persisted per user in localStorage. Answers come from the real ai-provider
// (Gemini/Claude) via window.API.aiChat over the поручения register.
const AI_SUGGESTIONS = [
"Сколько всего поручений за 2025 год?",
"Сколько поручений просрочено?",
"Сводка по отделам за 2025",
"Какие поручения у Фроловой В.Ю.?",
];
function aiInitials(name) {
const parts = String(name || "").trim().split(/\s+/).filter(Boolean);
if (!parts.length) return "Я";
if (parts.length === 1) return parts[0].slice(0, 2).toUpperCase();
return (parts[0][0] + parts[1][0]).toUpperCase();
}
function nowTime() {
const d = new Date();
return `${String(d.getHours()).padStart(2, "0")}:${String(d.getMinutes()).padStart(2, "0")}`;
}
// Per-user storage key so different accounts on one machine keep separate chats.
function chatsKey() {
return `crm_ai_chats_v1_${(window.ME && window.ME.login) || "anon"}`;
}
function greetingMsg() {
return {
id: 1,
from: "ai",
time: nowTime(),
text: "Здравствуйте! Я отвечаю по реестру поручений: сколько всего, что просрочено, разбивка по отделам и сотрудникам. Спросите — отвечу по реальным данным.",
suggestions: AI_SUGGESTIONS,
};
}
function makeChat() {
return {
id: `c${Date.now()}_${Math.floor(Math.random() * 100000)}`,
title: "Новый чат",
messages: [greetingMsg()],
updated: nowTime(),
};
}
function loadChats() {
try {
const data = JSON.parse(localStorage.getItem(chatsKey()));
if (data && Array.isArray(data.chats) && data.chats.length) {
const activeId = data.chats.some(c => c.id === data.activeId) ? data.activeId : data.chats[0].id;
return { chats: data.chats, activeId };
}
} catch (e) { /* ignore corrupt storage */ }
const c = makeChat();
return { chats: [c], activeId: c.id };
}
function saveChats(chats, activeId) {
try { localStorage.setItem(chatsKey(), JSON.stringify({ chats, activeId })); } catch (e) { /* quota / private mode */ }
}
// Approximate size of a conversation (sum of message text lengths) — used to warn
// the user when the dialog gets large enough to slow answers.
function chatSize(messages) {
return (messages || []).reduce((n, m) => n + ((m.text || "").length), 0);
}
const CTX_WARN_CHARS = 16000; // показать подсказку «начните новый чат» выше этого размера
const CTX_BUDGET_CHARS = 6000; // бюджет контекста, отправляемого модели (история)
// trimHistory builds the model context newest-first up to a character budget, then
// restores chronological order. Long answers (big tables) stop bloating the context
// → fewer timeouts and lower cost. The latest turn is always kept.
function trimHistory(messages, budget) {
const turns = (messages || []).filter(m => (m.from === "user" || m.from === "ai") && (m.text || "").trim());
const out = [];
let used = 0;
for (let i = turns.length - 1; i >= 0; i--) {
const text = turns[i].text || "";
if (out.length && used + text.length > budget) break; // keep at least the latest turn
used += text.length;
out.push({ role: turns[i].from === "ai" ? "model" : "user", text });
}
return out.reverse();
}
function ChatPage() {
const [state, setState] = React.useState(loadChats);
const { chats, activeId } = state;
const active = chats.find(c => c.id === activeId) || chats[0];
const [draft, setDraft] = React.useState("");
const [thinking, setThinking] = React.useState(false);
const [aiInfo, setAiInfo] = React.useState(null);
const [ctxWarnOff, setCtxWarnOff] = React.useState(false); // user dismissed the "big dialog" hint
const streamRef = React.useRef(null);
// Persist on every change.
React.useEffect(() => { saveChats(chats, activeId); }, [chats, activeId]);
// Show which provider/model is answering.
React.useEffect(() => {
if (window.API && window.API.aiStatus) {
window.API.aiStatus().then(setAiInfo).catch(() => {});
}
}, []);
// Keep the stream scrolled to the latest message.
React.useEffect(() => {
if (streamRef.current) streamRef.current.scrollTop = streamRef.current.scrollHeight;
}, [active && active.messages, thinking, activeId]);
// Apply an update to the currently active chat only (separate context per chat).
const patchActive = (updater) => {
setState(s => ({ ...s, chats: s.chats.map(c => (c.id === s.activeId ? updater(c) : c)) }));
};
// Patch a specific chat by id — used during streaming so deltas land in the
// right conversation even if the user switches chats mid-answer.
const patchChat = (id, updater) => {
setState(s => ({ ...s, chats: s.chats.map(c => (c.id === id ? updater(c) : c)) }));
};
// Persist an action's resolved state onto the message (so a confirmed/cancelled
// ActionCard doesn't reappear as a fresh form after a page refresh).
const resolveAction = (msgId, idx, patch) => patchChat(activeId, c => ({
...c,
messages: c.messages.map(m => (m.id === msgId && Array.isArray(m.actions)
? { ...m, actions: m.actions.map((a, j) => (j === idx ? { ...a, ...patch } : a)) }
: m)),
}));
// Resume / clean up interrupted answers (e.g. after a page refresh). A message
// left streaming with a jobId is re-attached to its server-side generation;
// one without a jobId can't be recovered, so it's finalized as interrupted.
const resumedRef = React.useRef(new Set());
React.useEffect(() => {
const chat = chats.find(c => c.id === activeId);
if (!chat) return;
const setAi = (msgId, fn) => patchChat(activeId, c => ({ ...c, messages: c.messages.map(m => (m.id === msgId ? fn(m) : m)) }));
chat.messages.forEach(m => {
if (m.from !== "ai" || !m.streaming) return;
if (m.jobId && window.API && window.API.aiResumeStream) {
if (resumedRef.current.has(m.jobId)) return;
resumedRef.current.add(m.jobId);
const msgId = m.id;
setAi(msgId, x => ({ ...x, text: "" })); // job replays all deltas from the start
setThinking(false);
window.API.aiResumeStream(m.jobId, {
onDelta: (d) => setAi(msgId, x => ({ ...x, text: x.text + d })),
onChart: (chart) => setAi(msgId, x => ({ ...x, card: chart })),
onActions: (actions) => setAi(msgId, x => ({ ...x, actions })),
onDone: (meta) => setAi(msgId, x => ({ ...x, text: meta && meta.text ? meta.text : x.text, streaming: false })),
onError: (msg, evt) => setAi(msgId, x => ({ ...x, streaming: false, text: (evt && evt.expired) ? "⚠️ Ответ устарел — задайте вопрос ещё раз." : (x.text || ("⚠️ " + msg)) })),
});
} else if (!m.jobId) {
setAi(m.id, x => ({ ...x, streaming: false, text: x.text || "⚠️ Ответ был прерван — задайте вопрос ещё раз." }));
}
});
}, [activeId]); // eslint-disable-line
const selectChat = (id) => { setThinking(false); setCtxWarnOff(false); setState(s => ({ ...s, activeId: id })); };
const createChat = () => {
const c = makeChat();
setDraft("");
setThinking(false);
setCtxWarnOff(false);
setState(s => ({ chats: [c, ...s.chats], activeId: c.id }));
};
const deleteChat = (id, e) => {
e.stopPropagation();
setState(s => {
let chats = s.chats.filter(c => c.id !== id);
if (!chats.length) chats = [makeChat()];
const activeId = s.activeId === id ? chats[0].id : s.activeId;
return { chats, activeId };
});
};
const send = async (text) => {
const v = (text ?? draft).trim();
if (!v || thinking) return;
const time = nowTime();
const chatId = state.activeId;
// Build history from THIS chat's messages (its own context), trimmed to a
// character budget so long answers (big tables) don't bloat the context.
const cur = (state.chats.find(c => c.id === chatId) || {}).messages || [];
const history = trimHistory(cur, CTX_BUDGET_CHARS);
// Add the user message + an empty AI placeholder we fill as deltas arrive.
// Derive both ids from one timestamp so they can never collide (a second
// Date.now() call could tick into the next ms and equal aiId).
const baseId = Date.now();
const userId = baseId;
const aiId = baseId + 1;
patchChat(chatId, c => ({
...c,
title: c.title === "Новый чат" ? (v.length > 42 ? v.slice(0, 42) + "…" : v) : c.title,
messages: [
...c.messages,
{ id: userId, from: "user", text: v, time },
{ id: aiId, from: "ai", text: "", time: nowTime(), streaming: true },
],
updated: time,
}));
setDraft("");
setThinking(true);
const setAi = (fn) => patchChat(chatId, c => ({ ...c, messages: c.messages.map(m => (m.id === aiId ? fn(m) : m)), updated: nowTime() }));
if (!(window.API && window.API.aiChatStream)) {
setAi(m => ({ ...m, text: "⚠️ ИИ-сервис недоступен.", streaming: false }));
setThinking(false);
return;
}
let got = false;
const finish = (full, chart, actions) => {
setAi(m => ({ ...m, text: full != null ? full : m.text, card: chart || m.card, actions: actions || m.actions, streaming: false }));
setThinking(false);
};
const fail = async (msg) => {
if (!got && window.API.aiChat) {
// Streaming failed before any text — try the plain (non-stream) endpoint.
try { const res = await window.API.aiChat(v, history); finish(res.text || "(пустой ответ)", res.chart, res.actions); return; }
catch (e) { msg = String((e && e.message) || e); }
}
setAi(m => ({ ...m, text: (m.text ? m.text + "\n\n" : "") + "⚠️ " + msg, streaming: false }));
setThinking(false);
};
try {
await window.API.aiChatStream(v, history, {
onJob: (id) => setAi(m => ({ ...m, jobId: id })),
onDelta: (d) => { got = true; setThinking(false); setAi(m => ({ ...m, text: m.text + d })); },
onChart: (chart) => setAi(m => ({ ...m, card: chart })),
onActions: (actions) => setAi(m => ({ ...m, actions })),
onDone: (meta) => finish(meta && meta.text ? meta.text : null),
onError: (m) => fail(m),
});
// Stream ended without an explicit done/error (e.g. dropped connection).
setThinking(false);
setAi(m => (m.streaming ? { ...m, streaming: false } : m));
} catch (e) {
fail(String((e && e.message) || e));
}
};
const onKey = (e) => {
if (e.key === "Enter" && !e.shiftKey) {
e.preventDefault();
send();
}
};
const subtitle = "Ответы по реальному реестру поручений";
return (
История · {chats.length}
{chats.map((c) => {
const last = c.messages[c.messages.length - 1];
const preview = last ? last.text : "";
return (
selectChat(c.id)}
style={{ position: "relative", cursor: "pointer" }}
>
{c.title}
{preview}
{c.updated}
);
})}
{active.messages.map(m => )}
{chatSize(active.messages) > CTX_WARN_CHARS && !ctxWarnOff && (
Диалог стал большим — ответы могут замедлиться. Для точных и быстрых ответов начните новый чат.
)}
Ответы основаны на данных реестра. Проверяйте важные цифры.
);
}
function Message({ msg, onSuggest, onResolveAction }) {
const isUser = msg.from === "user";
const me = window.ME || {};
const pending = !isUser && msg.streaming && !msg.text; // generating, no text yet → "думает"
return (
{isUser ? aiInitials(me.name || me.login) : }
{isUser ? (me.name || me.login || "Вы") : "Ассистент"}
{pending ? "думает…" : msg.time}
{pending ? (
) : (msg.text || !msg.streaming) ? (
{isUser ? msg.text : }
{msg.streaming && ▍}
) : null}
{msg.card &&
}
{msg.actions && msg.actions.map((a, i) =>
onResolveAction && onResolveAction(msg.id, i, patch)} />)}
{msg.suggestions && (
{msg.suggestions.map((s, i) => (
))}
)}
);
}
// InlineCard renders a chart the model asked for. The model only chooses the axes
// (chart/chartBy/chartType); the numbers come from Go's real aggregates over the
// register. Specs: single-series → {kind:"bars"|"line", data:[{name,value}]};
// multi-series → {kind:"stacked"|"grouped"|"mline", data:[{name,:n,…}], series:[…]}.
const CHART_COLORS = ["var(--accent)", "var(--ok)", "var(--warn)", "var(--err)", "var(--accent-strong)", "var(--ink-300)", "#7e6bc4", "#cf7bb3"];
function InlineCard({ card }) {
const { LineChart, Line, BarChart, Bar, XAxis, YAxis, Tooltip, Legend, ResponsiveContainer, CartesianGrid } = Recharts;
const data = Array.isArray(card.data) ? card.data : [];
const series = Array.isArray(card.series) ? card.series : null;
const tipStyle = { background: "var(--ink-800)", border: "none", borderRadius: 6, color: "#fff", fontSize: 11 };
const tipLabel = { color: "rgba(255,255,255,.55)" };
const tick = { fill: "var(--ink-400)", fontSize: 10 };
// Categorical horizontal bars (по отделам / по статусам / по сотрудникам).
if (card.kind === "bars") {
return (
{card.title || "Диаграмма"}
);
}
// Single-series line over months (динамика).
if (card.kind === "line") {
return (
{card.title || "Динамика"}
);
}
// Multi-series line (несколько серий по месяцам).
if (card.kind === "mline" && series) {
return (
{card.title || "Динамика"}
{series.map((s, i) => (
))}
);
}
// Vertical multi-series bars — stacked or grouped (напр. месяц × статус: «слева
// серии, снизу ось X»).
if ((card.kind === "stacked" || card.kind === "grouped") && series) {
const stack = card.kind === "stacked";
const dense = data.length > 7;
return (
{card.title || "Сравнение"}
{series.map((s, i) => (
))}
);
}
return null;
}
// --- Markdown rendering for AI answers (safe: builds React nodes, no innerHTML) ---
// mdInline renders inline markup: **bold**, *italic*/_italic_, `code`, [text](url).
function mdInline(s, kp) {
const out = [];
const re = /\*\*([^*]+)\*\*|__([^_]+)__|`([^`]+)`|\[([^\]]+)\]\(([^)]+)\)|\*([^*\n]+)\*|_([^_\n]+)_/g;
let last = 0, m, k = 0;
while ((m = re.exec(s))) {
if (m.index > last) out.push({s.slice(last, m.index)});
if (m[1] != null) out.push({m[1]});
else if (m[2] != null) out.push({m[2]});
else if (m[3] != null) out.push({m[3]});
else if (m[4] != null) out.push({m[4]});
else if (m[6] != null) out.push({m[6]});
else if (m[7] != null) out.push({m[7]});
last = re.lastIndex;
}
if (last < s.length) out.push({s.slice(last)});
return out;
}
// --- table helpers (GFM): split a row into cells, read column alignment ---
// Strips one optional leading/trailing pipe, then splits on pipes.
function mdTableCells(line) {
let s = line.trim();
if (s.startsWith("|")) s = s.slice(1);
if (s.endsWith("|")) s = s.slice(0, -1);
return s.split("|").map(c => c.trim());
}
// A separator row like |---|:--:|---| (≥2 columns) marks the header underline.
const MD_TABLE_SEP = /^\s*\|?\s*:?-{1,}:?\s*(\|\s*:?-{1,}:?\s*)+\|?\s*$/;
function mdAligns(sepLine) {
return mdTableCells(sepLine).map(c => {
const l = c.startsWith(":"), r = c.endsWith(":");
return l && r ? "center" : r ? "right" : l ? "left" : "";
});
}
const MD_HR = /^([-*_])\1{2,}$/; // --- *** ___ (spaces stripped)
// Markdown renders a practical GFM subset as safe React nodes (no innerHTML):
// headings, lists, tables, fenced code, blockquotes, rules, paragraphs.
function Markdown({ text }) {
const lines = String(text || "").split(/\r?\n/);
const blocks = [];
let i = 0;
const isSep = (l) => MD_TABLE_SEP.test(l);
while (i < lines.length) {
const t = lines[i].trim();
if (t === "") { i++; continue; }
// fenced code: ``` or ~~~ … (model output, incl. accidental mermaid) → code block
const fence = t.match(/^(```|~~~)/);
if (fence) {
const marker = fence[1];
i++;
const code = [];
while (i < lines.length && !lines[i].trim().startsWith(marker)) { code.push(lines[i]); i++; }
if (i < lines.length) i++; // skip closing fence
blocks.push({ type: "code", text: code.join("\n") });
continue;
}
// table: a row with pipes immediately followed by a |---|---| separator
if (t.includes("|") && i + 1 < lines.length && isSep(lines[i + 1])) {
const head = mdTableCells(t);
const align = mdAligns(lines[i + 1]);
i += 2;
const rows = [];
while (i < lines.length && lines[i].trim() !== "" && lines[i].includes("|") && !isSep(lines[i])) {
rows.push(mdTableCells(lines[i])); i++;
}
blocks.push({ type: "table", head, align, rows });
continue;
}
const h = t.match(/^(#{1,6})\s+(.*)$/);
if (h) { blocks.push({ type: "h", text: h[2] }); i++; continue; }
if (MD_HR.test(t.replace(/\s+/g, ""))) { blocks.push({ type: "hr" }); i++; continue; }
if (/^>\s?/.test(t)) {
const q = [];
while (i < lines.length && /^>\s?/.test(lines[i].trim())) { q.push(lines[i].trim().replace(/^>\s?/, "")); i++; }
blocks.push({ type: "quote", lines: q }); continue;
}
if (/^[*\-•]\s+/.test(t)) {
const items = [];
while (i < lines.length && /^\s*[*\-•]\s+/.test(lines[i])) { items.push(lines[i].replace(/^\s*[*\-•]\s+/, "")); i++; }
blocks.push({ type: "ul", items }); continue;
}
if (/^\d+\.\s+/.test(t)) {
const items = [];
while (i < lines.length && /^\s*\d+\.\s+/.test(lines[i])) { items.push(lines[i].replace(/^\s*\d+\.\s+/, "")); i++; }
blocks.push({ type: "ol", items }); continue;
}
const para = [t]; i++;
while (i < lines.length && lines[i].trim() !== ""
&& !/^\s*(#{1,6}\s|[*\-•]\s|\d+\.\s|>|```|~~~)/.test(lines[i])
&& !MD_HR.test(lines[i].replace(/\s+/g, ""))
&& !(lines[i].includes("|") && i + 1 < lines.length && isSep(lines[i + 1]))) {
para.push(lines[i].trim()); i++;
}
blocks.push({ type: "p", text: para.join(" ") });
}
return (
{blocks.map((b, k) => {
if (b.type === "h") return
{mdInline(b.text, "h" + k + "-")}
;
if (b.type === "hr") return
;
if (b.type === "code") return
{b.text}
;
if (b.type === "quote") return
{b.lines.map((ln, j) => {mdInline(ln, k + "q" + j + "-")}
)}
;
if (b.type === "table") return (
{b.head.map((c, j) => | {mdInline(c, k + "h" + j + "-")} | )}
{b.rows.map((r, ri) => {b.head.map((_, j) => | {mdInline(r[j] || "", k + "r" + ri + "c" + j + "-")} | )}
)}
);
if (b.type === "ul") return
{b.items.map((it, j) => - {mdInline(it, k + "-" + j + "-")}
)}
;
if (b.type === "ol") return
{b.items.map((it, j) => - {mdInline(it, k + "-" + j + "-")}
)}
;
return
{mdInline(b.text, k + "-")}
;
})}
);
}
// --- AI write-actions (proposed by the model, confirmed here by the user) ---
const STATUS_RU = { draft: "Черновик", in_progress: "В работе", sent: "Отправлено", accepted: "Принято", done: "Выполнено", cancelled: "Отменено" };
const MONTHS_RU = ["январь", "февраль", "март", "апрель", "май", "июнь", "июль", "август", "сентябрь", "октябрь", "ноябрь", "декабрь"];
function ruToISO(s) {
const m = String(s || "").match(/(\d{2})\.(\d{2})\.(\d{4})/);
return m ? `${m[3]}-${m[2]}-${m[1]}` : "";
}
// ActionCard renders a write-action the AI proposed and lets the user confirm it.
// Nothing is executed until the user clicks — execution goes through the normal
// REST API (so RBAC still applies). Create is an editable form; status/send are
// a plain confirm.
function ActionCard({ action, onResolved }) {
const me = window.ME || {};
const canWrite = me.role === "admin" || me.role === "controller";
const dir = useDirectory();
const p = (action && action.params) || {};
// Initial state comes from the persisted resolution so a confirmed/cancelled
// card stays resolved after a page refresh (it won't reappear as a form).
const [st, setSt] = React.useState(action._resolved || "pending"); // pending | busy | done | error | cancelled
const [err, setErr] = React.useState("");
const [okMsg, setOkMsg] = React.useState(action._result || "");
// create_task editable fields (declared unconditionally — hooks rule)
const [text, setText] = React.useState(p.text || "");
const [assignee, setAssignee] = React.useState(p.assignee || "");
const [dept, setDept] = React.useState(p.dept || "");
const [month, setMonth] = React.useState(MONTHS_RU.includes(p.month) ? p.month : MONTHS_RU[new Date().getMonth()]);
const [year, setYear] = React.useState(p.year || 2025);
const [due, setDue] = React.useState(ruToISO(p.dateEnd));
const [notify, setNotify] = React.useState(!!p.notify); // notify executor in Telegram after create
const run = async (fn, ok) => {
if (!window.API) return;
setSt("busy"); setErr("");
try {
const r = await fn();
const msg = typeof ok === "function" ? ok(r) : ok;
setOkMsg(msg);
setSt("done");
if (onResolved) onResolved({ _resolved: "done", _result: msg });
} catch (e) {
setErr(String((e && e.message) || e));
setSt("error");
}
};
const cancel = () => { setSt("cancelled"); if (onResolved) onResolved({ _resolved: "cancelled" }); };
const busy = st === "busy";
const disabledStyle = (cond) => (cond ? { opacity: 0.4, pointerEvents: "none" } : null);
const shell = (icon, title, body, confirmLabel, onConfirm, confirmDisabled) => (
{icon} {title}
{st === "done" ? (
{okMsg}
) : st === "cancelled" ? (
Отменено
) : (
<>
{body}
{err &&
{err}
}
{!canWrite &&
Изменения доступны контролёру или администратору.
}
>
)}
);
if (action.type === "create_task") {
const peopleOpts = Object.keys(dir.people || {}).map(n => ({ value: n, label: n, sub: (dir.people[n] || {}).dept }));
const body = (
{ setAssignee(name); const pp = (dir.people || {})[name]; if (pp && pp.dept) setDept(pp.dept); }} placeholder="ФИО…" />
);
return shell(
, "Создать поручение", body, "Создать",
() => run(async () => {
const created = await window.API.createTask({ mon: month, year: Number(year) || undefined, text: text.trim(), assignee, dept, dateEnd: due });
let suffix = "";
if (notify && window.API.sendTelegram) {
try { await window.API.sendTelegram(created.id); suffix = ", отправлено в Telegram"; }
catch (e) { suffix = " (в Telegram не отправлено: " + String((e && e.message) || e) + ")"; }
}
return { num: created.num, suffix };
}, r => `Создано поручение №${r.num}${r.suffix}`),
!text.trim()
);
}
if (action.type === "set_task_status") {
const body = Поручение #{p.id}: сменить статус на «{STATUS_RU[p.status] || p.status}».
;
return shell(
, "Сменить статус", body, "Подтвердить",
() => run(() => window.API.updateTask(p.id, { status: p.status }), "Статус обновлён"),
!p.id || !p.status
);
}
if (action.type === "send_task_telegram") {
const body = Отправить поручение #{p.id} исполнителю в Telegram.
;
return shell(
, "Отправить в Telegram", body, "Отправить",
() => run(() => window.API.sendTelegram(p.id), "Отправлено в Telegram"),
!p.id
);
}
return null;
}
window.ChatPage = ChatPage;