Files
Helpomatica/static/app.js
T
2026-08-14 11:06:58 +07:00

2467 lines
85 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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("<", "&lt;")
.replaceAll(">", "&gt;")
.replaceAll('"', "&quot;")
.replaceAll("'", "&#039;");
}
function unescapeBasicHtml(value = "") {
return String(value)
.replaceAll("&amp;", "&")
.replaceAll("&lt;", "<")
.replaceAll("&gt;", ">")
.replaceAll("&quot;", '"')
.replaceAll("&#039;", "'");
}
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 = `<span class="msg-cite-line">:${escapeHtml(String(line))}</span><span class="msg-cite-path">${escapeHtml(fileName)}</span>`;
if (href) {
return `<a class="msg-cite" href="${href}" download title="Скачать фрагмент ${escapeHtml(fileName)}:${escapeHtml(String(line))}">${inner}</a>`;
}
return `<span class="msg-cite" title="${escapeHtml(fileName)}">${inner}</span>`;
}
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 class="msg-inline">${code}</code>`))
.replace(/\\\((.+?)\\\)/g, (match) => protect(`<span class="msg-math-inline">${match}</span>`))
.replace(/!\[([^\]]*)\]\(([^)\s]+)\)/g, (_match, alt, url) => {
const href = unescapeBasicHtml(url).trim();
if (isSafeImageUrl(href)) {
return protect(
`<img class="msg-image" src="${escapeHtml(href)}" alt="${alt}" loading="lazy" title="Нажмите, чтобы увеличить" />`,
);
}
const label = alt || "изображение недоступно";
return protect(
`<span class="msg-image-missing" title="${escapeHtml(href)}">[Нет изображения: ${label}]</span>`,
);
})
.replace(/\[([^\]]+)\]\(([^)\s]+)\)/g, (_match, label, url) => {
const href = unescapeBasicHtml(url).trim();
if (!isSafeLinkUrl(href)) {
return label;
}
return protect(
`<a class="msg-link" href="${escapeHtml(href)}" target="_blank" rel="noopener noreferrer">${label}</a>`,
);
})
.replace(/\*\*([^*]+)\*\*/g, "<strong>$1</strong>")
.replace(/\*([^*\n]+)\*/g, "<em>$1</em>")
.replace(/(^|[\s([{])_([^_\n]+)_(?=[\s)\]}.,!?:;]|$)/g, "$1<em>$2</em>")
.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) => `<th>${formatInlineMarkdown(cell)}</th>`)
.join("");
const body = rows
.map((row) => {
const cells = headers
.map((_, cellIndex) => `<td>${formatInlineMarkdown(row[cellIndex] || "")}</td>`)
.join("");
return `<tr>${cells}</tr>`;
})
.join("");
return {
html: `<div class="msg-table-wrap"><table class="msg-table"><thead><tr>${head}</tr></thead><tbody>${body}</tbody></table></div>`,
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 = `
<div class="msg-mermaid-error">Не удалось отрисовать диаграмму: ${escapeHtml(detail)}</div>
<pre class="msg-mermaid-fallback">${escapeHtml(source)}</pre>
`;
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(
`<div class="code-wrap msg-mermaid-wrap">
<button type="button" class="code-copy" data-copy-code="${index}">Копировать</button>
<code class="msg-mermaid-source" data-code-block="${index}" hidden>${safeCode}</code>
<div class="msg-mermaid"><pre class="mermaid">${safeCode}</pre></div>
</div>`
);
} else {
blocks.push(
`<div class="code-wrap">
<button type="button" class="code-copy" data-copy-code="${index}">Копировать</button>
<pre class="msg-code"><code class="lang-${escapeHtml(lang || "text")}" data-code-block="${index}">${safeCode}</code></pre>
</div>`
);
}
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(`<div class="msg-math-block">$$${safeExpr}$$</div>`);
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(`<p>${body.replace(/\n/g, "<br>")}</p>`);
paragraph = [];
};
const consumeQuote = (start) => {
const quoteLines = [];
let cursor = start;
while (cursor < lines.length) {
const trimmed = lines[cursor].trim();
const match = trimmed.match(/^&gt;\s?(.*)$/);
if (!match) break;
quoteLines.push(match[1]);
cursor += 1;
}
if (!quoteLines.length) return null;
const body = formatInlineMarkdown(quoteLines.join("\n")).replace(/\n/g, "<br>");
return {
html: `<blockquote class="msg-quote">${body}</blockquote>`,
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(
`<li class="msg-task-item"><input type="checkbox" class="msg-task-check" disabled readonly ${checked ? "checked" : ""} /><span>${formatInlineMarkdown(match[3])}</span></li>`,
);
} else if (kind === "ol") {
const match = trimmed.match(olRe);
if (!match) break;
items.push(`<li class="msg-li">${formatInlineMarkdown(match[2])}</li>`);
} else {
const match = trimmed.match(ulRe);
if (!match) break;
items.push(`<li class="msg-li">${formatInlineMarkdown(match[2])}</li>`);
}
cursor += 1;
}
if (!items.length) return null;
if (kind === "task") {
return { html: `<ul class="msg-list msg-task-list">${items.join("")}</ul>`, end: cursor };
}
if (kind === "ol") {
return { html: `<ol class="msg-list msg-ol">${items.join("")}</ol>`, end: cursor };
}
return { html: `<ul class="msg-list msg-ul">${items.join("")}</ul>`, 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('<hr class="msg-hr" />');
index += 1;
continue;
}
const headerMatch = trimmed.match(/^(#{1,6})\s+(.+)$/);
if (headerMatch) {
flushParagraph();
const level = Math.min(headerMatch[1].length, 6);
output.push(
`<h${level} class="msg-h msg-h${level}">${formatInlineMarkdown(headerMatch[2])}</h${level}>`,
);
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) => `
<article class="project-card" data-project-id="${project.id}">
<div class="card-icon">${escapeHtml(project.icon)}</div>
<h3>${escapeHtml(project.name)}</h3>
<p>${escapeHtml(project.description || "Без описания")}</p>
<div class="card-meta">
<span>${project.chat_count} ${plural(project.chat_count, "чат", "чата", "чатов")}</span>
<span>${project.file_count} ${plural(project.file_count, "файл", "файла", "файлов")}</span>
</div>
</article>
`).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 = `
<span class="row-icon">${escapeHtml(project.icon)}</span>
<span class="row-text">${escapeHtml(project.name)}</span>
`;
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) => `
<button class="side-row" data-recent-chat="${chat.id}" data-recent-project="${chat.project_id}">
<span>◌</span>
<span class="row-text" title="${escapeHtml(chat.projectName)}">${escapeHtml(chat.title)}</span>
</button>
`).join("")
: '<div class="sidebar-empty">Чатов пока нет</div>';
$$("[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", `<option value="${escapeHtml(model)}">${escapeHtml(model)}</option>`);
}
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) => `
<div class="chat-row" data-chat-id="${chat.id}" draggable="true" title="Перетащите, чтобы изменить порядок">
<span class="chat-drag-handle" aria-hidden="true">⋮⋮</span>
<span class="chat-bubble-icon">◌</span>
<span class="chat-row-title">${escapeHtml(chat.title)}</span>
<span class="chat-row-time">${formatDate(chat.updated_at)}</span>
</div>
`).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 `<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" aria-hidden="true"><path d="M12 3v12"/><path d="M7 10l5 5 5-5"/><path d="M5 21h14"/></svg>`;
}
function iconTrashSvg() {
return `<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" aria-hidden="true"><path d="M3 6h18"/><path d="M8 6V4h8v2"/><path d="M19 6l-1 14H6L5 6"/><path d="M10 11v6"/><path d="M14 11v6"/></svg>`;
}
function iconCloseSvg() {
return `<svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.4" aria-hidden="true"><path d="M6 6l12 12"/><path d="M18 6L6 18"/></svg>`;
}
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 = `
<div class="thinking-step-detail">${escapeHtml(detail.slice(0, 180))}…</div>
<details class="thinking-step-more">
<summary>подробнее</summary>
<div class="thinking-step-detail">${escapeHtml(detail)}</div>
</details>`;
} else {
detailHtml = `<div class="thinking-step-detail">${escapeHtml(detail)}</div>`;
}
}
const titleHtml = isThink
? ""
: `<div class="thinking-step-title">${title}</div>`;
return `<li class="thinking-step" data-type="${escapeHtml(type)}" data-status="${escapeHtml(status)}">${titleHtml}${detailHtml}</li>`;
}
function renderThinkingStepsHtml(steps) {
const items = Array.isArray(steps)
? coalesceThinkingSteps(steps.filter((step) => step && (step.title || step.detail) && !isJunkActivityStep(step)))
: [];
if (!items.length) return "";
return `<ol class="thinking-steps">${items.map(thinkingStepHtml).join("")}</ol>`;
}
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 `
<div class="thinking-block ${openClass}${streaming ? " streaming" : ""}">
<button type="button" class="thinking-toggle">
<span class="thinking-chevron" aria-hidden="true">▶</span>
<span class="thinking-label">${escapeHtml(label)}</span>
${duration ? `<span class="thinking-duration">${escapeHtml(duration)}</span>` : `<span class="thinking-duration"></span>`}
</button>
<div class="thinking-content">${body}</div>
</div>
`;
}
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
? `<a class="file-chip-name" href="${href}" download title="Скачать ${escapeHtml(name)}">${escapeHtml(name)}</a>`
: `<strong class="file-chip-name" title="${escapeHtml(name)}">${escapeHtml(name)}</strong>`;
const removeHtml = removable
? `<button type="button" class="composer-file-remove" data-composer-remove="${escapeHtml(id)}" title="Убрать" aria-label="Убрать ${escapeHtml(name)}">${iconCloseSvg()}</button>`
: (href
? `<a class="file-chip-download" href="${href}" download title="Скачать">${iconDownloadSvg()}</a>`
: "");
return `
<span class="composer-file-chip" data-composer-file="${escapeHtml(id)}" title="${escapeHtml(name)}">
<span class="composer-file-badge">${escapeHtml(badge)}</span>
${nameHtml}
${removeHtml}
</span>
`;
}).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 `
<div class="file-row">
<span class="file-type" title="${escapeHtml(extension)}">${escapeHtml(extension)}</span>
<a class="file-info" href="${href}" download title="Скачать ${escapeHtml(file.name)}">
<strong>${escapeHtml(file.name)}</strong>
<small>${formatSize(file.size)}</small>
</a>
<a class="file-action file-download" href="${href}" download title="Скачать">${iconDownloadSvg()}</a>
<button type="button" class="file-action file-delete" data-file-delete="${file.id}" title="Удалить">${iconTrashSvg()}</button>
</div>
`;
}).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 = `
<div class="users-head">
<span>Имя</span><span>Логин</span><span>Роль</span>
</div>
${users.map((user) => `
<div class="users-row">
<span>${escapeHtml(user.display_name)}</span>
<span>${escapeHtml(user.username)}</span>
<span class="role-pill">${escapeHtml(user.role)}</span>
</div>
`).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
? `<div class="message-attachments">${renderAttachmentChips(attachments)}</div>`
: "";
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 `
<div class="message-row ${isUser ? "user" : "assistant"}${highlightClass}" data-message-id="${escapeHtml(message.id || "")}">
<div class="message-role">${escapeHtml(messageRoleLabel(message))}</div>
<div class="message ${isUser ? "user" : ""}">
${chipsHtml}
${thinkingHtml}
<div class="message-body">${formatMessageHtml(bodyContent, message.role)}</div>
<div class="message-actions">
<button type="button" data-quote="${quotePayload}">Цитата</button>
</div>
</div>
</div>
`;
}).join("")
: '<div class="messages-empty">Начните разговор.<br>Чаты общие для всех пользователей проекта.</div>';
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", `
<div class="message-row assistant" id="assistantStreaming">
<div class="message-role">Помощник</div>
<div class="context-chips hidden" id="contextChips"></div>
<div class="message">
${thinkingToggleHtml({
label: "Думаю",
collapsed: false,
content: "",
steps: [],
streaming: true,
})}
<div class="message-body" id="streamingBody"></div>
</div>
</div>
`);
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 = `<span class="composer-file-badge">${escapeHtml(badge)}</span><span>${escapeHtml(name)}</span>`;
return href
? `<a class="chip chip-file" href="${href}" download title="Скачать ${escapeHtml(name)}">${body}</a>`
: `<span class="chip chip-file" title="${escapeHtml(name)}">${body}</span>`;
}
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 `<details class="context-more"><summary class="chip">${escapeHtml(label)}</summary>${
files.map(renderContextFileChip).join("")
}</details>`;
}
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 = `<span class="chip-label">Контекст</span>${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 = '<div class="search-empty">Ничего не найдено</div>';
box.classList.remove("hidden");
return;
}
box.innerHTML = items.map((item) => `
<button type="button" class="search-hit" data-search-chat="${escapeHtml(item.chat_id)}" data-search-project="${escapeHtml(item.project_id)}" data-search-message="${escapeHtml(item.message_id || "")}">
<strong>${escapeHtml(item.chat_title || "Чат")}</strong>
<small>${escapeHtml(item.project_name || "")}${item.match_in === "message" ? " · сообщение" : ""}</small>
<span>${escapeHtml(item.snippet || "")}</span>
</button>
`).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();