const state = { user: null, projects: [], activeProject: null, activeChat: null, chats: [], files: [], chatFiles: [], composerUploads: [], responding: false, streamController: null, agentStatusTimer: null, }; const $ = (selector) => document.querySelector(selector); const $$ = (selector) => [...document.querySelectorAll(selector)]; async function api(path, options = {}) { const response = await fetch(`/api${path}`, { credentials: "same-origin", ...options, }); if (response.status === 401 && path !== "/auth/login" && path !== "/auth/me") { showLogin(); throw new Error("Требуется вход"); } if (!response.ok) { let detail = "Произошла ошибка"; try { const payload = await response.json(); if (typeof payload.detail === "string" && payload.detail.trim()) { detail = payload.detail; } else if (Array.isArray(payload.detail)) { detail = payload.detail.map((item) => item.msg || item).join("; "); } else if (payload.detail) { detail = JSON.stringify(payload.detail); } } catch {} throw new Error(detail); } if (response.status === 204) return null; return response.json(); } function jsonOptions(method, body) { return { method, headers: { "Content-Type": "application/json" }, body: JSON.stringify(body), }; } function escapeHtml(value = "") { return value .replaceAll("&", "&") .replaceAll("<", "<") .replaceAll(">", ">") .replaceAll('"', """) .replaceAll("'", "'"); } function unescapeBasicHtml(value = "") { return String(value) .replaceAll("&", "&") .replaceAll("<", "<") .replaceAll(">", ">") .replaceAll(""", '"') .replaceAll("'", "'"); } function isSafeLinkUrl(url = "") { const value = String(url).trim(); return /^(https?:\/\/|mailto:|\/(?!\/))/i.test(value); } function isSafeImageUrl(url = "") { const value = String(url).trim(); return /^(https?:\/\/|\/api\/|data:image\/)/i.test(value); } function logExcerptHref(fileName, line) { const files = visibleFiles(); const base = String(fileName || "").replace(/\\/g, "/").split("/").pop().toLowerCase(); if (!base) return ""; const file = files.find((item) => String(item?.name || "").toLowerCase() === base) || files.find((item) => String(item?.name || "").toLowerCase().endsWith(base)); if (!file?.id) return ""; const n = Number(line) || 1; const start = Math.max(1, n - 25); const end = n + 25; return `/api/files/${encodeURIComponent(file.id)}/excerpt?start=${start}&end=${end}`; } function logCiteChip(fileName, line) { const href = logExcerptHref(fileName, line); const inner = `:${escapeHtml(String(line))}${escapeHtml(fileName)}`; if (href) { return `${inner}`; } return `${inner}`; } function formatInlineMarkdown(html = "") { const protectedParts = []; const protect = (markup) => { const index = protectedParts.length; protectedParts.push(markup); return `\u0000INLINE${index}\u0000`; }; let text = String(html) .replace(/::log:([^:\s]+):(\d+)::/g, (_match, path, line) => protect(logCiteChip(path, line))) .replace(/`([A-Za-z0-9_./\\-]+\.(?:log(?:\.\d+)?|[A-Za-z0-9]+)):(\d+)`/g, (_match, path, line) => protect(logCiteChip(path, line)), ) .replace(/`(\d+):([A-Za-z0-9_./\\-]+\.(?:log(?:\.\d+)?|[A-Za-z0-9]+))`/g, (_match, line, path) => protect(logCiteChip(path, line)), ) .replace(/`([^`\n]+)`/g, (_match, code) => protect(`${code}`)) .replace(/\\\((.+?)\\\)/g, (match) => protect(`${match}`)) .replace(/!\[([^\]]*)\]\(([^)\s]+)\)/g, (_match, alt, url) => { const href = unescapeBasicHtml(url).trim(); if (isSafeImageUrl(href)) { return protect( `${alt}`, ); } const label = alt || "изображение недоступно"; return protect( `[Нет изображения: ${label}]`, ); }) .replace(/\[([^\]]+)\]\(([^)\s]+)\)/g, (_match, label, url) => { const href = unescapeBasicHtml(url).trim(); if (!isSafeLinkUrl(href)) { return label; } return protect( `${label}`, ); }) .replace(/\*\*([^*]+)\*\*/g, "$1") .replace(/\*([^*\n]+)\*/g, "$1") .replace(/(^|[\s([{])_([^_\n]+)_(?=[\s)\]}.,!?:;]|$)/g, "$1$2") .replace( /(^|[\s(])(:(\d+):([A-Za-z0-9_./\\-]+))/g, (_match, prefix, _full, line, path) => `${prefix}${protect(logCiteChip(path, line))}`, ); return text.replace(/\u0000INLINE(\d+)\u0000/g, (_match, index) => protectedParts[Number(index)] || ""); } function splitTableCells(line = "") { let trimmed = line.trim(); if (trimmed.startsWith("|")) trimmed = trimmed.slice(1); if (trimmed.endsWith("|")) trimmed = trimmed.slice(0, -1); return trimmed.split("|").map((cell) => cell.trim()); } function isTableSeparator(line = "") { const cells = splitTableCells(line); if (!cells.length) return false; return cells.every((cell) => { const token = cell.replace(/\s/g, ""); return /^:?-{3,}:?$/.test(token); }); } function renderMarkdownTable(lines, start) { if (start + 1 >= lines.length) return null; const headerLine = lines[start]; const separatorLine = lines[start + 1]; if (!headerLine.trim().includes("|")) return null; if (!isTableSeparator(separatorLine)) return null; const headers = splitTableCells(headerLine); if (!headers.length) return null; const rows = []; let index = start + 2; while (index < lines.length) { const rowLine = lines[index]; if (!rowLine.trim() || !rowLine.includes("|") || isTableSeparator(rowLine)) break; rows.push(splitTableCells(rowLine)); index += 1; } const head = headers .map((cell) => `${formatInlineMarkdown(cell)}`) .join(""); const body = rows .map((row) => { const cells = headers .map((_, cellIndex) => `${formatInlineMarkdown(row[cellIndex] || "")}`) .join(""); return `${cells}`; }) .join(""); return { html: `
${head}${body}
`, end: index, }; } function isDarkTheme() { return document.documentElement.dataset.theme === "dark"; } function getMermaidTheme() { return isDarkTheme() ? "dark" : "neutral"; } function initMermaid() { if (!window.mermaid) return; const dark = isDarkTheme(); window.mermaid.initialize({ startOnLoad: false, theme: getMermaidTheme(), securityLevel: "antiscript", fontFamily: "Manrope, system-ui, sans-serif", themeVariables: dark ? { // Match chat dark surface; avoid cream/white voids from light pads. background: "#262620", primaryColor: "#2c2c25", primaryTextColor: "#efede4", primaryBorderColor: "#9d9a8e", lineColor: "#c8c4b8", secondaryColor: "#32322a", tertiaryColor: "#262620", mainBkg: "#2c2c25", nodeBkg: "#2c2c25", nodeBorder: "#9d9a8e", clusterBkg: "#21211c", clusterBorder: "#3b3a32", titleColor: "#efede4", edgeLabelBackground: "#262620", fontFamily: "Manrope, system-ui, sans-serif", } : { background: "#f5f5f0", primaryColor: "#efece3", primaryTextColor: "#1a1a16", primaryBorderColor: "#8a8678", lineColor: "#3a3a32", secondaryColor: "#e4e1d6", tertiaryColor: "#f5f5f0", fontFamily: "Manrope, system-ui, sans-serif", }, }); } function showMermaidFailure(node, error) { const wrap = node.closest(".msg-mermaid-wrap"); const sourceEl = wrap?.querySelector(".msg-mermaid-source"); const container = wrap?.querySelector(".msg-mermaid") || node.parentElement; if (!container) return; const detail = error?.message || String(error || "unknown error"); const source = sourceEl?.textContent || node.textContent || ""; container.innerHTML = `
Не удалось отрисовать диаграмму: ${escapeHtml(detail)}
${escapeHtml(source)}
`; if (sourceEl) sourceEl.hidden = false; } async function renderMermaidDiagrams(root = $("#messages")) { if (!window.mermaid || !root) return; const nodes = [...root.querySelectorAll("pre.mermaid")]; if (!nodes.length) return; for (const node of nodes) { try { await window.mermaid.run({ nodes: [node] }); } catch (error) { console.warn("Mermaid render failed", error); showMermaidFailure(node, error); } } } function closeDiagramLightbox() { const lightbox = $("#diagramLightbox"); if (!lightbox) return; lightbox.classList.add("hidden"); const inner = $("#diagramLightboxInner"); if (inner) inner.innerHTML = ""; } function openDiagramLightbox(fromEl) { const lightbox = $("#diagramLightbox"); const inner = $("#diagramLightboxInner"); if (!lightbox || !inner || !fromEl) return; inner.innerHTML = ""; if (fromEl.tagName === "IMG") { const clone = document.createElement("img"); clone.src = fromEl.currentSrc || fromEl.src; clone.alt = fromEl.alt || "Диаграмма"; inner.appendChild(clone); } else { const svg = fromEl.closest?.("svg") || fromEl.querySelector?.("svg") || fromEl; if (!svg || String(svg.tagName).toLowerCase() !== "svg") return; // Clone the rendered SVG. Mermaid often sets width="100%", which collapses // inside the lightbox (no definite containing width) — size from viewBox/bbox. const clone = svg.cloneNode(true); const vb = svg.viewBox?.baseVal; const rect = svg.getBoundingClientRect(); const w = (vb && vb.width > 0 ? vb.width : rect.width) || svg.clientWidth || 800; const h = (vb && vb.height > 0 ? vb.height : rect.height) || svg.clientHeight || 600; if (!clone.getAttribute("viewBox") && w && h) { const minX = vb ? vb.x : 0; const minY = vb ? vb.y : 0; clone.setAttribute("viewBox", `${minX} ${minY} ${w} ${h}`); } clone.removeAttribute("width"); clone.removeAttribute("height"); clone.style.cssText = "width:auto;height:auto;max-width:95vw;max-height:90vh;"; clone.setAttribute("preserveAspectRatio", "xMidYMid meet"); // Keep intrinsic ratio for CSS max-* sizing. clone.setAttribute("width", String(Math.round(w))); clone.setAttribute("height", String(Math.round(h))); inner.appendChild(clone); } lightbox.classList.remove("hidden"); } function renderKatexMath(root = $("#messages")) { if (!window.renderMathInElement || !root) return; try { window.renderMathInElement(root, { delimiters: [ { left: "$$", right: "$$", display: true }, { left: "\\(", right: "\\)", display: false }, ], throwOnError: false, strict: "ignore", }); } catch (error) { console.warn("KaTeX render failed", error); } } function formatMessageHtml(content = "", role = "assistant", { renderMermaid = true } = {}) { const text = String(content).trimEnd(); if (role === "user") { return escapeHtml(text); } const blocks = []; let withFences = text.replace(/```(\w*)\n?([\s\S]*?)```/g, (_match, lang, code) => { const index = blocks.length; const safeCode = escapeHtml(code.replace(/\n$/, "")); const langNorm = String(lang || "").toLowerCase(); if (langNorm === "mermaid" && renderMermaid) { blocks.push( `
${safeCode}
` ); } else { blocks.push( `
${safeCode}
` ); } return `\n\n\u0000BLOCK${index}\u0000\n\n`; }); withFences = withFences.replace(/\$\$([\s\S]+?)\$\$/g, (_match, expr) => { const index = blocks.length; const safeExpr = escapeHtml(String(expr).trim()); blocks.push(`
$$${safeExpr}$$
`); return `\n\n\u0000BLOCK${index}\u0000\n\n`; }); const lines = escapeHtml(withFences).split("\n"); const output = []; let paragraph = []; let index = 0; const flushParagraph = () => { if (!paragraph.length) return; const body = formatInlineMarkdown(paragraph.join("\n")); output.push(`

