Files
Helpomatica/static/app.js
T

2467 lines
85 KiB
JavaScript
Raw Normal View History

2026-08-14 11:06:58 +07:00
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();