diff --git a/cmd/creator-web/static/assets/chat.css b/cmd/creator-web/static/assets/chat.css index 836370d..98e6390 100644 --- a/cmd/creator-web/static/assets/chat.css +++ b/cmd/creator-web/static/assets/chat.css @@ -166,3 +166,42 @@ textarea { resize: vertical; min-height: 84px; max-height: 240px; padding: 13px; .chat-layout { grid-template-columns: 218px minmax(0, 1fr) 260px; } .chat-side { padding-inline: 14px; } } + +/* Focused composer: one calm surface, with compact controls kept together. */ +.active-panel { grid-template-rows: minmax(0, 1fr) auto auto auto; } +.conversation-starters { width: min(100%, 900px); margin: 0 auto; padding: 0 22px 8px; gap: 6px; align-items: center; } +.conversation-starters span { color: var(--faint); font: 9px/1 var(--mono); letter-spacing: .03em; text-transform: uppercase; } +.conversation-starters button { min-height: 27px; padding: 0 9px; border: 1px solid var(--line); border-radius: 999px; color: var(--muted); background: transparent; font: 9px/1 var(--mono); } +.conversation-starters button:hover, .conversation-starters button:focus-visible { border-color: var(--blue); color: var(--blue); outline: none; } +.composer { width: min(100%, 900px); margin: 0 auto; padding: 0 22px 7px; display: block; border-top: 0; background: transparent; } +.composer-input-wrap { position: relative; min-height: 62px; padding: 0 58px 32px 12px; border: 1px solid var(--line-strong); border-radius: 12px; background: var(--panel); } +.composer textarea { display: block; min-height: 54px; max-height: 178px; height: 54px; padding: 12px 0 8px; overflow-y: hidden; line-height: 1.4; } +.composer-actions { position: absolute; right: 8px; bottom: 7px; left: 8px; height: 27px; display: flex; align-items: center; gap: 8px; } +.composer-hint { order: 3; padding: 0; color: var(--faint); font: 9px/1 var(--mono); } +.composer-tool { width: 27px; height: 27px; flex: 0 0 27px; border: 1px solid var(--line-strong); border-radius: 7px; color: var(--muted); background: transparent; font-size: 18px; line-height: 1; } +.composer-tool:hover, .composer-tool:focus-visible { border-color: var(--blue); color: var(--blue); outline: none; } +.context-indicator { display: inline-flex; align-items: center; gap: 5px; color: var(--faint); font: 9px/1 var(--mono); white-space: nowrap; } +.context-indicator output { color: var(--muted); } +.send-button { order: 5; width: 32px; height: 32px; flex: 0 0 32px; margin-left: auto; display: grid; place-items: center; padding: 0; border: 1px solid rgba(197,239,145,.48); border-radius: 50%; color: var(--lime-ink); background: var(--lime); font-size: 17px; line-height: 1; } +.send-button:hover, .send-button:focus-visible { box-shadow: 0 0 0 3px rgba(197,239,145,.12); outline: none; } +.send-button[aria-busy="true"] { border-color: rgba(238,146,146,.55); color: var(--bad); background: rgba(238,146,146,.12); } +.send-button[aria-busy="true"]:hover { background: rgba(238,146,146,.2); } +.sr-only { position: absolute; width: 1px; height: 1px; padding: 0; margin: -1px; overflow: hidden; clip: rect(0,0,0,0); white-space: nowrap; border: 0; } +.composer-toolbar { width: min(100%, 900px); min-height: 30px; margin: 0 auto; padding: 0 22px 8px; position: relative; display: flex; align-items: center; gap: 6px; } +.toolbar-button { min-height: 27px; padding: 0 9px; border: 1px solid var(--line); border-radius: 7px; color: var(--muted); background: transparent; font: 9px/1 var(--mono); text-transform: uppercase; } +.toolbar-button:hover, .toolbar-button:focus-visible { border-color: var(--blue); color: var(--blue); outline: none; } +.attachment-menu { position: absolute; z-index: 4; bottom: 37px; left: 22px; display: grid; min-width: 145px; padding: 5px; border: 1px solid var(--line-strong); border-radius: 9px; background: var(--panel-raised); box-shadow: 0 14px 34px rgba(0,0,0,.35); } +.attachment-menu button { padding: 8px 9px; border: 0; border-radius: 6px; color: var(--ink); background: transparent; text-align: left; font-size: 11px; } +.attachment-menu button:hover, .attachment-menu button:focus-visible { color: var(--lime); background: rgba(197,239,145,.1); outline: none; } +.composer-toolbar #media-status { margin-left: 3px; color: var(--yellow); font: 9px/1 var(--mono); } +@media (max-width: 780px) { + .conversation-starters { width: 100%; padding-inline: 12px; overflow-x: auto; flex-wrap: nowrap; scrollbar-width: thin; } + .conversation-starters span { flex: 0 0 auto; width: auto; } + .conversation-starters button { flex: 0 0 auto; } + .composer { width: 100%; padding-inline: 12px; } + .composer-input-wrap { padding-right: 50px; } + .composer-toolbar { width: 100%; padding-inline: 12px; } + .attachment-menu { left: 12px; } + .composer-hint { display: none; } + .context-indicator { font-size: 8px; } +} diff --git a/cmd/creator-web/static/assets/chat.js b/cmd/creator-web/static/assets/chat.js index 7787e76..8889d99 100644 --- a/cmd/creator-web/static/assets/chat.js +++ b/cmd/creator-web/static/assets/chat.js @@ -22,19 +22,49 @@ const conversationStarters = Array.from(document.querySelectorAll("[data-starter const aiDataForm = document.querySelector("#ai-data-form"); const aiDataInput = document.querySelector("#ai-data"); const aiDataError = document.querySelector("#ai-data-error"); +const attachToggle = document.querySelector("#attach-toggle"); +const toolbarAttach = document.querySelector("#toolbar-attach"); +const attachmentMenu = document.querySelector("#attachment-menu"); +const photoTrigger = document.querySelector("#photo-trigger"); +const micTrigger = document.querySelector("#mic-trigger"); +const sendIcon = document.querySelector("#send-icon"); +const stopIcon = document.querySelector("#stop-icon"); let token = sessionStorage.getItem("domovoy.chat.token") || ""; let conversationID = sessionStorage.getItem("domovoy.chat.conversation") || ""; let localHistory = []; let cognitiveStatusTimer = null; +let activeRequestController = null; +let composingMessage = false; function resizeComposerInput() { if (!messageInput) return; messageInput.style.height = "auto"; - messageInput.style.height = `${Math.min(messageInput.scrollHeight, 220)}px`; + const maxHeight = 178; + messageInput.style.height = `${Math.min(messageInput.scrollHeight, maxHeight)}px`; + messageInput.style.overflowY = messageInput.scrollHeight > maxHeight ? "auto" : "hidden"; if (messageCount) messageCount.textContent = `${messageInput.value.length.toLocaleString("ru-RU")} / 16 384`; } +function isChatBusy() { + return sendButton?.getAttribute("aria-busy") === "true"; +} + +function setChatBusy(busy) { + if (!sendButton) return; + sendButton.setAttribute("aria-busy", busy ? "true" : "false"); + sendButton.setAttribute("aria-label", busy ? "Остановить обработку" : "Отправить"); + sendButton.title = busy ? "Остановить обработку" : "Отправить"; + if (sendLabel) sendLabel.textContent = busy ? "Остановить обработку" : "Отправить"; + if (sendIcon) sendIcon.hidden = busy; + if (stopIcon) stopIcon.hidden = !busy; +} + +function setSuggestionsVisible(visible) { + const suggestions = document.querySelector(".conversation-starters"); + if (suggestions) suggestions.hidden = !visible; +} + class ChatApiError extends Error { constructor(status, detail = "") { super(detail || `chat api returned ${status}`); @@ -149,6 +179,7 @@ function appendMessage(kind, text, meta = "", media = null) { messages.appendChild(node); messages.scrollTop = messages.scrollHeight; localHistory.push({kind, text, meta, at: new Date()}); + setSuggestionsVisible(!localHistory.some((item) => item.kind === "user")); renderHistory(); } @@ -318,12 +349,11 @@ async function sendChatMessage(text, options = {}) { const clean = text.trim(); if (!clean) return; if (!options.silentUser) appendMessage("user", clean); - sendButton.disabled = true; - sendButton.setAttribute("aria-busy", "true"); - if (sendLabel) sendLabel.textContent = "Домовой обрабатывает"; + activeRequestController = new AbortController(); + setChatBusy(true); messages.setAttribute("aria-busy", "true"); try { - const result = await api("/api/chat/messages", {method: "POST", body: JSON.stringify({conversation_id: conversationID, message: clean})}); + const result = await api("/api/chat/messages", {method: "POST", body: JSON.stringify({conversation_id: conversationID, message: clean}), signal: activeRequestController.signal}); conversationID = result.conversation_id; sessionStorage.setItem("domovoy.chat.conversation", conversationID); updateActiveFocus(result.active_focus, result.project_provenance); @@ -333,7 +363,9 @@ async function sendChatMessage(text, options = {}) { activateTab("today-panel"); } } catch (error) { - if (error instanceof ChatApiError && (error.status === 401 || error.status === 403)) { + if (error.name === "AbortError") { + appendMessage("assistant", "Обработка остановлена."); + } else if (error instanceof ChatApiError && (error.status === 401 || error.status === 403)) { expireSession("Сессия устарела. Войдите снова, чтобы продолжить разговор."); } else if (error instanceof ChatApiError && error.status >= 500) { appendMessage("assistant", "Домовой не смог завершить обработку: основной runtime сейчас недоступен. Встроенный канал проверяется; повторите сообщение через несколько секунд."); @@ -341,9 +373,8 @@ async function sendChatMessage(text, options = {}) { appendMessage("assistant", "Домовой не принял сообщение. Проверьте текст и повторите попытку."); } } finally { - sendButton.disabled = false; - sendButton.removeAttribute("aria-busy"); - if (sendLabel) sendLabel.textContent = "Отправить"; + activeRequestController = null; + setChatBusy(false); messages.removeAttribute("aria-busy"); messageInput.focus(); } @@ -377,6 +408,7 @@ loginForm.addEventListener("submit", async (event) => { chatForm.addEventListener("submit", async (event) => { event.preventDefault(); + if (isChatBusy()) return; const text = messageInput.value.trim(); if (!text) return; messageInput.value = ""; @@ -385,6 +417,13 @@ chatForm.addEventListener("submit", async (event) => { }); messageInput.addEventListener("input", resizeComposerInput); +messageInput.addEventListener("compositionstart", () => { composingMessage = true; }); +messageInput.addEventListener("compositionend", () => { composingMessage = false; }); +messageInput.addEventListener("keydown", (event) => { + if (event.key !== "Enter" || event.shiftKey || event.isComposing || composingMessage) return; + event.preventDefault(); + if (!isChatBusy()) chatForm.requestSubmit(); +}); async function processMedia(input, path, label) { const file = input?.files?.[0]; @@ -414,6 +453,38 @@ if (audioInput) audioInput.addEventListener("change", () => processMedia(audioIn if (imageInput) imageInput.addEventListener("change", () => processMedia(imageInput, "/api/multimodal/understand", "Описание изображения")); if (ocrInput) ocrInput.addEventListener("change", () => processMedia(ocrInput, "/api/multimodal/ocr", "Текст с изображения")); +function toggleAttachmentMenu(open) { + if (!attachmentMenu) return; + const next = typeof open === "boolean" ? open : attachmentMenu.hidden; + attachmentMenu.hidden = !next; + for (const control of [attachToggle, toolbarAttach]) { + if (control) control.setAttribute("aria-expanded", String(next)); + } +} + +for (const control of [attachToggle, toolbarAttach]) { + if (control) control.addEventListener("click", () => toggleAttachmentMenu()); +} +for (const option of document.querySelectorAll("[data-input]")) { + option.addEventListener("click", () => { + const input = document.querySelector(`#${option.dataset.input}`); + toggleAttachmentMenu(false); + if (input) input.click(); + }); +} +if (photoTrigger && imageInput) photoTrigger.addEventListener("click", () => imageInput.click()); +if (micTrigger && audioInput) micTrigger.addEventListener("click", () => audioInput.click()); +document.addEventListener("click", (event) => { + if (attachmentMenu && !attachmentMenu.hidden && !attachmentMenu.contains(event.target) && !attachToggle?.contains(event.target) && !toolbarAttach?.contains(event.target)) toggleAttachmentMenu(false); +}); +if (sendButton) { + sendButton.addEventListener("click", (event) => { + if (!isChatBusy()) return; + event.preventDefault(); + activeRequestController?.abort(); + }); +} + for (const tab of navTabs) { tab.addEventListener("click", () => activateTab(tab.dataset.tab)); } @@ -421,7 +492,7 @@ for (const tab of navTabs) { for (const starter of conversationStarters) { starter.addEventListener("click", async () => { const text = starter.dataset.starter || ""; - if (!text || sendButton.disabled) return; + if (!text || isChatBusy()) return; messageInput.value = ""; await sendChatMessage(text); }); @@ -433,7 +504,7 @@ if (aiDataForm) { aiDataForm.addEventListener("submit", async (event) => { event.preventDefault(); const details = aiDataInput.value.trim(); - if (!details || sendButton.disabled) return; + if (!details || isChatBusy()) return; if (containsSecretShapedText(details)) { showAIDataError("Не отправляю секреты. Удалите пароль, токен, API-ключ или приватный ключ и оставьте только описание модели."); return; diff --git a/cmd/creator-web/static/chat.html b/cmd/creator-web/static/chat.html index 7bff49d..12bb56f 100644 --- a/cmd/creator-web/static/chat.html +++ b/cmd/creator-web/static/chat.html @@ -66,25 +66,37 @@
-
-
- -
Enter — отправить · Shift+Enter — новая строка0 / 16384
-
- -
-
- - - - -
-
- Можно начать так: +
+ Можно начать так
+
+
+ +
+ + контекст0 / 16 384 + Enter — отправить · Shift+Enter — новая строка + +
+
+
+
+ + + + + + + + +