diff --git a/channels/webui.py b/channels/webui.py index 293e3203..aff356ea 100644 --- a/channels/webui.py +++ b/channels/webui.py @@ -886,6 +886,11 @@ async def websocket_endpoint(websocket: fastapi.WebSocket): message = await channel.context.chat.messages.get(index) message["content"] = data.get("content") + + filenames = data.get("filenames") + if filenames is not None: + message.setdefault("_metadata", {})["filenames"] = filenames + await channel.context.chat.messages.edit(index, message) await ws_mgr.broadcast({ @@ -920,7 +925,7 @@ async def websocket_endpoint(websocket: fastapi.WebSocket): await channel.context.chat.messages.delete_from(max(0, last_user_message_index)) await ws_mgr.broadcast({"type": "sync"}) - await ws_mgr.start_stream(channel, channel.context.chat.get("id"), user_message.get("content")) + await ws_mgr.start_stream(channel, channel.context.chat.get("id"), user_message) case _: channel.log(channel.name, f"Unknown websocket command received: {msg_type}") diff --git a/channels/webui/assets/css/chat/message_bubble.css b/channels/webui/assets/css/chat/message_bubble.css index 56db60eb..908f8a7f 100644 --- a/channels/webui/assets/css/chat/message_bubble.css +++ b/channels/webui/assets/css/chat/message_bubble.css @@ -15,6 +15,13 @@ position: relative; max-width: var(--message-max-width, 75%); width: fit-content; + + /* when a message is being edited, expand the bubble to its max width + so the edit box doesn't collapse to the textarea's intrinsic size */ + &.editing-active { + width: 100%; + } + backdrop-filter: blur(10px); .command { @@ -177,6 +184,9 @@ textarea { width: 100%; min-height: 60px; + max-height: 80vh; + /* autosize switches to scroll when the content hits max-height */ + overflow-y: hidden; padding: 8px; background: var(--bg-input); color: var(--text-primary); @@ -230,6 +240,62 @@ } } } + + .edit-files { + display: flex; + flex-direction: column; + gap: 4px; + padding: 8px; + background: var(--bg-tertiary); + border: 1px solid var(--border-color); + border-radius: var(--radius-sm); + max-width: 100%; + + .edit-file-row { + display: flex; + align-items: center; + gap: 8px; + + .edit-file-remove { + width: 28px; + height: 28px; + padding: 0; + flex-shrink: 0; + background: var(--bg-tertiary); + border: 1px solid var(--border-color); + border-radius: var(--radius-md); + color: var(--text-secondary); + cursor: pointer; + display: flex; + align-items: center; + justify-content: center; + opacity: 0.6; + transition: all 0.2s ease; + + svg { + width: 14px; + height: 14px; + } + + &:hover { + background: var(--bg-secondary); + color: var(--error); + border-color: var(--error); + opacity: 1; + } + + &:active { + transform: scale(0.95); + } + } + + .edit-file-name { + font-size: 0.85rem; + color: var(--text-secondary); + word-break: break-all; + } + } + } } } diff --git a/channels/webui/assets/js/stores/chat.js b/channels/webui/assets/js/stores/chat.js index ac83129a..55cacd63 100644 --- a/channels/webui/assets/js/stores/chat.js +++ b/channels/webui/assets/js/stores/chat.js @@ -16,6 +16,7 @@ CHAT_STORE = { turnHistory: [], editingMessageIndex: null, editContent: '', + editAttached: [], user_input: '', last_user_input: '', @@ -227,7 +228,13 @@ CHAT_STORE = { const turn = this.turnHistory[turnIndex]; const msg = turn?.messages?.[turn.messages?.length - 1]; // last message in the turn if (!msg) return; - navigator.clipboard.writeText(msg.content) + + // copy and edit should always show the same content (text). + const text = Array.isArray(msg.content) + ? this._extractEditText(msg) + : msg.content; + + navigator.clipboard.writeText(text) .then(() => { return true; }) @@ -266,24 +273,107 @@ CHAT_STORE = { if (!msg) { return; } this.editingMessageIndex = msg.index; - this.editContent = msg.content; + this.editContent = this._extractEditText(msg); + // files currently attached (excluding the '' text slot) - the working set + // for this edit session; changes only commit on save + this.editAttached = (msg._metadata?.filenames || []).filter(f => f); Alpine.store('ui').scrollToTurnIndex = turnIndex; + + // after Alpine renders the edit box, scroll its top into view + // (the bubble collapses when entering edit mode, which can leave + // the edit box above the visible area) + Alpine.nextTick(() => { + const turnEl = document.querySelector(`[data-turn-index="${turnIndex}"]`); + turnEl?.querySelector('.editing textarea')?.scrollIntoView({ block: 'start' }); + }); + }, + + /* + * messages with attached files store their content as an array of blocks + * (text + image_url/input_audio/text blocks for files). + * only the first block is the user's own editable text + * (it is the only one with an empty entry in _metadata.filenames). + */ + _extractEditText(msg) { + if (Array.isArray(msg.content)) { + const filenames = msg._metadata?.filenames; + const isUserTextFirst = Array.isArray(filenames) && filenames[0] === ''; + if (isUserTextFirst) { + const textBlock = msg.content.find(block => block.type === 'text'); + return textBlock?.text ?? ''; + } + // no user text (pure file upload) - nothing editable + return ''; + } + return msg.content; }, async cancelEdit() { this.editingMessageIndex = null; this.editContent = ''; + this.editAttached = []; + }, + + removeEditFile(fname) { + this.editAttached = this.editAttached.filter(f => f !== fname); + }, + + _findMessage(index) { + for (const turn of this.turnHistory) { + const found = (turn.messages || []).find(m => m.index === index); + if (found) { return found; } + } + return null; }, async saveEdit(index) { + // find the original message so we can preserve any attached files + let origMessage = this._findMessage(index); + + let content = this.editContent; + let filenames = null; + + if (origMessage && Array.isArray(origMessage.content)) { + // keep the file blocks, only replace the first text block (the actual message), + // and drop any blocks whose file is no longer in the edit working set + const attached = this.editAttached; + let replacedText = false; + const blocks = []; + const names = []; + + origMessage.content.forEach((block, blockIndex) => { + const fname = origMessage._metadata?.filenames?.[blockIndex]; + if (fname && !attached.includes(fname)) { return; } // file removed by user + + if (block.type === 'text' && !replacedText) { + replacedText = true; + blocks.push({ ...block, text: content }); + } else { + blocks.push(block); + } + names.push(fname || ''); + }); + + // if the message had no text block but the user typed something, add one + if (!replacedText && content) { + blocks.unshift({ type: 'text', text: content }); + names.unshift(''); + } + + content = blocks; + filenames = names; + } + await simpleSocketSend({ "type": "message_edit", "index": index, - "content": this.editContent + "content": content, + "filenames": filenames }); this.editingMessageIndex = null; this.editContent = ''; + this.editAttached = []; }, /* ---------------------- diff --git a/channels/webui/templates/chat/index.html b/channels/webui/templates/chat/index.html index 2772828b..0b0fd38d 100644 --- a/channels/webui/templates/chat/index.html +++ b/channels/webui/templates/chat/index.html @@ -42,7 +42,7 @@