2467 lines
85 KiB
JavaScript
2467 lines
85 KiB
JavaScript
const state = {
|
||||
|
|
user: null,
|
|||
|
|
projects: [],
|
|||
|
|
activeProject: null,
|
|||
|
|
activeChat: null,
|
|||
|
|
chats: [],
|
|||
|
|
files: [],
|
|||
|
|
chatFiles: [],
|
|||
|
|
composerUploads: [],
|
|||
|
|
responding: false,
|
|||
|
|
streamController: null,
|
|||
|
|
agentStatusTimer: null,
|
|||
|
|
};
|
|||
|
|
|
|||
|
|
const $ = (selector) => document.querySelector(selector);
|
|||
|
|
const $$ = (selector) => [...document.querySelectorAll(selector)];
|
|||
|
|
|
|||
|
|
async function api(path, options = {}) {
|
|||
|
|
const response = await fetch(`/api${path}`, {
|
|||
|
|
credentials: "same-origin",
|
|||
|
|
...options,
|
|||
|
|
});
|
|||
|
|
if (response.status === 401 && path !== "/auth/login" && path !== "/auth/me") {
|
|||
|
|
showLogin();
|
|||
|
|
throw new Error("Требуется вход");
|
|||
|
|
}
|
|||
|
|
if (!response.ok) {
|
|||
|
|
let detail = "Произошла ошибка";
|
|||
|
|
try {
|
|||
|
|
const payload = await response.json();
|
|||
|
|
if (typeof payload.detail === "string" && payload.detail.trim()) {
|
|||
|
|
detail = payload.detail;
|
|||
|
|
} else if (Array.isArray(payload.detail)) {
|
|||
|
|
detail = payload.detail.map((item) => item.msg || item).join("; ");
|
|||
|
|
} else if (payload.detail) {
|
|||
|
|
detail = JSON.stringify(payload.detail);
|
|||
|
|
}
|
|||
|
|
} catch {}
|
|||
|
|
throw new Error(detail);
|
|||
|
|
}
|
|||
|
|
if (response.status === 204) return null;
|
|||
|
|
return response.json();
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
function jsonOptions(method, body) {
|
|||
|
|
return {
|
|||
|
|
method,
|
|||
|
|
headers: { "Content-Type": "application/json" },
|
|||
|
|
body: JSON.stringify(body),
|
|||
|
|
};
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
function escapeHtml(value = "") {
|
|||
|
|
return value
|
|||
|
|
.replaceAll("&", "&")
|
|||
|
|
.replaceAll("<", "<")
|
|||
|
|
.replaceAll(">", ">")
|
|||
|
|
.replaceAll('"', """)
|
|||
|
|
.replaceAll("'", "'");
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
function unescapeBasicHtml(value = "") {
|
|||
|
|
return String(value)
|
|||
|
|
.replaceAll("&", "&")
|
|||
|
|
.replaceAll("<", "<")
|
|||
|
|
.replaceAll(">", ">")
|
|||
|
|
.replaceAll(""", '"')
|
|||
|
|
.replaceAll("'", "'");
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
function isSafeLinkUrl(url = "") {
|
|||
|
|
const value = String(url).trim();
|
|||
|
|
return /^(https?:\/\/|mailto:|\/(?!\/))/i.test(value);
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
function isSafeImageUrl(url = "") {
|
|||
|
|
const value = String(url).trim();
|
|||
|
|
return /^(https?:\/\/|\/api\/|data:image\/)/i.test(value);
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
function logExcerptHref(fileName, line) {
|
|||
|
|
const files = visibleFiles();
|
|||
|
|
const base = String(fileName || "").replace(/\\/g, "/").split("/").pop().toLowerCase();
|
|||
|
|
if (!base) return "";
|
|||
|
|
const file = files.find((item) => String(item?.name || "").toLowerCase() === base)
|
|||
|
|
|| files.find((item) => String(item?.name || "").toLowerCase().endsWith(base));
|
|||
|
|
if (!file?.id) return "";
|
|||
|
|
const n = Number(line) || 1;
|
|||
|
|
const start = Math.max(1, n - 25);
|
|||
|
|
const end = n + 25;
|
|||
|
|
return `/api/files/${encodeURIComponent(file.id)}/excerpt?start=${start}&end=${end}`;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
function logCiteChip(fileName, line) {
|
|||
|
|
const href = logExcerptHref(fileName, line);
|
|||
|
|
const inner = `<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(/^>\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();
|