diff --git a/internal/service/static/icons.svg b/internal/service/static/icons.svg index 9a71bfb..584fe48 100644 --- a/internal/service/static/icons.svg +++ b/internal/service/static/icons.svg @@ -124,4 +124,8 @@ + + + + diff --git a/internal/service/static/main.css b/internal/service/static/main.css index 680d635..656be96 100644 --- a/internal/service/static/main.css +++ b/internal/service/static/main.css @@ -1023,3 +1023,162 @@ body { .footer__toolbar-context--critical { color: var(--color-red); } + +/* ==================== */ +.modal-overlay { + position: fixed; + inset: 0; + display: flex; + align-items: center; + justify-content: center; + background: rgba(0, 0, 0, 0.5); + z-index: 1000; + padding: var(--s2); +} + +.modal { + background: var(--color-surface); + border-radius: var(--radius); + border: var(--border-width) solid var(--color-border); + width: 100%; + max-width: 600px; + max-height: 90vh; + display: flex; + flex-direction: column; + overflow: hidden; +} + +.modal__header { + display: flex; + align-items: center; + justify-content: space-between; + padding: var(--s-1) var(--s1); + border-bottom: 1px solid var(--color-border-light); + flex-shrink: 0; +} + +.modal__header h2 { + font-size: var(--s0); + font-weight: 600; + margin: 0; +} + +.modal__close { + background: none; + border: none; + cursor: pointer; + padding: var(--s-2); + color: var(--color-text-muted); + display: flex; + align-items: center; + flex-shrink: 0; +} + +.modal__close .icon { + width: var(--icon-size); + height: var(--icon-size); +} + +.modal__close:hover { + color: var(--color-text); +} + +.modal__body { + padding: var(--s1); + overflow-y: auto; + flex: 1; + min-height: 0; +} + +.modal__textarea { + width: 100%; + min-height: 200px; + height: 100%; + padding: var(--s-1); + font-family: var(--font-mono); + font-size: var(--s-1); + line-height: 1.5; + border: var(--border-width) solid var(--color-border-light); + border-radius: var(--radius); + resize: none; + outline: none; +} + +.modal__textarea:focus { + border-color: var(--color-primary); +} + +.modal__textarea--error { + border-color: var(--color-red); +} + +.modal__footer { + padding: var(--s-1) var(--s1); + border-top: 1px solid var(--color-border-light); + display: flex; + justify-content: flex-end; + flex-shrink: 0; +} + +.modal__save { + padding: var(--s-1) var(--s2); + font-size: var(--s0); + background: var(--color-primary); + color: white; + border: none; + border-radius: var(--radius); + cursor: pointer; +} + +.modal__save:hover { + background: var(--color-primary-hover); +} + +.modal__save--error { + background: var(--color-red); + animation: modal-flash 0.3s ease-in-out; +} + +@keyframes modal-flash { + 0%, + 100% { + opacity: 1; + } + 50% { + opacity: 0.5; + } +} + +/* Mobile: full-screen modal */ +@media (max-width: 768px) { + .modal-overlay { + padding: 0; + align-items: stretch; + } + + .modal { + max-width: 100%; + max-height: 100%; + height: 100%; + border-radius: 0; + border: none; + } + + .modal__header { + padding: var(--s-1) var(--s1); + } + + .modal__body { + padding: var(--s-1) var(--s1); + } + + .modal__footer { + padding: var(--s-1) var(--s1); + } + + .modal__save { + width: 100%; + padding: var(--s-1) var(--s2); + font-size: var(--s0); + } +} diff --git a/internal/service/static/main.js b/internal/service/static/main.js index 2a69543..033f5e2 100644 --- a/internal/service/static/main.js +++ b/internal/service/static/main.js @@ -937,6 +937,389 @@ class Odidere { this.#clearPendingDelete(); } + // ==================== + // EDIT MODAL + // ==================== + + /** + * #handleEditClick opens the edit modal for the given message. + * @param {MouseEvent} event + * @param {string} messageId + */ + #handleEditClick(event, messageId) { + event.stopPropagation(); + this.#openEditModal(messageId); + } + + /** + * #openEditModal creates and shows the edit modal for a message. + * @param {string} messageId + */ + #openEditModal(messageId) { + const msg = this.messagesMap.get(messageId); + if (!msg) return; + + // Build modal DOM. + const $overlay = this.document.createElement('div'); + $overlay.className = 'modal-overlay'; + $overlay.innerHTML = ` + + `; + + // Determine if this message should be edited as JSON. + const isJson = this.#shouldShowJson(msg); + const textarea = $overlay.querySelector('#edit-textarea'); + const saveBtn = $overlay.querySelector('#edit-save'); + const closeBtn = $overlay.querySelector('.modal__close'); + + // Populate textarea. + if (isJson) { + textarea.value = JSON.stringify( + this.#extractEditableFields(msg), + null, + 2, + ); + } else { + textarea.value = this.#extractText(msg.content); + } + + // Adjust textarea size for JSON. + if (isJson) { + textarea.rows = 20; + } + + // Bind events. + closeBtn.addEventListener('click', () => this.#closeEditModal()); + saveBtn.addEventListener('click', () => + this.#saveEdit(messageId, textarea, isJson, saveBtn), + ); + + // Close on overlay click (outside modal). + $overlay.addEventListener('click', (e) => { + if (e.target === $overlay) this.#closeEditModal(); + }); + + // Close on Escape. + const handleEscape = (e) => { + if (e.key === 'Escape') { + this.#closeEditModal(); + this.document.removeEventListener('keydown', handleEscape); + } + }; + this.document.addEventListener('keydown', handleEscape); + + this.document.body.appendChild($overlay); + textarea.focus(); + } + + /** + * #closeEditModal removes the modal from the DOM. + */ + #closeEditModal() { + const $overlay = this.document.querySelector('.modal-overlay'); + if ($overlay) $overlay.remove(); + } + + /** + * #shouldShowJson determines if a message should be edited as JSON. + * Assistant messages with reasoning or tool calls are shown as JSON. + * @param {Object} message + * @returns {boolean} + */ + #shouldShowJson(message) { + if (message.role !== 'assistant') return false; + if (message.reasoning_content) return true; + if (message.tool_calls?.length > 0) return true; + return false; + } + + /** + * #extractEditableFields returns the editable fields of a message, + * stripping tree-structural fields (id, parent, children, meta). + * @param {Object} message + * @returns {Object} + */ + #extractEditableFields(message) { + const { id, parent, children, meta, ...editable } = message; + + if (message.role === 'assistant' && message.tool_calls?.length > 0) { + const toolCallIds = new Set(message.tool_calls.map((tc) => tc.id)); + const toolResults = []; + for (const result of this.messagesMap.values()) { + if (result.role === 'tool' && toolCallIds.has(result.tool_call_id)) { + toolResults.push(this.#extractEditableFields(result)); + } + } + if (toolResults.length > 0) { + editable.tool_results = toolResults; + } + } + + return editable; + } + + /** + * #saveEdit validates and applies the edited content to a message. + * @param {string} messageId + * @param {HTMLTextAreaElement} textarea + * @param {boolean} isJson + * @param {HTMLButtonElement} saveBtn + */ + #saveEdit(messageId, textarea, isJson, saveBtn) { + const msg = this.messagesMap.get(messageId); + if (!msg) return; + + if (isJson) { + // JSON mode: parse and validate. + let parsed; + try { + parsed = JSON.parse(textarea.value); + } catch (_e) { + // Invalid JSON: show error, do nothing else. + textarea.classList.add('modal__textarea--error'); + saveBtn.classList.add('modal__save--error'); + setTimeout(() => { + saveBtn.classList.remove('modal__save--error'); + }, 500); + return; + } + + // Apply parsed fields to the message. + // Preserve structural fields (id, parent, children, meta). + // tool_results is a synthetic editable field for hidden tool result + // messages that are displayed inside assistant tool-call blocks. + for (const key of Object.keys(parsed)) { + if ( + ['id', 'parent', 'children', 'meta', 'tool_results'].includes(key) + ) { + continue; + } + msg[key] = parsed[key]; + } + + if (msg.role === 'assistant' && Array.isArray(parsed.tool_results)) { + const toolCallIds = new Set((msg.tool_calls ?? []).map((tc) => tc.id)); + const existingResults = []; + for (const result of this.messagesMap.values()) { + if (result.role === 'tool' && toolCallIds.has(result.tool_call_id)) { + existingResults.push(result); + } + } + + parsed.tool_results.forEach((parsedResult, index) => { + const result = + existingResults.find( + (r) => r.tool_call_id === parsedResult?.tool_call_id, + ) ?? existingResults[index]; + if (!result || !parsedResult || typeof parsedResult !== 'object') { + return; + } + for (const key of Object.keys(parsedResult)) { + if (['id', 'parent', 'children', 'meta'].includes(key)) continue; + result[key] = parsedResult[key]; + } + }); + } + } else { + // Plain text mode: update content. + const newText = textarea.value; + if (Array.isArray(msg.content)) { + // Multipart: update first text part or create one. + const textPart = msg.content.find((p) => p.type === ContentTypeText); + if (textPart) { + textPart.text = newText; + } else { + msg.content.unshift({ type: ContentTypeText, text: newText }); + } + } else { + msg.content = newText; + } + } + + // Persist. + this.#saveToStorage(); + + // Re-render the message in place. + this.#reRenderMessage(msg); + + // Close modal. + this.#closeEditModal(); + } + + /** + * #reRenderMessage replaces the DOM element for a message. + * @param {Object} message + */ + #reRenderMessage(message) { + const $old = this.$chat.querySelector(`[data-id="${message.id}"]`); + if (!$old) return; + + if (message.role === 'user') { + // Render user message. + const $msg = + this.$tplUserMessage.content.cloneNode(true).firstElementChild; + if (message.id) $msg.dataset.id = message.id; + + const content = this.#extractText(message.content); + $msg.querySelector('.message__content').textContent = content; + + // Populate debug panel. + const $dl = $msg.querySelector('.message__debug-list'); + if (message.meta?.language) + this.#appendDebugRow($dl, 'Language', message.meta.language); + if (message.meta?.voice) + this.#appendDebugRow($dl, 'Voice', message.meta.voice); + if ($dl.children.length === 0) this.#appendDebugRow($dl, 'No data', ''); + + // Bind action buttons. + const $inspect = $msg.querySelector('[data-action="inspect"]'); + $inspect.addEventListener('click', () => { + const isOpen = $msg.classList.toggle('message--debug-open'); + $inspect.setAttribute('aria-expanded', String(isOpen)); + }); + + const $copy = $msg.querySelector('[data-action="copy"]'); + $copy.addEventListener('click', () => + this.#copyToClipboard($copy, content), + ); + + const $delete = $msg.querySelector('[data-action="delete"]'); + $delete.addEventListener('click', (e) => + this.#handleDeleteClick(e, message.id), + ); + + const $edit = $msg.querySelector('[data-action="edit"]'); + $edit.addEventListener('click', (e) => + this.#handleEditClick(e, message.id), + ); + + $old.replaceWith($msg); + } else if (message.role === 'assistant') { + // Render assistant message. + const $msg = + this.$tplAssistantMessage.content.cloneNode(true).firstElementChild; + if (message.id) $msg.dataset.id = message.id; + + const $body = $msg.querySelector('.message__body'); + const $content = $msg.querySelector('.message__content'); + + // Reasoning block. + if (message.reasoning_content) { + const $reasoning = this.#createCollapsibleBlock( + 'reasoning', + 'Reasoning', + message.reasoning_content, + ); + $body.insertBefore($reasoning, $content); + } + + // Tool call blocks. + if (message.tool_calls?.length > 0) { + const toolResultMap = new Map(); + const toolCallIds = new Set(message.tool_calls.map((tc) => tc.id)); + for (const result of this.messagesMap.values()) { + if (result.role === 'tool' && toolCallIds.has(result.tool_call_id)) { + toolResultMap.set(result.tool_call_id, result); + } + } + for (const toolCall of message.tool_calls) { + const $toolBlock = this.#createToolCallBlock( + toolCall, + toolResultMap.get(toolCall.id), + ); + $body.insertBefore($toolBlock, $content); + } + } + + // Main content. + if (this.#extractText(message.content)) { + $content.textContent = this.#extractText(message.content); + } else { + $content.remove(); + } + + // Populate debug panel. + const $dl = $msg.querySelector('.message__debug-list'); + if (message.meta?.voice) + this.#appendDebugRow($dl, 'Voice', message.meta.voice); + if (message.meta?.usage) { + const u = message.meta.usage; + this.#appendDebugRow($dl, 'Prompt tokens', String(u.prompt_tokens)); + this.#appendDebugRow( + $dl, + 'Completion tokens', + String(u.completion_tokens), + ); + this.#appendDebugRow($dl, 'Total tokens', String(u.total_tokens)); + } + if ($dl.children.length === 0) this.#appendDebugRow($dl, 'No data', ''); + + // Bind action buttons. + const $inspect = $msg.querySelector('[data-action="inspect"]'); + $inspect.addEventListener('click', () => { + const isOpen = $msg.classList.toggle('message--debug-open'); + $inspect.setAttribute('aria-expanded', String(isOpen)); + }); + + // Play button. + const $play = $msg.querySelector('[data-action="play"]'); + if (this.#extractText(message.content)) { + const voice = message.meta?.voice || this.$voice.value || ''; + $play.addEventListener('click', (e) => + this.#handlePlayClick( + e, + message.id, + this.#extractText(message.content), + voice, + ), + ); + } else { + $play.remove(); + } + + // Copy button. + const $copy = $msg.querySelector('[data-action="copy"]'); + $copy.addEventListener('click', () => { + const copyParts = []; + if (message.reasoning_content) + copyParts.push(message.reasoning_content); + for (const tc of message.tool_calls ?? []) { + const name = tc.function?.name || 'unknown'; + const args = tc.function?.arguments || '{}'; + copyParts.push(`${name}(${args})`); + } + if (this.#extractText(message.content)) + copyParts.push(this.#extractText(message.content)); + this.#copyToClipboard($copy, copyParts.join('\n\n')); + }); + + const $delete = $msg.querySelector('[data-action="delete"]'); + $delete.addEventListener('click', (e) => + this.#handleDeleteClick(e, message.id), + ); + + const $edit = $msg.querySelector('[data-action="edit"]'); + $edit.addEventListener('click', (e) => + this.#handleEditClick(e, message.id), + ); + + $old.replaceWith($msg); + } + } + /** * #stopConversation aborts the current streaming request and audio. */ @@ -2370,6 +2753,11 @@ class Odidere { this.#handleDeleteClick(e, message.id), ); + const $edit = $msg.querySelector('[data-action="edit"]'); + $edit.addEventListener('click', (e) => + this.#handleEditClick(e, message.id), + ); + this.$chat.insertBefore($msg, this.$chatLoading); this.#scrollToBottom(); } @@ -2579,6 +2967,11 @@ class Odidere { this.#handleDeleteClick(e, message.id), ); + const $edit = $msg.querySelector('[data-action="edit"]'); + $edit.addEventListener('click', (e) => + this.#handleEditClick(e, message.id), + ); + $streaming.replaceWith($msg); this.#scrollToBottom(); return $msg; @@ -2721,6 +3114,11 @@ class Odidere { this.#handleDeleteClick(e, message.id), ); + const $edit = $msg.querySelector('[data-action="edit"]'); + $edit.addEventListener('click', (e) => + this.#handleEditClick(e, message.id), + ); + this.$chat.insertBefore($msg, this.$chatLoading); this.#scrollToBottom(); } diff --git a/internal/service/templates/static/templates.gohtml b/internal/service/templates/static/templates.gohtml index a4103a5..bd58a2a 100644 --- a/internal/service/templates/static/templates.gohtml +++ b/internal/service/templates/static/templates.gohtml @@ -23,6 +23,14 @@ > + +