// 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 (

ИИ-ассистент

{subtitle}

История · {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 && (
Диалог стал большим — ответы могут замедлиться. Для точных и быстрых ответов начните новый чат.
)}