This commit is contained in:
@@ -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; }
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -66,25 +66,37 @@
|
||||
<main class="chat-main">
|
||||
<section id="chat-panel" class="surface-panel active-panel">
|
||||
<div id="messages" class="messages" aria-live="polite"></div>
|
||||
<form id="chat-form" class="composer">
|
||||
<div class="composer-input-wrap">
|
||||
<textarea id="message" rows="1" maxlength="16384" aria-label="Сообщение Домовому" placeholder="Напишите Домовому обычное сообщение..." required></textarea>
|
||||
<div class="composer-hint"><span>Enter — отправить · Shift+Enter — новая строка</span><output id="message-count" aria-live="polite">0 / 16384</output></div>
|
||||
</div>
|
||||
<button id="send" type="submit"><span id="send-label">Отправить</span><span aria-hidden="true">↵</span></button>
|
||||
</form>
|
||||
<div class="media-tools" aria-label="Мультимодальные сообщения">
|
||||
<label>Аудио <input id="audio-input" type="file" accept="audio/*"></label>
|
||||
<label>Фото <input id="image-input" type="file" accept="image/*"></label>
|
||||
<label>Текст с фото <input id="ocr-input" type="file" accept="image/*"></label>
|
||||
<span id="media-status" role="status" aria-live="polite"></span>
|
||||
</div>
|
||||
<div class="conversation-starters" aria-label="Частые вопросы">
|
||||
<span>Можно начать так:</span>
|
||||
<div class="conversation-starters" aria-label="Предложения для начала разговора">
|
||||
<span>Можно начать так</span>
|
||||
<button type="button" data-starter="Проверь локальный канал мышления">Проверь канал</button>
|
||||
<button type="button" data-starter="Что сейчас доступно?">Что доступно?</button>
|
||||
<button type="button" data-starter="Хочу изменить настройку локальной модели">Изменить настройку</button>
|
||||
</div>
|
||||
<form id="chat-form" class="composer">
|
||||
<div class="composer-input-wrap">
|
||||
<textarea id="message" rows="1" maxlength="16384" aria-label="Сообщение Домовому" placeholder="Напишите Домовому обычное сообщение..." required></textarea>
|
||||
<div class="composer-actions">
|
||||
<button id="attach-toggle" class="composer-tool" type="button" aria-expanded="false" aria-controls="attachment-menu" title="Вложить">+</button>
|
||||
<span class="context-indicator" title="Ограничение контекста"><span>контекст</span><output id="message-count" aria-live="polite">0 / 16 384</output></span>
|
||||
<span class="composer-hint">Enter — отправить · Shift+Enter — новая строка</span>
|
||||
<button id="send" class="send-button" type="submit" aria-label="Отправить" title="Отправить"><span id="send-label" class="sr-only">Отправить</span><span id="send-icon" aria-hidden="true">↑</span><span id="stop-icon" aria-hidden="true" hidden>■</span></button>
|
||||
</div>
|
||||
</div>
|
||||
</form>
|
||||
<div class="composer-toolbar media-tools" aria-label="Вложения и голос">
|
||||
<button id="toolbar-attach" class="toolbar-button" type="button" aria-expanded="false" aria-controls="attachment-menu">+ Вложить</button>
|
||||
<button id="photo-trigger" class="toolbar-button" type="button">Фото</button>
|
||||
<button id="mic-trigger" class="toolbar-button" type="button">Микрофон</button>
|
||||
<div id="attachment-menu" class="attachment-menu" hidden>
|
||||
<button type="button" data-input="image-input">Фото</button>
|
||||
<button type="button" data-input="audio-input">Аудио / голос</button>
|
||||
<button type="button" data-input="ocr-input">Текст с фото</button>
|
||||
</div>
|
||||
<input id="audio-input" type="file" accept="audio/*" hidden>
|
||||
<input id="image-input" type="file" accept="image/*" hidden>
|
||||
<input id="ocr-input" type="file" accept="image/*" hidden>
|
||||
<span id="media-status" role="status" aria-live="polite"></span>
|
||||
</div>
|
||||
</section>
|
||||
<section id="today-panel" class="surface-panel attention-surface" hidden>
|
||||
<div class="surface-heading"><p>Сегодня / внимание</p><button id="refresh-today" type="button">Обновить</button></div>
|
||||
|
||||
Reference in New Issue
Block a user