Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 6 additions & 1 deletion channels/webui.py
Original file line number Diff line number Diff line change
Expand Up @@ -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({
Expand Down Expand Up @@ -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}")

Expand Down
66 changes: 66 additions & 0 deletions channels/webui/assets/css/chat/message_bubble.css
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down Expand Up @@ -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);
Expand Down Expand Up @@ -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;
}
}
}
}
}

Expand Down
96 changes: 93 additions & 3 deletions channels/webui/assets/js/stores/chat.js
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,7 @@ CHAT_STORE = {
turnHistory: [],
editingMessageIndex: null,
editContent: '',
editAttached: [],

user_input: '',
last_user_input: '',
Expand Down Expand Up @@ -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;
})
Expand Down Expand Up @@ -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 = [];
},

/* ----------------------
Expand Down
4 changes: 2 additions & 2 deletions channels/webui/templates/chat/index.html
Original file line number Diff line number Diff line change
Expand Up @@ -42,7 +42,7 @@
<!-- user turn (always one message) -->
<template x-if="turn.role === 'user'">
<div class="message-wrapper user" x-data="{ get message() { return turn.messages[0] } }">
<div class="message user">
<div class="message user" :class="{'editing-active': $store.chat.editingMessageIndex === turn.first_message_index}">
{% include 'chat/turns/user.html' %}
<div x-data="{ get targetIndex() { return turn.first_message_index } }" class="fullwidth">
{% include 'chat/message_buttons.html' %}
Expand All @@ -54,7 +54,7 @@
<!-- assistant turn (multiple messages combined into one bubble) -->
<template x-if="turn.role === 'assistant'">
<div class="message-wrapper assistant">
<div class="message assistant">
<div class="message assistant" :class="{'editing-active': $store.chat.editingMessageIndex === turn.last_message_index}">
<template x-for="(message, messageIndex) in turn.messages" :key="messageIndex">
{% include 'chat/turns/assistant_history.html' %}
</template>
Expand Down
2 changes: 1 addition & 1 deletion channels/webui/templates/chat/turns/assistant_history.html
Original file line number Diff line number Diff line change
Expand Up @@ -14,7 +14,7 @@
</template>
<template x-if="$store.chat.editingMessageIndex === turn.last_message_index">
<div class="flex fullwidth editing">
<textarea x-model="$store.chat.editContent" rows="3"></textarea>
<textarea x-model="$store.chat.editContent" rows="3" x-autosize @focus="$autosize($el)"></textarea>
<div class="edit-actions">
<button @click="$store.chat.saveEdit($store.chat.editingMessageIndex)">Save</button>
<button @click="$store.chat.cancelEdit()">Cancel</button>
Expand Down
14 changes: 13 additions & 1 deletion channels/webui/templates/chat/turns/user.html
Original file line number Diff line number Diff line change
Expand Up @@ -33,10 +33,22 @@

<template x-if="$store.chat.editingMessageIndex === turn.first_message_index">
<div class="flex fullwidth editing">
<textarea x-model="$store.chat.editContent" rows="3"></textarea>
<textarea x-model="$store.chat.editContent" rows="3" x-autosize @focus="$autosize($el)"></textarea>
<div class="edit-actions">
<button @click="$store.chat.saveEdit($store.chat.editingMessageIndex)">Save</button>
<button @click="$store.chat.cancelEdit()">Cancel</button>
</div>
<template x-if="$store.chat.editAttached.length > 0">
<div class="edit-files">
<template x-for="fname in $store.chat.editAttached" :key="fname">
<div class="edit-file-row">
<button class="edit-file-remove" title="Remove file" @click="$store.chat.removeEditFile(fname)">
{% include 'svg/cross.svg' %}
</button>
<span class="edit-file-name" x-text="fname"></span>
</div>
</template>
</div>
</template>
</div>
</template>
18 changes: 15 additions & 3 deletions core/channel.py
Original file line number Diff line number Diff line change
Expand Up @@ -232,7 +232,7 @@ def _extract_content(self, message_dict):
# ---------------------
# Content Processors
# ---------------------
async def _process_multimodal(self, message: str = None, files: list = None) -> list:
async def _process_multimodal(self, message: str = None, files: list = None, metadata: dict = None) -> list:
"""
Converts a list of file handler objects into an openAI API multimodal message object,
allowing the AI to process images, audio, etc.
Expand All @@ -245,12 +245,18 @@ async def _process_multimodal(self, message: str = None, files: list = None) ->
"my_audio.mp3": (file handler object),
and so on
}

`metadata` is preserved when the message is already multimodal.
"""
content_blocks = []

# if the message was a list... this was already multimodal, so dont modify
if isinstance(message, list):
return {"role": "user", "content": message}
result = {"role": "user", "content": message}
if metadata:
# copy so we don't mutate the caller's dict
result["_metadata"] = dict(metadata)
return result

if not message and not files:
# wtf why would you do that
Expand Down Expand Up @@ -418,15 +424,21 @@ async def _send_preprocess(self, message: str, files: list = None, commands_auth
"""
internal helper function so that send() and send_stream()
both use many of the same code paths and i don't have to keep maintaining each one individually

if the message is a dict (e.g. regenerate passes the stored message through),
its `_metadata` is preserved so attachment info like `_metadata.filenames`
survives being re-added to history.
"""
await self._set_as_active_channel()
user_message = message
metadata = None

# sometimes legacy parts of the openlumara framework still send dicts.
# that is not supposed to happen, and i need to find the code that does it
# so, TODO: find the legacy code that calls channel.send()/send_stream() with dicts
# but for now.. to avoid breaking everything, i'll convert
if isinstance(user_message, dict):
metadata = user_message.get("_metadata")
user_message = user_message.get("content", "")

if isinstance(user_message, str):
Expand Down Expand Up @@ -467,7 +479,7 @@ async def _send_preprocess(self, message: str, files: list = None, commands_auth
user_message = usr_msg_result

# apply multimodal content if applicable
user_message_processed = await self._process_multimodal(message=user_message, files=files)
user_message_processed = await self._process_multimodal(message=user_message, files=files, metadata=metadata)

# and add the user's message to context
add_success = await self.context.chat.messages.add(user_message_processed)
Expand Down