${body.replace(/\n/g, "
")}

`); paragraph = []; }; const consumeQuote = (start) => { const quoteLines = []; let cursor = start; while (cursor < lines.length) { const trimmed = lines[cursor].trim(); const match = trimmed.match(/^>\s?(.*)$/); if (!match) break; quoteLines.push(match[1]); cursor += 1; } if (!quoteLines.length) return null; const body = formatInlineMarkdown(quoteLines.join("\n")).replace(/\n/g, "
"); return { html: `
${body}
`, end: cursor, }; }; const consumeList = (start) => { const first = lines[start].trim(); const taskRe = /^([*-+])\s+\[([ xX])\]\s+(.+)$/; const ulRe = /^([*-+])\s+(?!\[[ xX]\])(.+)$/; const olRe = /^(\d+)\.\s+(.+)$/; let kind = null; if (taskRe.test(first)) kind = "task"; else if (olRe.test(first)) kind = "ol"; else if (ulRe.test(first)) kind = "ul"; else return null; const items = []; let cursor = start; while (cursor < lines.length) { const trimmed = lines[cursor].trim(); if (kind === "task") { const match = trimmed.match(taskRe); if (!match) break; const checked = match[2].toLowerCase() === "x"; items.push( `
  • ${formatInlineMarkdown(match[3])}
  • `, ); } else if (kind === "ol") { const match = trimmed.match(olRe); if (!match) break; items.push(`
  • ${formatInlineMarkdown(match[2])}
  • `); } else { const match = trimmed.match(ulRe); if (!match) break; items.push(`
  • ${formatInlineMarkdown(match[2])}
  • `); } cursor += 1; } if (!items.length) return null; if (kind === "task") { return { html: ``, end: cursor }; } if (kind === "ol") { return { html: `
      ${items.join("")}
    `, end: cursor }; } return { html: ``, end: cursor }; }; while (index < lines.length) { const line = lines[index]; const trimmed = line.trim(); if (!trimmed) { flushParagraph(); index += 1; continue; } const blockMatch = trimmed.match(/^\u0000BLOCK(\d+)\u0000$/); if (blockMatch) { flushParagraph(); output.push(blocks[Number(blockMatch[1])]); index += 1; continue; } if (/^(-{3,}|\*{3,}|_{3,})$/.test(trimmed)) { flushParagraph(); output.push('
    '); index += 1; continue; } const headerMatch = trimmed.match(/^(#{1,6})\s+(.+)$/); if (headerMatch) { flushParagraph(); const level = Math.min(headerMatch[1].length, 6); output.push( `${formatInlineMarkdown(headerMatch[2])}`, ); index += 1; continue; } const quote = consumeQuote(index); if (quote) { flushParagraph(); output.push(quote.html); index = quote.end; continue; } const list = consumeList(index); if (list) { flushParagraph(); output.push(list.html); index = list.end; continue; } const table = renderMarkdownTable(lines, index); if (table) { flushParagraph(); output.push(table.html); index = table.end; continue; } paragraph.push(line); index += 1; } flushParagraph(); return output.join(""); } function formatDate(value) { const date = new Date(value); const today = new Date(); const sameDay = date.toDateString() === today.toDateString(); return new Intl.DateTimeFormat("ru", sameDay ? { hour: "2-digit", minute: "2-digit" } : { day: "numeric", month: "short" } ).format(date); } function formatSize(bytes) { const size = Number(bytes) || 0; if (size < 1024) return `${size} Б`; if (size < 1024 * 1024) return `${(size / 1024).toFixed(1)} КБ`; return `${(size / 1024 / 1024).toFixed(1)} МБ`; } function showToast(message) { const toast = $("#toast"); toast.textContent = message; toast.classList.add("visible"); clearTimeout(showToast.timer); showToast.timer = setTimeout(() => toast.classList.remove("visible"), 2600); } function showLogin() { $("#loginScreen").classList.remove("hidden"); $("#appShell").classList.add("hidden"); if (state.agentStatusTimer) { clearInterval(state.agentStatusTimer); state.agentStatusTimer = null; } } function showApp() { $("#loginScreen").classList.add("hidden"); $("#appShell").classList.remove("hidden"); renderUserChrome(); } function renderUserChrome() { if (!state.user) return; const name = state.user.display_name || state.user.username; $("#userDisplayName").textContent = name; $("#userRoleLabel").textContent = state.user.role; $("#userAvatar").textContent = (name[0] || "?").toUpperCase(); $("#usersNav").classList.toggle("hidden", state.user.role !== "admin"); } function showView(viewName) { $$(".view").forEach((view) => view.classList.remove("active")); $(`#${viewName}View`).classList.add("active"); $$(".nav-item").forEach((item) => { if (viewName === "users") { item.classList.toggle("active", item.dataset.view === "users"); } else { item.classList.toggle( "active", item.dataset.view === "projects" || item.dataset.view === "home", ); } }); $(".sidebar").classList.remove("open"); } function openModal(id) { $(id).classList.remove("hidden"); setTimeout(() => $(id).querySelector("input, textarea")?.focus(), 50); } function closeModal(id) { $(id).classList.add("hidden"); } function closeMenus() { $$(".menu-dropdown").forEach((menu) => menu.classList.add("hidden")); } function messageRoleLabel(message) { if (message.role === "assistant") return "Помощник"; if (!state.user) return message.author_name || "Вы"; if (state.user.role === "admin") { return message.author_name || message.user_id || "Пользователь"; } if (message.user_id && message.user_id === state.user.id) return "Вы"; return message.author_name || "Коллега"; } function setResponding(active, queued = false) { state.responding = active; $("#messageSend").classList.toggle("hidden", active); $("#messageStop").classList.toggle("hidden", !active); $("#messageSend").disabled = active; $("#messageInput").placeholder = queued ? "В очереди…" : active ? "Генерация ответа…" : "Напишите сообщение…"; } async function refreshAgentStatus() { try { const status = await api("/agent/status"); const dot = $("#agentStatusDot"); const text = $("#agentStatusText"); let label = "idle"; let cls = "idle"; if (!status.key_ok) { label = "no key"; cls = "error"; } else if (!status.sdk_ok) { label = "no sdk"; cls = "error"; } else if (status.busy) { const n = Number(status.active_count) || (status.active_chats || []).length || 1; label = n > 1 ? `busy×${n}` : "busy"; cls = "busy"; } text.textContent = label; dot.className = `status-dot ${cls}`; } catch { $("#agentStatusText").textContent = "—"; $("#agentStatusDot").className = "status-dot error"; } } async function loadProjects() { state.projects = await api("/projects"); renderProjects(); renderProjectNavigation(); await renderRecents(); } function renderProjects() { const grid = $("#projectGrid"); const empty = $("#projectsEmpty"); grid.innerHTML = state.projects.map((project) => `
    ${escapeHtml(project.icon)}

    ${escapeHtml(project.name)}

    ${escapeHtml(project.description || "Без описания")}

    ${project.chat_count} ${plural(project.chat_count, "чат", "чата", "чатов")} ${project.file_count} ${plural(project.file_count, "файл", "файла", "файлов")}
    `).join(""); empty.classList.toggle("hidden", state.projects.length > 0); grid.classList.toggle("hidden", state.projects.length === 0); $$(".project-card").forEach((card) => { card.addEventListener("click", () => openProject(card.dataset.projectId)); }); } function plural(number, one, few, many) { const mod10 = number % 10; const mod100 = number % 100; if (mod10 === 1 && mod100 !== 11) return one; if (mod10 >= 2 && mod10 <= 4 && (mod100 < 12 || mod100 > 14)) return few; return many; } function renderProjectNavigation() { const list = $("#projectNavList"); if (!list) return; // Replace contents (never append) so load/open cannot double-render rows. list.replaceChildren(); const fragment = document.createDocumentFragment(); state.projects.forEach((project) => { const button = document.createElement("button"); button.type = "button"; button.className = `side-row ${state.activeProject?.id === project.id ? "active" : ""}`; button.dataset.sideProject = project.id; button.innerHTML = ` ${escapeHtml(project.icon)} ${escapeHtml(project.name)} `; button.addEventListener("click", () => openProject(project.id)); fragment.appendChild(button); }); list.appendChild(fragment); } async function renderRecents() { const allChats = (await Promise.all( state.projects.map(async (project) => { const chats = await api(`/projects/${project.id}/chats`); return chats.map((chat) => ({ ...chat, projectName: project.name })); }) )).flat().sort((a, b) => new Date(b.updated_at) - new Date(a.updated_at)).slice(0, 12); $("#recentList").innerHTML = allChats.length ? allChats.map((chat) => ` `).join("") : ''; $$("[data-recent-chat]").forEach((item) => { item.addEventListener("click", async () => { if (state.activeProject?.id !== item.dataset.recentProject) { await openProject(item.dataset.recentProject, false); } await openChat(item.dataset.recentChat); }); }); } async function openProject(projectId, navigate = true) { const previousProjectId = state.activeProject?.id; state.activeProject = state.projects.find((project) => project.id === projectId); if (!state.activeProject) return; if (previousProjectId && previousProjectId !== projectId) { state.composerUploads = []; } state.chatFiles = []; [state.chats, state.files] = await Promise.all([ api(`/projects/${projectId}/chats`), api(`/projects/${projectId}/files`), ]); $("#projectIcon").textContent = state.activeProject.icon; $("#projectName").textContent = state.activeProject.name; $("#projectDescription").textContent = state.activeProject.description || "Без описания"; $("#instructionsPreview").textContent = state.activeProject.instructions || "Добавьте правила и контекст для всех разговоров проекта."; const model = state.activeProject.model || "auto"; const select = $("#modelSelect"); if (![...select.options].some((opt) => opt.value === model)) { select.insertAdjacentHTML("beforeend", ``); } select.value = model; $("#breadcrumbs").textContent = `Проекты / ${state.activeProject.name}`; renderChats(); renderFiles(); renderComposerAttachments(); renderProjectNavigation(); if (navigate) showView("project"); } function renderChats() { const list = $("#chatList"); list.innerHTML = state.chats.map((chat) => `
    ${escapeHtml(chat.title)} ${formatDate(chat.updated_at)}
    `).join(""); $("#chatsEmpty").classList.toggle("hidden", state.chats.length > 0); $$("#chatList [data-chat-id]").forEach((row) => { row.addEventListener("click", () => { if (row.dataset.didDrag === "1") { row.dataset.didDrag = "0"; return; } openChat(row.dataset.chatId); }); }); bindChatListDrag(); } let chatDragId = null; function bindChatListDrag() { const list = $("#chatList"); if (!list || list.dataset.dndBound === "1") return; list.dataset.dndBound = "1"; list.addEventListener("dragstart", (event) => { const row = event.target.closest("[data-chat-id]"); if (!row || !list.contains(row)) return; chatDragId = row.dataset.chatId; row.dataset.didDrag = "1"; row.classList.add("dragging"); event.dataTransfer.effectAllowed = "move"; event.dataTransfer.setData("text/plain", chatDragId); }); list.addEventListener("dragend", () => { $$("#chatList .chat-row").forEach((row) => row.classList.remove("dragging", "drag-over")); chatDragId = null; }); list.addEventListener("dragover", (event) => { const row = event.target.closest("[data-chat-id]"); if (!row || !chatDragId || row.dataset.chatId === chatDragId) return; event.preventDefault(); $$("#chatList .chat-row").forEach((item) => item.classList.toggle("drag-over", item === row)); }); list.addEventListener("dragleave", (event) => { const row = event.target.closest("[data-chat-id]"); if (row) row.classList.remove("drag-over"); }); list.addEventListener("drop", async (event) => { event.preventDefault(); const target = event.target.closest("[data-chat-id]"); $$("#chatList .chat-row").forEach((row) => row.classList.remove("drag-over", "dragging")); const sourceId = chatDragId || event.dataTransfer.getData("text/plain"); chatDragId = null; if (!target || !sourceId || sourceId === target.dataset.chatId || !state.activeProject) return; const ids = state.chats.map((chat) => chat.id); const from = ids.indexOf(sourceId); const to = ids.indexOf(target.dataset.chatId); if (from < 0 || to < 0) return; ids.splice(from, 1); ids.splice(to, 0, sourceId); const previous = state.chats.slice(); state.chats = ids.map((id) => previous.find((chat) => chat.id === id)).filter(Boolean); renderChats(); try { state.chats = await api(`/projects/${state.activeProject.id}/chats/order`, jsonOptions("PUT", { chat_ids: ids, })); renderChats(); } catch (error) { state.chats = previous; renderChats(); showToast(error.message); } }); } const ATTACH_FOOTER_RE = /\n\n\[Прикреплённые файлы проекта:\s*([^\]]+)\]\s*$/; function fileExtensionBadge(name) { const raw = String(name || ""); const dot = raw.lastIndexOf("."); if (dot < 0 || dot === raw.length - 1) return "FILE"; const ext = raw.slice(dot + 1).replace(/[^a-zA-Z0-9]/g, ""); if (!ext) return "FILE"; return ext.slice(0, 5).toUpperCase(); } function iconDownloadSvg() { return ``; } function iconTrashSvg() { return ``; } function iconCloseSvg() { return ``; } function fileDownloadHref(fileId) { if (!fileId || String(fileId).startsWith("local-")) return ""; return `/api/files/${encodeURIComponent(fileId)}`; } function visibleFiles() { const byId = new Map(); for (const file of [...(state.files || []), ...(state.chatFiles || [])]) { if (file?.id) byId.set(file.id, file); } return [...byId.values()]; } function fileIsChatScoped(file, chatId) { return (file?.scope === "chat" || file?.chat_id) && file?.chat_id === chatId; } function localComposerId() { if (typeof crypto !== "undefined" && typeof crypto.randomUUID === "function") { return `local-${crypto.randomUUID()}`; } return `local-${Date.now()}-${Math.random().toString(16).slice(2)}`; } function isLocalComposerFile(file) { return Boolean(file?.localFile) || String(file?.id || "").startsWith("local-"); } function formatThinkingDuration(ms) { const value = Number(ms); if (!Number.isFinite(value) || value < 0) return ""; const sec = Math.max(1, Math.round(value / 1000)); if (sec < 60) return `${sec} с`; const minutes = Math.floor(sec / 60); const rest = sec % 60; return rest ? `${minutes} мин ${rest} с` : `${minutes} мин`; } function thinkingStepTitle(step) { const type = String(step?.type || ""); if (step?.title) return String(step.title); if (type === "thinking" || type === "narration") return "Размышления"; if (type === "status") return "Статус"; return "Инструмент"; } const JUNK_STATUS_TOKENS = new Set([ "running", "finished", "done", "completed", "complete", "success", "ok", "ready", "idle", "завершено", "готово", ]); function compactStatusToken(text) { return String(text || "").trim().toLowerCase().replace(/[^a-zа-яё]+/g, ""); } function isJunkActivityStep(step) { const type = String(step?.type || ""); const detail = compactStatusToken(step?.detail); const title = compactStatusToken(step?.title); if (type === "status" && (JUNK_STATUS_TOKENS.has(detail) || JUNK_STATUS_TOKENS.has(title))) { return true; } if (type === "thinking" && JUNK_STATUS_TOKENS.has(detail)) { return true; } return false; } function isThinkingStepType(type) { return type === "thinking"; } function mergeThinkingDetail(prev, incoming) { const before = String(prev || ""); const next = String(incoming || ""); if (!next) return before; if (!before) return next; if (next === before || before.endsWith(next)) return before; if (next.startsWith(before)) return next; if (before.includes(next) && next.length > 12) return before; return `${before}${next}`; } function thinkingStepHtml(step) { const type = String(step?.type || "thinking"); const status = String(step?.status || ""); const title = escapeHtml(thinkingStepTitle(step)); const detail = String(step?.detail || ""); const isThink = type === "thinking"; const expandable = !isThink && detail.length > 220; let detailHtml = ""; if (detail) { if (expandable) { detailHtml = `
    ${escapeHtml(detail.slice(0, 180))}…
    подробнее
    ${escapeHtml(detail)}
    `; } else { detailHtml = `
    ${escapeHtml(detail)}
    `; } } const titleHtml = isThink ? "" : `
    ${title}
    `; return `
  • ${titleHtml}${detailHtml}
  • `; } function renderThinkingStepsHtml(steps) { const items = Array.isArray(steps) ? coalesceThinkingSteps(steps.filter((step) => step && (step.title || step.detail) && !isJunkActivityStep(step))) : []; if (!items.length) return ""; return `
      ${items.map(thinkingStepHtml).join("")}
    `; } function coalesceThinkingSteps(steps) { const merged = []; for (const step of steps || []) { const type = String(step?.type || "thinking"); const last = merged[merged.length - 1]; if (isThinkingStepType(type) && last && isThinkingStepType(last.type)) { last.detail = mergeThinkingDetail(last.detail, step.detail); last.status = step.status || last.status; last.title = last.title || step.title; continue; } merged.push({ ...step, type }); } return merged; } function normalizeMessageActivity(message) { if (Array.isArray(message?.activity) && message.activity.length) { return coalesceThinkingSteps( message.activity.filter((step) => step && (step.title || step.detail) && !isJunkActivityStep(step)), ); } const thinking = String(message?.thinking || "").trim(); if (!thinking) return []; return [{ type: "thinking", title: "Размышления", detail: thinking, status: "completed" }]; } function thinkingToggleHtml({ label = "Размышления", durationMs = null, collapsed = true, content = "", steps = null, streaming = false, } = {}) { const duration = formatThinkingDuration(durationMs); const openClass = collapsed ? "collapsed" : "open"; const body = (steps && steps.length) ? renderThinkingStepsHtml(steps) : escapeHtml(content); return `
    ${body}
    `; } function bindThinkingToggles(root) { root.querySelectorAll(".thinking-toggle").forEach((button) => { button.addEventListener("click", (event) => { event.preventDefault(); const block = button.closest(".thinking-block"); if (!block) return; block.classList.toggle("collapsed"); block.classList.toggle("open", !block.classList.contains("collapsed")); }); }); } function normalizeAttachmentList(attachments) { if (!Array.isArray(attachments)) return []; return attachments .map((item) => ({ id: item?.id || "", name: item?.name || "файл", })) .filter((item) => item.id || item.name); } function parseMessageAttachments(message) { const stored = normalizeAttachmentList(message?.attachments); if (stored.length) return stored; const match = String(message?.content || "").match(ATTACH_FOOTER_RE); if (!match) return []; return match[1] .split(",") .map((part) => part.trim()) .filter(Boolean) .map((name) => { const file = visibleFiles().find((entry) => entry.name === name); return { id: file?.id || "", name }; }); } function messageBodyContent(message) { return String(message?.content || "").replace(ATTACH_FOOTER_RE, ""); } function renderAttachmentChips(attachments, { removable = false } = {}) { return attachments.map((file) => { const name = file?.name || "файл"; const id = file?.id || ""; const badge = fileExtensionBadge(name); const href = fileDownloadHref(id); const nameHtml = href ? `${escapeHtml(name)}` : `${escapeHtml(name)}`; const removeHtml = removable ? `` : (href ? `${iconDownloadSvg()}` : ""); return ` ${escapeHtml(badge)} ${nameHtml} ${removeHtml} `; }).join(""); } function renderFiles() { const list = $("#fileList"); const empty = $("#filesEmpty"); if (!list) return; list.innerHTML = state.files.map((file) => { const extension = fileExtensionBadge(file.name); const href = fileDownloadHref(file.id); return `
    ${escapeHtml(extension)} ${escapeHtml(file.name)} ${formatSize(file.size)} ${iconDownloadSvg()}
    `; }).join(""); empty?.classList.toggle("hidden", state.files.length > 0); $$("[data-file-delete]").forEach((button) => { button.addEventListener("click", async (event) => { event.preventDefault(); event.stopPropagation(); try { await api(`/files/${button.dataset.fileDelete}`, { method: "DELETE" }); state.composerUploads = state.composerUploads.filter( (file) => file.id !== button.dataset.fileDelete, ); renderComposerAttachments(); await openProject(state.activeProject.id, false); showToast("Файл удалён"); } catch (error) { showToast(error.message); } }); }); } async function loadUsers() { const users = await api("/users"); $("#usersTable").innerHTML = `
    ИмяЛогинРоль
    ${users.map((user) => `
    ${escapeHtml(user.display_name)} ${escapeHtml(user.username)} ${escapeHtml(user.role)}
    `).join("")} `; } function composerMessageFromUploads(typed, uploads) { const snapshot = (uploads || []).map((file) => ({ ...file })); const named = snapshot.filter((file) => file?.name); const text = String(typed || "").trim(); let content = text; if (named.length) { const names = named.map((file) => file.name).join(", "); content = text ? `${text}\n\n[Прикреплённые файлы проекта: ${names}]` : `[Прикреплённые файлы проекта: ${names}]`; } return { content, attachments: named .filter((file) => file.id && !isLocalComposerFile(file)) .map((file) => ({ id: file.id, name: file.name })), snapshot, }; } async function createChat(initialMessage = "", { attachUploads = false } = {}) { if (!state.activeProject) { if (!state.projects.length) return openModal("#projectModal"); await openProject(state.projects[0].id, false); } const typed = String(initialMessage || "").trim(); const pendingUploads = attachUploads ? state.composerUploads.slice() : []; const sendNow = Boolean(typed) || pendingUploads.length > 0; const chat = await api("/chats", jsonOptions("POST", { project_id: state.activeProject.id, title: "Новый чат", })); state.chats.unshift(chat); if (sendNow) { try { if (pendingUploads.length) { await flushComposerUploads(chat.id); } const payload = composerMessageFromUploads(typed, state.composerUploads); state.composerUploads = []; renderComposerAttachments(); await api(`/chats/${chat.id}/messages`, jsonOptions("POST", { role: "user", content: payload.content, attachments: payload.attachments, })); } catch (error) { state.composerUploads = pendingUploads; renderComposerAttachments(); await openProject(state.activeProject.id, false); await openChat(chat.id); throw error; } } await openProject(state.activeProject.id, false); await openChat(chat.id); if (sendNow) await requestAssistant(); await loadProjects(); } async function openChat(chatId, { messageId = "" } = {}) { const previousChatId = state.activeChat?.id; state.activeChat = state.chats.find((chat) => chat.id === chatId); if (!state.activeChat) { state.chats = await api(`/projects/${state.activeProject.id}/chats`); state.activeChat = state.chats.find((chat) => chat.id === chatId); } if (!state.activeChat) return; hideGenProgress(); // Pending uploads stay when opening a chat from the project screen. // Clear only when switching between two different chats already in chat view. const leavingChatView = $("#chatView")?.classList.contains("active"); if (leavingChatView && previousChatId && previousChatId !== chatId) { state.composerUploads = []; } renderComposerAttachments(); $("#chatTitle").textContent = state.activeChat.title; $("#chatProjectName").textContent = state.activeProject.name; $("#breadcrumbs").textContent = `${state.activeProject.name} / ${state.activeChat.title}`; const exportHref = `/api/chats/${encodeURIComponent(chatId)}/export`; const exportBtn = $("#exportChat"); const exportMenu = $("#exportChatMenu"); if (exportBtn) exportBtn.href = exportHref; if (exportMenu) exportMenu.href = exportHref; try { const listed = await api( `/projects/${state.activeProject.id}/files?chat_id=${encodeURIComponent(chatId)}`, ); state.chatFiles = listed.filter((file) => fileIsChatScoped(file, chatId)); } catch { state.chatFiles = []; } const messages = await api(`/chats/${chatId}/messages`); renderMessages(messages, { highlightId: messageId }); showView("chat"); } function bindMessageActions(root) { root.querySelectorAll("[data-copy-code]").forEach((button) => { button.addEventListener("click", async () => { const code = root.querySelector(`[data-code-block="${button.dataset.copyCode}"]`); try { await navigator.clipboard.writeText(code?.textContent || ""); showToast("Код скопирован"); } catch { showToast("Не удалось скопировать"); } }); }); root.querySelectorAll("[data-quote]").forEach((button) => { button.addEventListener("click", () => { let raw = ""; try { raw = decodeURIComponent(button.getAttribute("data-quote") || ""); } catch { raw = button.getAttribute("data-quote") || ""; } const quoted = raw .split("\n") .map((line) => `> ${line}`) .join("\n"); const input = $("#messageInput"); const prefix = input.value.trim() ? `${input.value.trim()}\n\n` : ""; input.value = `${prefix}${quoted}\n\n`; input.focus(); }); }); } function renderMessages(messages, { highlightId = "" } = {}) { $("#messages").innerHTML = messages.length ? messages.map((message) => { const isUser = message.role === "user"; const bodyContent = messageBodyContent(message); const attachments = parseMessageAttachments(message); const quotePayload = encodeURIComponent(bodyContent || ""); const chipsHtml = attachments.length ? `
    ${renderAttachmentChips(attachments)}
    ` : ""; const activity = (!isUser) ? normalizeMessageActivity(message) : []; const thinkingHtml = activity.length ? thinkingToggleHtml({ label: "Размышления", durationMs: message.thinking_duration_ms, collapsed: true, steps: activity, }) : ""; const highlightClass = highlightId && message.id === highlightId ? " highlight" : ""; return `
    ${escapeHtml(messageRoleLabel(message))}
    ${chipsHtml} ${thinkingHtml}
    ${formatMessageHtml(bodyContent, message.role)}
    `; }).join("") : '
    Начните разговор.
    Чаты общие для всех пользователей проекта.
    '; bindMessageActions($("#messages")); bindThinkingToggles($("#messages")); const highlightRow = highlightId ? $("#messages").querySelector(`[data-message-id="${CSS.escape(highlightId)}"]`) : null; if (highlightRow) { highlightRow.scrollIntoView({ block: "center" }); } else { $("#messages").scrollTop = $("#messages").scrollHeight; } void renderMermaidDiagrams($("#messages")).then(() => { renderKatexMath($("#messages")); }); } let streamActivity = { steps: [], usedActivity: false, thinkingText: "" }; function resetStreamActivity() { streamActivity = { steps: [], usedActivity: false, thinkingText: "" }; } function ensureStreamingBubble() { let row = $("#assistantStreaming"); if (row) return row; resetStreamActivity(); $("#messages").insertAdjacentHTML("beforeend", `
    Помощник
    ${thinkingToggleHtml({ label: "Думаю", collapsed: false, content: "", steps: [], streaming: true, })}
    `); const created = $("#assistantStreaming"); bindThinkingToggles(created); created.dataset.thinkingStartedAt = String(Date.now()); return created; } function streamingThinkingEls() { const block = $("#assistantStreaming .thinking-block"); return { block, label: block?.querySelector(".thinking-label"), duration: block?.querySelector(".thinking-duration"), content: block?.querySelector(".thinking-content"), }; } function renderStreamingThinking() { const { block, content } = streamingThinkingEls(); if (!block || !content) return; const steps = streamActivity.steps; content.innerHTML = steps.length ? renderThinkingStepsHtml(steps) : escapeHtml(streamActivity.thinkingText); block.classList.remove("collapsed", "hidden"); block.classList.add("open", "streaming"); content.scrollTop = content.scrollHeight; } function appendStreamingThinking(text, { replace = false } = {}) { if (replace) { // History must accumulate; ignore replace from older servers. } const chunk = String(text || ""); if (!chunk.trim() || JUNK_STATUS_TOKENS.has(compactStatusToken(chunk))) { renderStreamingThinking(); return; } streamActivity.thinkingText += chunk; const last = streamActivity.steps[streamActivity.steps.length - 1]; if (last && isThinkingStepType(last.type)) { last.detail = mergeThinkingDetail(last.detail, chunk); } else { streamActivity.steps.push({ id: last?.id === "think-fallback" ? "think-fallback" : `think-${streamActivity.steps.length + 1}`, type: "thinking", title: "Размышления", detail: chunk, status: "running", }); } renderStreamingThinking(); } function upsertStreamingActivity(step) { if (!step || typeof step !== "object") return; const incoming = { ...step }; incoming.type = String(incoming.type || (incoming.title === "Размышления" || incoming.title === "Думаю" ? "thinking" : incoming.type || "")); if (isJunkActivityStep(incoming)) return; if (!streamActivity.usedActivity) { streamActivity.steps = streamActivity.steps.filter((item) => item.id !== "think-fallback"); streamActivity.usedActivity = true; } const last = streamActivity.steps[streamActivity.steps.length - 1]; if (isThinkingStepType(incoming.type) && last && isThinkingStepType(last.type)) { last.detail = mergeThinkingDetail(last.detail, incoming.detail); last.status = incoming.status || last.status; last.title = incoming.title || last.title; if (incoming.id && !last.id) last.id = incoming.id; renderStreamingThinking(); return; } const idx = streamActivity.steps.findIndex((item) => item.id && incoming.id && item.id === incoming.id); if (idx >= 0) { const prev = streamActivity.steps[idx]; const sameThinking = isThinkingStepType(prev.type) && isThinkingStepType(incoming.type); streamActivity.steps[idx] = { ...prev, ...incoming, detail: sameThinking ? mergeThinkingDetail(prev.detail, incoming.detail) : (incoming.detail != null ? incoming.detail : prev.detail), }; } else { streamActivity.steps.push({ ...incoming }); } renderStreamingThinking(); } function finishStreamingThinking(durationMs) { const row = $("#assistantStreaming"); const { block, label, duration, content } = streamingThinkingEls(); if (!block) return; const started = Number(row?.dataset.thinkingStartedAt || 0); const elapsed = started ? Date.now() - started : 0; const ms = durationMs != null ? durationMs : elapsed; const hasText = Boolean( streamActivity.steps.length || (streamActivity.thinkingText || "").trim() || (content?.textContent || "").trim() ); if (!hasText) { block.classList.add("hidden"); return; } renderStreamingThinking(); if (label) label.textContent = "Размышления"; if (duration) duration.textContent = formatThinkingDuration(ms); block.classList.add("collapsed"); block.classList.remove("open", "streaming"); } function renderContextFileChip(entry) { const name = typeof entry === "string" ? entry : (entry?.name || "файл"); const id = typeof entry === "string" ? "" : (entry?.id || ""); const file = id ? visibleFiles().find((item) => item.id === id) : visibleFiles().find((item) => item.name === name); const href = fileDownloadHref(file?.id || id); const badge = fileExtensionBadge(name); const body = `${escapeHtml(badge)}${escapeHtml(name)}`; return href ? `${body}` : `${body}`; } function dedupeContextEntries(items) { const seenIds = new Set(); const seenNames = new Set(); const unique = []; for (const item of items || []) { const name = typeof item === "string" ? item : (item?.name || ""); const id = typeof item === "string" ? "" : (item?.id || ""); const key = name.toLowerCase(); if (id && seenIds.has(id)) continue; if (key && seenNames.has(key)) continue; if (id) seenIds.add(id); if (key) seenNames.add(key); unique.push(typeof item === "string" ? { id: "", name } : { id, name: name || "файл" }); } return unique; } function renderContextMore(label, files) { if (!files.length) return ""; return `
    ${escapeHtml(label)}${ files.map(renderContextFileChip).join("") }
    `; } function renderContextChips(data) { const chips = $("#contextChips"); if (!chips) return; const attachments = dedupeContextEntries(data?.attachments || []); const chatFiles = dedupeContextEntries(data?.chat_files || []); const projectFiles = dedupeContextEntries(data?.project_files || []); let primary = dedupeContextEntries([...attachments, ...chatFiles]); const project = projectFiles.filter((item) => { const idMatch = item.id && primary.some((entry) => entry.id === item.id); const nameMatch = primary.some((entry) => entry.name.toLowerCase() === item.name.toLowerCase()); return !idMatch && !nameMatch; }); if (!primary.length && !project.length) { const legacy = dedupeContextEntries( Array.isArray(data?.files) ? data.files.map((item) => (typeof item === "string" ? item : item?.name)).filter(Boolean) : [], ); if (!legacy.length) { chips.classList.add("hidden"); chips.innerHTML = ""; return; } primary = legacy; } const shown = primary.slice(0, 5); const extra = primary.slice(5); let html = `Контекст${shown.map(renderContextFileChip).join("")}`; if (extra.length) html += renderContextMore(`ещё ${extra.length}`, extra); if (project.length) { if (attachments.length || project.length > 3) { html += renderContextMore(`Файлы проекта (${project.length})`, project); } else { const projectShown = project.slice(0, 5); const projectExtra = project.slice(5); html += projectShown.map(renderContextFileChip).join(""); if (projectExtra.length) html += renderContextMore(`ещё ${projectExtra.length}`, projectExtra); } } chips.classList.remove("hidden"); chips.innerHTML = html; } function parseSseChunk(buffer) { const normalized = String(buffer).replace(/\r\n/g, "\n").replace(/\r/g, "\n"); const events = []; const parts = normalized.split("\n\n"); const rest = parts.pop() || ""; for (const part of parts) { if (!part.trim()) continue; let event = "message"; const dataLines = []; for (const line of part.split("\n")) { if (line.startsWith("event:")) event = line.slice(6).trim(); else if (line.startsWith("data:")) dataLines.push(line.slice(5).trimStart()); } let data = {}; try { data = JSON.parse(dataLines.join("\n") || "{}"); } catch { data = { detail: dataLines.join("\n") }; } events.push({ event, data }); } return { events, rest }; } function renderStreamingText(text) { const body = $("#streamingBody"); if (!body) return; try { body.innerHTML = formatMessageHtml(text, "assistant", { renderMermaid: false }); } catch (error) { console.warn("stream render failed", error); body.textContent = text; } } function renderComposerAttachments() { const boxes = $$(".composer-attachments"); const uploads = normalizeAttachmentList(state.composerUploads); for (const box of boxes) { if (!uploads.length) { box.classList.add("hidden"); box.innerHTML = ""; continue; } box.classList.remove("hidden"); box.innerHTML = renderAttachmentChips(uploads, { removable: true }); box.querySelectorAll("[data-composer-remove]").forEach((button) => { button.addEventListener("click", (event) => { event.preventDefault(); event.stopPropagation(); const removeId = button.dataset.composerRemove; state.composerUploads = state.composerUploads.filter((file) => file.id !== removeId); renderComposerAttachments(); }); }); } } function rememberComposerUploads(uploaded) { const byId = new Map(state.composerUploads.map((file) => [file.id, file])); for (const entry of uploaded || []) { if (entry?.id) byId.set(entry.id, { id: entry.id, name: entry.name || "файл" }); } state.composerUploads = [...byId.values()]; renderComposerAttachments(); } async function uploadProjectFiles(fileList, { chatId = null, scope = null } = {}) { const files = [...fileList]; if (!files.length) return []; if (!state.activeProject) { throw new Error("Сначала откройте проект"); } const resolvedScope = scope || (chatId ? "chat" : "project"); if (resolvedScope === "chat" && !chatId) { throw new Error("Сначала откройте чат"); } const uploaded = []; for (const file of files) { const form = new FormData(); form.append("file", file); form.append("scope", resolvedScope); if (chatId) form.append("chat_id", chatId); const query = new URLSearchParams(); if (chatId) query.set("chat_id", chatId); query.set("scope", resolvedScope); const entry = await api(`/projects/${state.activeProject.id}/files?${query}`, { method: "POST", body: form, }); uploaded.push(entry); if (entry?.id) { if (resolvedScope === "chat") { if (!state.chatFiles.some((item) => item.id === entry.id)) { state.chatFiles = [entry, ...state.chatFiles]; } } else if (!state.files.some((item) => item.id === entry.id)) { state.files = [entry, ...state.files]; } } if (resolvedScope !== "chat") renderFiles(); } try { if (resolvedScope === "chat" && chatId) { const listed = await api( `/projects/${state.activeProject.id}/files?chat_id=${encodeURIComponent(chatId)}`, ); state.chatFiles = listed.filter((file) => fileIsChatScoped(file, chatId)); } else { state.files = await api(`/projects/${state.activeProject.id}/files`); } } catch {} if (resolvedScope !== "chat") renderFiles(); return uploaded; } async function flushComposerUploads(chatId) { if (!chatId) throw new Error("Сначала откройте чат"); const pending = []; const ready = []; for (const item of state.composerUploads) { if (item?.localFile) pending.push(item); else if (item?.id && !isLocalComposerFile(item)) ready.push(item); } const uploaded = pending.length ? await uploadProjectFiles( pending.map((item) => item.localFile), { chatId, scope: "chat" }, ) : []; if (ready.length) { try { await api(`/chats/${chatId}/files/bind`, jsonOptions("POST", { file_ids: ready.map((item) => item.id), })); } catch {} } const merged = new Map(); for (const entry of [...ready, ...uploaded]) { if (entry?.id) merged.set(entry.id, { id: entry.id, name: entry.name || "файл" }); } state.composerUploads = [...merged.values()]; renderComposerAttachments(); return state.composerUploads; } async function attachComposerFiles(files, chatId = null) { const boundId = chatId || state.activeChat?.id || null; if (boundId) { const uploaded = await uploadProjectFiles(files, { chatId: boundId, scope: "chat" }); rememberComposerUploads(uploaded); return uploaded; } for (const file of files) { state.composerUploads.push({ id: localComposerId(), name: file.name, localFile: file, }); } renderComposerAttachments(); return state.composerUploads; } function hasFilePayload(event) { const types = event.dataTransfer?.types; if (!types) return false; if (typeof types.includes === "function") return types.includes("Files"); if (typeof types.contains === "function") return types.contains("Files"); return Array.from(types).includes("Files"); } const COMPOSER_MAX_HEIGHT = 180; function autoGrowTextarea(textarea, { maxHeight = COMPOSER_MAX_HEIGHT } = {}) { if (!textarea) return; textarea.style.height = "auto"; const next = Math.min(textarea.scrollHeight, maxHeight); textarea.style.height = `${Math.max(next, 24)}px`; textarea.style.overflowY = textarea.scrollHeight > maxHeight ? "auto" : "hidden"; } function resetTextareaHeight(textarea, { maxHeight = COMPOSER_MAX_HEIGHT } = {}) { if (!textarea) return; textarea.style.height = "auto"; autoGrowTextarea(textarea, { maxHeight }); } function bindAutoGrow(textarea, { maxHeight = COMPOSER_MAX_HEIGHT } = {}) { if (!textarea || textarea.dataset.autoGrowBound === "1") return; textarea.dataset.autoGrowBound = "1"; const resize = () => autoGrowTextarea(textarea, { maxHeight }); textarea.addEventListener("input", resize); textarea.addEventListener("focus", resize); resize(); } function setupFileDropZone(element, { onFiles, activeClass = "drop-target" } = {}) { if (!element || element.dataset.dropBound === "1") return; element.dataset.dropBound = "1"; const clearActive = () => element.classList.remove(activeClass); element.addEventListener("dragenter", (event) => { if (!hasFilePayload(event)) return; event.preventDefault(); event.stopPropagation(); element.classList.add(activeClass); }); element.addEventListener("dragover", (event) => { if (!hasFilePayload(event)) return; event.preventDefault(); event.stopPropagation(); if (event.dataTransfer) event.dataTransfer.dropEffect = "copy"; element.classList.add(activeClass); }); element.addEventListener("dragleave", (event) => { if (!hasFilePayload(event)) return; // Stay active while the pointer moves across child nodes. if (event.relatedTarget && element.contains(event.relatedTarget)) return; clearActive(); }); element.addEventListener("drop", async (event) => { if (!hasFilePayload(event)) return; event.preventDefault(); event.stopPropagation(); clearActive(); // Clear ancestors that may still show the drag highlight. let parent = element.parentElement; while (parent) { parent.classList.remove(activeClass); parent = parent.parentElement; } const files = [...(event.dataTransfer?.files || [])]; if (!files.length) return; try { await onFiles(files); } catch (error) { showToast(error.message); } }); } async function handleDroppedFiles(files, { chatId = null, composer = false } = {}) { if (!files?.length) return; if (composer) { await attachComposerFiles(files, chatId); } else { await uploadProjectFiles(files, { chatId: null, scope: "project" }); } showToast(`${files.length} ${plural(files.length, "файл добавлен", "файла добавлены", "файлов добавлено")}`); } function showGenProgress({ message = "", percent = null, phase = "" } = {}) { const box = $("#genProgress"); const label = $("#genProgressLabel"); const bar = $("#genProgressBar"); if (!box || !label || !bar) return; box.classList.remove("hidden"); label.textContent = message || (phase === "agent" ? "Думаю…" : "Читаю лог…"); if (percent == null || Number.isNaN(Number(percent))) { bar.style.width = "32%"; bar.classList.add("indeterminate"); } else { bar.classList.remove("indeterminate"); bar.style.width = `${Math.max(0, Math.min(100, Number(percent)))}%`; } } function hideGenProgress() { const box = $("#genProgress"); const bar = $("#genProgressBar"); if (box) box.classList.add("hidden"); if (bar) { bar.classList.remove("indeterminate"); bar.style.width = "0%"; } } async function requestAssistant() { if (state.responding || !state.activeChat) return; setResponding(true); const controller = new AbortController(); state.streamController = controller; ensureStreamingBubble(); showGenProgress({ phase: "agent", message: "Запуск…", percent: null }); let streamed = ""; let queued = false; try { const response = await fetch(`/api/chats/${state.activeChat.id}/respond`, { method: "POST", credentials: "same-origin", signal: controller.signal, }); if (response.status === 401) { showLogin(); throw new Error("Требуется вход"); } if (!response.ok) { let detail = "Ошибка генерации"; try { const payload = await response.json(); detail = payload.detail || detail; } catch {} throw new Error(detail); } if (!response.body || typeof response.body.getReader !== "function") { throw new Error("Поток ответа недоступен в этом браузере"); } const reader = response.body.getReader(); const decoder = new TextDecoder(); let buffer = ""; while (true) { const { value, done } = await reader.read(); if (done) break; buffer += decoder.decode(value, { stream: true }); const parsed = parseSseChunk(buffer); buffer = parsed.rest; for (const { event, data } of parsed.events) { if (event === "status") { queued = data.state === "queued"; setResponding(true, queued); const { label } = streamingThinkingEls(); if (label && data.detail) label.textContent = data.detail; if (data.state === "scanning" && data.detail) { const pct = String(data.detail).match(/(\d+)\s*%/); showGenProgress({ phase: "scan", message: data.detail, percent: pct ? Number(pct[1]) : 0, }); } } else if (event === "progress") { showGenProgress(data); const { label } = streamingThinkingEls(); if (label && data.message) label.textContent = data.message; } else if (event === "context") { renderContextChips(data); } else if (event === "activity") { upsertStreamingActivity(data); } else if (event === "thinking") { appendStreamingThinking(data.text || ""); } else if (event === "thinking_done") { finishStreamingThinking(data.duration_ms); } else if (event === "delta") { if (data.replace) streamed = data.text || ""; else streamed += data.text || ""; renderStreamingText(streamed); const messages = $("#messages"); if (messages) messages.scrollTop = messages.scrollHeight; } else if (event === "error") { throw new Error(data.detail || "Ошибка агента"); } else if (event === "done" || event === "cancelled") { if (event === "cancelled") showToast("Генерация остановлена"); if (!streamed && (data.text || "")) { streamed = data.text; renderStreamingText(streamed); } if (Array.isArray(data.message?.activity) && data.message.activity.length) { streamActivity.usedActivity = true; streamActivity.steps = coalesceThinkingSteps( data.message.activity.filter((step) => step && !isJunkActivityStep(step)), ); renderStreamingThinking(); } finishStreamingThinking(data.message?.thinking_duration_ms || data.duration_ms); } } } if (buffer.trim()) { const parsed = parseSseChunk(`${buffer}\n\n`); for (const { event, data } of parsed.events) { if (event === "activity") { upsertStreamingActivity(data); } else if (event === "thinking") { appendStreamingThinking(data.text || ""); } else if (event === "thinking_done") { finishStreamingThinking(data.duration_ms); } else if (event === "delta") { if (data.replace) streamed = data.text || ""; else streamed += data.text || ""; renderStreamingText(streamed); } else if (event === "error") { throw new Error(data.detail || "Ошибка агента"); } else if ((event === "done" || event === "cancelled") && !streamed && data.text) { streamed = data.text; renderStreamingText(streamed); if (Array.isArray(data.message?.activity) && data.message.activity.length) { streamActivity.usedActivity = true; streamActivity.steps = coalesceThinkingSteps( data.message.activity.filter((step) => step && !isJunkActivityStep(step)), ); renderStreamingThinking(); } finishStreamingThinking(data.message?.thinking_duration_ms); } } } renderMessages(await api(`/chats/${state.activeChat.id}/messages`)); await loadProjects(); } catch (error) { if (error.name === "AbortError") { showToast("Остановлено"); try { renderMessages(await api(`/chats/${state.activeChat.id}/messages`)); } catch {} } else { $("#assistantStreaming")?.remove(); showToast(error.message); } } finally { state.streamController = null; setResponding(false); hideGenProgress(); $("#messageInput").focus(); refreshAgentStatus(); } } async function cancelAssistant() { if (!state.activeChat) return; try { await api(`/chats/${state.activeChat.id}/cancel`, { method: "POST" }); } catch (error) { // Still abort local stream reader. showToast(error.message); } state.streamController?.abort(); } async function sendMessage() { const input = $("#messageInput"); const typed = input.value.trim(); if (!state.activeChat || state.responding) return; const pendingSnapshot = state.composerUploads.slice(); if (!typed && !pendingSnapshot.length) return; input.value = ""; resetTextareaHeight(input); try { await flushComposerUploads(state.activeChat.id); const payload = composerMessageFromUploads(typed, state.composerUploads); if (!payload.content) return; state.composerUploads = []; renderComposerAttachments(); await api(`/chats/${state.activeChat.id}/messages`, jsonOptions("POST", { role: "user", content: payload.content, attachments: payload.attachments, })); state.chats = await api(`/projects/${state.activeProject.id}/chats`); state.activeChat = state.chats.find((chat) => chat.id === state.activeChat.id); $("#chatTitle").textContent = state.activeChat.title; renderMessages(await api(`/chats/${state.activeChat.id}/messages`)); await requestAssistant(); await loadProjects(); } catch (error) { input.value = typed; resetTextareaHeight(input); state.composerUploads = pendingSnapshot; renderComposerAttachments(); showToast(error.message); } } function hideSearchResults() { $("#searchResults")?.classList.add("hidden"); } function renderSearchResults(items) { const box = $("#searchResults"); if (!box) return; if (!items?.length) { box.innerHTML = '
    Ничего не найдено
    '; box.classList.remove("hidden"); return; } box.innerHTML = items.map((item) => ` `).join(""); box.classList.remove("hidden"); box.querySelectorAll("[data-search-chat]").forEach((button) => { button.addEventListener("click", async () => { hideSearchResults(); const input = $("#sidebarSearch"); if (input) input.value = ""; try { if (state.activeProject?.id !== button.dataset.searchProject) { await openProject(button.dataset.searchProject, false); } await openChat(button.dataset.searchChat, { messageId: button.dataset.searchMessage || "", }); } catch (error) { showToast(error.message); } }); }); } async function runSidebarSearch(query) { const q = String(query || "").trim(); if (q.length < 2) { hideSearchResults(); return; } try { const items = await api(`/search?q=${encodeURIComponent(q)}`); renderSearchResults(items); } catch (error) { showToast(error.message); } } function setupEvents() { $("#loginForm").addEventListener("submit", async (event) => { event.preventDefault(); const form = new FormData(event.currentTarget); $("#loginError").classList.add("hidden"); try { state.user = await api("/auth/login", jsonOptions("POST", { username: form.get("username"), password: form.get("password"), })); showApp(); await bootApp(); } catch (error) { $("#loginError").textContent = error.message; $("#loginError").classList.remove("hidden"); } }); $("#logoutButton").addEventListener("click", async () => { try { await api("/auth/logout", { method: "POST" }); } catch {} state.user = null; showLogin(); }); ["#headerAddProject", "#mainAddProject", "#sidebarAddProject", "#emptyAddProject"].forEach((id) => { $(id).addEventListener("click", () => openModal("#projectModal")); }); $$(".modal-close").forEach((button) => { button.addEventListener("click", () => closeModal("#projectModal")); }); $$(".instructions-close").forEach((button) => { button.addEventListener("click", () => closeModal("#instructionsModal")); }); $$(".rename-close").forEach((button) => { button.addEventListener("click", () => closeModal("#renameModal")); }); $("#projectForm").addEventListener("submit", async (event) => { event.preventDefault(); const formEl = event.currentTarget; const form = new FormData(formEl); try { const project = await api("/projects", jsonOptions("POST", Object.fromEntries(form))); formEl?.reset(); closeModal("#projectModal"); await loadProjects(); await openProject(project.id); showToast("Проект создан"); } catch (error) { showToast(error.message); } }); $("#editInstructions").addEventListener("click", () => { $("#instructionsForm").elements.instructions.value = state.activeProject.instructions || ""; openModal("#instructionsModal"); }); $("#instructionsForm").addEventListener("submit", async (event) => { event.preventDefault(); const instructions = event.currentTarget.elements.instructions.value; try { const updated = await api(`/projects/${state.activeProject.id}`, jsonOptions("PATCH", { instructions })); Object.assign(state.activeProject, updated); $("#instructionsPreview").textContent = instructions || "Добавьте правила и контекст для всех разговоров проекта."; closeModal("#instructionsModal"); showToast("Инструкции сохранены"); } catch (error) { showToast(error.message); } }); $("#modelSelect").addEventListener("change", async (event) => { if (!state.activeProject) return; try { const updated = await api( `/projects/${state.activeProject.id}`, jsonOptions("PATCH", { model: event.target.value }), ); Object.assign(state.activeProject, updated); const idx = state.projects.findIndex((p) => p.id === updated.id); if (idx >= 0) state.projects[idx] = { ...state.projects[idx], ...updated }; showToast(`Модель: ${updated.model}`); } catch (error) { showToast(error.message); } }); $("#renameForm").addEventListener("submit", async (event) => { event.preventDefault(); const form = event.currentTarget; const name = form.elements.name.value.trim(); const kind = form.elements.kind.value; try { if (kind === "project") { const updated = await api( `/projects/${state.activeProject.id}`, jsonOptions("PATCH", { name }), ); Object.assign(state.activeProject, updated); await loadProjects(); await openProject(updated.id, false); showToast("Проект переименован"); } else { const updated = await api( `/chats/${state.activeChat.id}`, jsonOptions("PATCH", { title: name }), ); Object.assign(state.activeChat, updated); $("#chatTitle").textContent = updated.title; await openProject(state.activeProject.id, false); showToast("Чат переименован"); } closeModal("#renameModal"); } catch (error) { showToast(error.message); } }); $("#projectMenuBtn").addEventListener("click", (event) => { event.stopPropagation(); $("#chatMenu").classList.add("hidden"); $("#projectMenu").classList.toggle("hidden"); }); $("#chatMenuBtn").addEventListener("click", (event) => { event.stopPropagation(); $("#projectMenu").classList.add("hidden"); $("#chatMenu").classList.toggle("hidden"); }); document.addEventListener("click", closeMenus); $("#renameProject").addEventListener("click", () => { closeMenus(); $("#renameEyebrow").textContent = "Проект"; $("#renameTitle").textContent = "Переименовать проект"; $("#renameForm").elements.kind.value = "project"; $("#renameForm").elements.name.value = state.activeProject.name; openModal("#renameModal"); }); $("#renameChat").addEventListener("click", () => { closeMenus(); $("#renameEyebrow").textContent = "Чат"; $("#renameTitle").textContent = "Переименовать чат"; $("#renameForm").elements.kind.value = "chat"; $("#renameForm").elements.name.value = state.activeChat.title; openModal("#renameModal"); }); const handleFileUpload = async (event) => { const files = [...event.target.files]; if (!files.length) return; const fromChat = event.target.id === "chatFileInput"; const fromProjectComposer = event.target.id === "projectFileInput"; try { await handleDroppedFiles(files, { chatId: fromChat ? state.activeChat?.id : null, composer: fromChat || fromProjectComposer, }); event.target.value = ""; } catch (error) { showToast(error.message); } }; $("#fileInput")?.addEventListener("change", (event) => handleFileUpload(event)); $("#projectFileInput")?.addEventListener("change", (event) => handleFileUpload(event)); $("#chatFileInput")?.addEventListener("change", (event) => handleFileUpload(event)); setupFileDropZone($("#projectDropZone"), { onFiles: (files) => handleDroppedFiles(files, { composer: false }), }); setupFileDropZone($("#filesPanel"), { onFiles: (files) => handleDroppedFiles(files, { composer: false }), }); setupFileDropZone($("#projectComposer"), { onFiles: (files) => handleDroppedFiles(files, { composer: true }), }); setupFileDropZone($("#chatDropZone"), { onFiles: (files) => handleDroppedFiles(files, { chatId: state.activeChat?.id, composer: true, }), }); setupFileDropZone($("#chatComposer"), { onFiles: (files) => handleDroppedFiles(files, { chatId: state.activeChat?.id, composer: true, }), }); bindAutoGrow($("#messageInput")); bindAutoGrow($("#quickPrompt"), { maxHeight: COMPOSER_MAX_HEIGHT }); $("#quickSend").addEventListener("click", async () => { const input = $("#quickPrompt"); const value = input.value; input.value = ""; resetTextareaHeight(input); try { await createChat(value, { attachUploads: true }); } catch (error) { input.value = value; resetTextareaHeight(input); showToast(error.message); } }); $("#quickPrompt").addEventListener("keydown", (event) => { if (event.key === "Enter" && !event.shiftKey) { event.preventDefault(); $("#quickSend").click(); } }); $("#addChat").addEventListener("click", () => createChat()); $("#newChatButton").addEventListener("click", () => createChat()); $("#messageSend").addEventListener("click", sendMessage); $("#messageStop").addEventListener("click", cancelAssistant); $("#messageInput").addEventListener("keydown", (event) => { if (event.key === "Enter" && !event.shiftKey) { event.preventDefault(); sendMessage(); } }); $("#backToProject").addEventListener("click", () => openProject(state.activeProject.id)); $("#deleteChat").addEventListener("click", async () => { closeMenus(); if (!confirm(`Удалить чат «${state.activeChat.title}»?`)) return; await api(`/chats/${state.activeChat.id}`, { method: "DELETE" }); state.activeChat = null; await openProject(state.activeProject.id); await loadProjects(); showToast("Чат удалён"); }); $("#deleteProject").addEventListener("click", async () => { closeMenus(); if (!confirm(`Удалить проект «${state.activeProject.name}» со всеми файлами и чатами?`)) return; await api(`/projects/${state.activeProject.id}`, { method: "DELETE" }); state.activeProject = null; $("#breadcrumbs").textContent = "Все проекты"; await loadProjects(); showView("projects"); showToast("Проект удалён"); }); $$(".nav-item").forEach((item) => { item.addEventListener("click", async () => { if (item.dataset.view === "users") { try { await loadUsers(); $("#breadcrumbs").textContent = "Пользователи"; showView("users"); } catch (error) { showToast(error.message); } return; } $("#breadcrumbs").textContent = "Все проекты"; showView("projects"); }); }); $("#userCreateForm").addEventListener("submit", async (event) => { event.preventDefault(); const formEl = event.currentTarget; const form = new FormData(formEl); try { await api("/users", jsonOptions("POST", { username: form.get("username"), display_name: form.get("display_name") || "", password: form.get("password"), role: form.get("role") || "user", })); formEl?.reset(); await loadUsers(); showToast("Пользователь создан"); } catch (error) { showToast(error.message); } }); $("#themeToggle").addEventListener("click", async () => { const html = document.documentElement; const next = html.dataset.theme === "dark" ? "light" : "dark"; html.dataset.theme = next; localStorage.setItem("theme", next); initMermaid(); if (state.activeChat && $("#chatView")?.classList.contains("active")) { try { renderMessages(await api(`/chats/${state.activeChat.id}/messages`)); } catch {} } }); $("#openSidebar").addEventListener("click", () => $(".sidebar").classList.add("open")); $("#closeSidebar").addEventListener("click", () => $(".sidebar").classList.remove("open")); const searchInput = $("#sidebarSearch"); if (searchInput) { searchInput.addEventListener("input", () => { clearTimeout(runSidebarSearch.timer); runSidebarSearch.timer = setTimeout(() => runSidebarSearch(searchInput.value), 200); }); searchInput.addEventListener("keydown", (event) => { if (event.key === "Escape") { searchInput.value = ""; hideSearchResults(); } }); } document.addEventListener("click", (event) => { const wrap = $("#sidebarSearchWrap"); if (wrap && !wrap.contains(event.target)) hideSearchResults(); }); $("#diagramLightboxClose")?.addEventListener("click", (event) => { event.stopPropagation(); closeDiagramLightbox(); }); $("#diagramLightbox")?.addEventListener("click", (event) => { if (event.target === $("#diagramLightbox")) closeDiagramLightbox(); }); $("#diagramLightboxInner")?.addEventListener("click", (event) => { event.stopPropagation(); }); $("#messages")?.addEventListener("click", (event) => { const img = event.target.closest?.("img.msg-image"); if (img) { event.preventDefault(); openDiagramLightbox(img); return; } const mermaidHost = event.target.closest?.(".msg-mermaid"); if (mermaidHost) { const svg = mermaidHost.querySelector("svg"); if (svg) { event.preventDefault(); openDiagramLightbox(svg); } } }); $$(".modal-backdrop").forEach((backdrop) => { backdrop.addEventListener("click", (event) => { if (event.target === backdrop) backdrop.classList.add("hidden"); }); }); window.addEventListener("keydown", (event) => { if (event.key === "Escape") { closeDiagramLightbox(); $$(".modal-backdrop").forEach((modal) => modal.classList.add("hidden")); closeMenus(); hideSearchResults(); } if ((event.ctrlKey || event.metaKey) && event.key.toLowerCase() === "k") { event.preventDefault(); createChat(); } }); } async function bootApp() { await loadProjects(); await refreshAgentStatus(); if (state.agentStatusTimer) clearInterval(state.agentStatusTimer); state.agentStatusTimer = setInterval(refreshAgentStatus, 5000); } async function init() { document.documentElement.dataset.theme = localStorage.getItem("theme") || "dark"; initMermaid(); setupEvents(); try { state.user = await api("/auth/me"); showApp(); await bootApp(); } catch { showLogin(); } } init();