const els = { apiBase: document.getElementById("apiBase"), healthStatus: document.getElementById("healthStatus"), projectSelect: document.getElementById("projectSelect"), chatSelect: document.getElementById("chatSelect"), messages: document.getElementById("messages"), messageInput: document.getElementById("messageInput"), chatForm: document.getElementById("chatForm"), sendButton: document.getElementById("sendButton"), createProjectBtn: document.getElementById("createProject"), refreshProjectsBtn: document.getElementById("refreshProjects"), createChatBtn: document.getElementById("createChat"), renameProjectBtn: document.getElementById("renameProject"), deleteProjectBtn: document.getElementById("deleteProject"), renameChatBtn: document.getElementById("renameChat"), clearMessagesBtn: document.getElementById("clearMessages"), clearChatMemoryBtn: document.getElementById("clearChatMemory"), checkHealthBtn: document.getElementById("checkHealth"), projectNameInput: document.getElementById("projectName"), chatNameInput: document.getElementById("chatName"), }; const PROJECT_ID_KEY = "onecAgent.projectId"; const CHAT_BY_PROJECT_KEY = "onecAgent.chatByProject"; const REQUEST_TIMEOUT_MS = 120000; const TURN_TIMEOUT_MS = 90000; const state = { projectId: "", chatId: "", chatByProject: {}, }; function loadStateFromStorage() { try { const projectId = localStorage.getItem(PROJECT_ID_KEY); if (projectId) { state.projectId = projectId; } const map = localStorage.getItem(CHAT_BY_PROJECT_KEY); if (map) { const parsed = JSON.parse(map); if (parsed && typeof parsed === "object") { state.chatByProject = parsed; } } } catch { state.projectId = ""; state.chatByProject = {}; } } function persistProjectId(projectId) { state.projectId = projectId || ""; try { if (projectId) { localStorage.setItem(PROJECT_ID_KEY, projectId); } else { localStorage.removeItem(PROJECT_ID_KEY); } } catch { // no-op if localStorage unavailable } } function persistChatId(projectId, chatId) { const map = state.chatByProject && typeof state.chatByProject === "object" ? { ...state.chatByProject } : {}; if (projectId && chatId) { map[projectId] = chatId; } else if (projectId) { delete map[projectId]; } state.chatByProject = map; state.chatId = chatId || ""; try { localStorage.setItem(CHAT_BY_PROJECT_KEY, JSON.stringify(state.chatByProject)); } catch { // no-op if localStorage unavailable } } function removePersistedProject(projectId) { persistProjectId(""); if (!projectId) { return; } const map = state.chatByProject && typeof state.chatByProject === "object" ? { ...state.chatByProject } : {}; delete map[projectId]; state.chatByProject = map; try { localStorage.setItem(CHAT_BY_PROJECT_KEY, JSON.stringify(state.chatByProject)); } catch { // no-op if localStorage unavailable } } function getApiBase() { return (els.apiBase.value || window.location.origin).replace(/\/$/, ""); } function setStatus(message) { els.healthStatus.textContent = message; } function safeStringify(value) { try { return JSON.stringify(value, null, 2); } catch { return String(value ?? {}); } } function formatMetaTime(requestPayload, created_at, payload) { const rawTime = requestPayload?.createdAt ? requestPayload.createdAt : (created_at || payload?.created_at || payload?.timestamp || null); if (!rawTime) { return new Date().toLocaleTimeString(); } const dt = new Date(rawTime); return Number.isNaN(dt.getTime()) ? String(rawTime) : dt.toLocaleTimeString("ru-RU", { hour: "2-digit", minute: "2-digit", second: "2-digit" }); } function buildExchangeLog(role, payload) { const transport = payload && typeof payload === "object" ? payload.transport : null; const sections = []; if (role === "user" && transport?.outbound) { sections.push({ title: "Что отправили модели", value: transport.outbound, }); } if (role === "assistant") { if (transport?.inbound) { sections.push({ title: "Что получили от модели", value: transport.inbound, }); } if (payload?.raw !== undefined) { sections.push({ title: "Полный ответ провайдера", value: payload.raw, }); } } if (payload?.route !== undefined) { sections.push({ title: "Маршрут модели", value: payload.route, }); } if (payload?.provider !== undefined) { sections.push({ title: "Провайдер", value: payload.provider, }); } return sections; } function renderMessage({ role, content, payload, requestPayload, created_at }) { const wrapper = document.createElement("div"); wrapper.className = `message ${role}`; const meta = document.createElement("div"); meta.className = "meta"; const timestamp = formatMetaTime(requestPayload, created_at, payload); meta.textContent = `${role} • ${timestamp}`; const text = document.createElement("div"); text.textContent = content || ""; wrapper.appendChild(meta); wrapper.appendChild(text); const logs = buildExchangeLog(role, payload || {}); if (logs.length) { const journal = document.createElement("details"); journal.className = "journal"; journal.open = false; const summary = document.createElement("summary"); summary.textContent = "Журнал обмена"; journal.appendChild(summary); logs.forEach((item) => { const heading = document.createElement("div"); heading.className = "journal-title"; heading.textContent = item.title; const pre = document.createElement("pre"); pre.className = "mono"; pre.textContent = safeStringify(item.value); journal.appendChild(heading); journal.appendChild(pre); }); wrapper.appendChild(journal); } els.messages.appendChild(wrapper); } async function apiRequest(path, options = {}) { const controller = new AbortController(); const { timeoutMs, ...fetchOptions } = options; const effectiveTimeoutMs = timeoutMs ?? REQUEST_TIMEOUT_MS; const timeout = setTimeout(() => controller.abort(), effectiveTimeoutMs); const url = `${getApiBase()}${path}`; let response; try { try { response = await fetch(url, { ...fetchOptions, signal: controller.signal, headers: { "Content-Type": "application/json", ...(fetchOptions.headers || {}), }, }); } catch (error) { if (error?.name === "AbortError") { throw new Error(`Превышен таймаут запроса (${Math.round(effectiveTimeoutMs / 1000)} сек)`); } throw error; } } finally { clearTimeout(timeout); } const bodyText = await response.text(); const body = bodyText ? JSON.parse(bodyText) : {}; if (!response.ok) { const message = body?.error?.message || `HTTP ${response.status}`; throw new Error(message); } return body; } async function checkHealth() { const data = await apiRequest("/v1/health"); setStatus(`health ok: ${data.status}, service=${data.service}`); return data; } async function loadProjects() { const data = await apiRequest("/v1/projects"); const projects = data.projects || []; els.projectSelect.innerHTML = ""; projects.forEach((project) => { const option = document.createElement("option"); option.value = project.id; option.textContent = project.name; els.projectSelect.appendChild(option); }); if (!projects.length) { state.projectId = ""; state.chatId = ""; persistProjectId(""); persistChatId("", ""); els.projectNameInput.value = ""; els.chatNameInput.value = ""; els.chatSelect.innerHTML = ""; return false; } if (!state.projectId || !projects.some((p) => p.id === state.projectId)) { state.projectId = projects[0].id; } persistProjectId(state.projectId); els.projectSelect.value = state.projectId; const currentProject = projects.find((project) => project.id === state.projectId); if (currentProject) { els.projectNameInput.value = currentProject.name || ""; } return true; } async function loadChats() { if (!state.projectId) { els.chatSelect.innerHTML = ""; state.chatId = ""; els.chatNameInput.value = ""; return; } const chats = await apiRequest(`/v1/projects/${state.projectId}/chats`); const list = chats.chats || []; els.chatSelect.innerHTML = ""; list.forEach((chat) => { const option = document.createElement("option"); option.value = chat.id; option.textContent = chat.title; els.chatSelect.appendChild(option); }); if (!list.length) { state.chatId = ""; els.chatNameInput.value = ""; els.chatSelect.innerHTML = ""; persistChatId(state.projectId, ""); return; } const savedChatId = state.chatByProject[state.projectId]; if (!state.chatId || !list.some((c) => c.id === state.chatId)) { state.chatId = savedChatId || ""; } if (!state.chatId || !list.some((c) => c.id === state.chatId)) { state.chatId = list[0].id; } els.chatSelect.value = state.chatId; const activeChat = list.find((chat) => chat.id === state.chatId); if (activeChat) { els.chatNameInput.value = activeChat.title || ""; } persistChatId(state.projectId, state.chatId); } async function loadMessages() { if (!state.projectId || !state.chatId) { els.messages.innerHTML = ""; const placeholder = document.createElement("div"); placeholder.className = "message system"; if (!state.projectId) { placeholder.textContent = "Нет проекта. Создайте проект вручную."; } else if (!state.chatId) { placeholder.textContent = "В выбранном проекте нет чатов. Создайте чат вручную."; } els.messages.appendChild(placeholder); return; } const data = await apiRequest(`/v1/projects/${state.projectId}/chats/${state.chatId}/messages?limit=200`); const messages = data.messages || []; els.messages.innerHTML = ""; const orderedMessages = [...messages].reverse(); orderedMessages.forEach((message) => { renderMessage(message); }); if (!messages.length) { const placeholder = document.createElement("div"); placeholder.className = "message system"; placeholder.textContent = "История чата пуста. Напишите первый вопрос."; els.messages.appendChild(placeholder); } els.messages.scrollTop = 0; } async function refreshConversation() { const hasProjects = await loadProjects(); if (!hasProjects) { await loadMessages(); return; } await loadChats(); await loadMessages(); } async function createChat() { if (!state.projectId) { return; } const created = await apiRequest(`/v1/projects/${state.projectId}/chats`, { method: "POST", body: JSON.stringify({ title: els.chatNameInput.value.trim() || `Веб-чат ${new Date().toLocaleTimeString("ru-RU")}`, }), }); state.chatId = created.chat.id; persistChatId(state.projectId, state.chatId); await loadChats(); await loadMessages(); } async function sendTurn(messageText) { await apiRequest(`/v1/projects/${state.projectId}/chats/${state.chatId}/turn`, { method: "POST", body: JSON.stringify({ message: messageText, }), timeoutMs: TURN_TIMEOUT_MS, }); await loadMessages(); } function formatTimeoutMessage(durationMs) { const seconds = Math.max(1, Math.round(durationMs / 1000)); return `Ожидаем ответ модели ~${seconds} сек...`; } function setSendingFeedback(startTs) { const tick = () => { const elapsed = Date.now() - startTs; if (els.sendButton.disabled) { setStatus(formatTimeoutMessage(elapsed)); } }; const timer = setInterval(tick, 12000); return () => clearInterval(timer); } els.checkHealthBtn.addEventListener("click", async () => { try { await checkHealth(); } catch { // handled in function } }); els.refreshProjectsBtn.addEventListener("click", async () => { await refreshConversation(); }); els.createProjectBtn.addEventListener("click", async () => { const now = new Date().toLocaleString("ru-RU"); const customName = els.projectNameInput.value.trim(); const created = await apiRequest("/v1/projects", { method: "POST", body: JSON.stringify({ name: customName || `Проект ${now}`, description: "Создано из веб-интерфейса", }), }); state.projectId = created.project.id; persistProjectId(state.projectId); persistChatId(state.projectId, ""); await refreshConversation(); }); els.createChatBtn.addEventListener("click", async () => { if (!state.projectId) { return; } await createChat(); }); els.deleteProjectBtn.addEventListener("click", async () => { if (!state.projectId) { return; } const projectName = els.projectNameInput.value.trim() || "текущий проект"; if (!window.confirm(`Удалить проект «${projectName}» и все его чаты?`)) { return; } await apiRequest(`/v1/projects/${state.projectId}`, { method: "DELETE", }); removePersistedProject(state.projectId); state.projectId = ""; state.chatId = ""; els.projectNameInput.value = ""; els.chatNameInput.value = ""; await refreshConversation(); }); els.renameProjectBtn.addEventListener("click", async () => { if (!state.projectId) { return; } const newName = els.projectNameInput.value.trim(); if (!newName) { return; } await apiRequest(`/v1/projects/${state.projectId}`, { method: "PATCH", body: JSON.stringify({ name: newName }), }); await refreshConversation(); }); els.renameChatBtn.addEventListener("click", async () => { if (!state.projectId || !state.chatId) { return; } const newTitle = els.chatNameInput.value.trim(); if (!newTitle) { return; } await apiRequest(`/v1/projects/${state.projectId}/chats/${state.chatId}`, { method: "PATCH", body: JSON.stringify({ title: newTitle }), }); await refreshConversation(); }); els.projectSelect.addEventListener("change", async () => { state.projectId = els.projectSelect.value; state.chatId = ""; persistProjectId(state.projectId); persistChatId(state.projectId, ""); await refreshConversation(); }); els.chatSelect.addEventListener("change", async () => { state.chatId = els.chatSelect.value; persistChatId(state.projectId, state.chatId); await loadMessages(); }); els.clearMessagesBtn.addEventListener("click", () => { els.messages.innerHTML = ""; const placeholder = document.createElement("div"); placeholder.className = "message system"; if (!state.projectId) { placeholder.textContent = "Нет проекта. Создайте проект вручную."; } else if (!state.chatId) { placeholder.textContent = "В выбранном проекте нет чатов. Создайте чат вручную."; } else { placeholder.textContent = "История чата очищена в интерфейсе."; } els.messages.appendChild(placeholder); }); els.clearChatMemoryBtn.addEventListener("click", async () => { if (!state.projectId || !state.chatId) { return; } await apiRequest(`/v1/projects/${state.projectId}/chats/${state.chatId}/messages`, { method: "DELETE", }); await loadMessages(); }); els.messageInput.addEventListener("keydown", (event) => { if (event.key === "Enter" && !event.shiftKey) { event.preventDefault(); if (!els.messageInput.value.trim()) { return; } els.chatForm.requestSubmit(); } }); els.chatForm.addEventListener("submit", async (event) => { event.preventDefault(); const messageText = els.messageInput.value.trim(); if (!messageText) { return; } if (!state.projectId) { setStatus("Сначала выберите или создайте проект."); return; } if (!state.chatId) { setStatus("Сначала выберите или создайте чат."); return; } els.sendButton.disabled = true; setStatus("Отправляю сообщение..."); const stopSendingFeedback = setSendingFeedback(Date.now()); try { await sendTurn(messageText); els.messageInput.value = ""; } catch (error) { setStatus(`Ошибка отправки: ${error.message}`); } finally { stopSendingFeedback(); els.sendButton.disabled = false; if (!els.healthStatus.textContent.startsWith("Ошибка отправки") && !els.healthStatus.textContent.startsWith("Ожидаем")) { setStatus("Готов"); } } }); els.apiBase.value = window.location.origin; (async function init() { loadStateFromStorage(); try { await checkHealth(); await refreshConversation(); } catch (error) { setStatus(`Не удалось инициализироваться: ${error.message}`); } })();