Skip to content
Draft
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
38 changes: 38 additions & 0 deletions backend/chainlit/message.py
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
import asyncio
import json
import os
import time
import uuid
from abc import ABC
Expand Down Expand Up @@ -195,6 +196,43 @@ async def stream_token(self, token: str, is_sequence=False):
id=self.id, token=token, is_sequence=is_sequence
)

async def set_author_and_avatar(
self,
author: Optional[str] = None,
avatar: Optional[str] = None,
):
"""
Update the author and/or avatar for a message and send it to the UI.

This method allows you to change the displayed author name and avatar for a message after it has been sent. The avatar name is stored in `metadata.avatarName` and is used by the UI to select the corresponding image from the `public/avatars` folder. If a file extension is provided (e.g., "avatar.png"), it will be automatically removed (e.g., "avatar").

Notes:
- If the `@author_rename` decorator is used in your config, it will override any author changes made by this method.
- If `avatar` is not provided, the UI will use the default avatar for the author name.
- The avatar name should match the filename (without extension) of the image in `public/avatars`.

Args:
author (str, optional): The new author name to display. If None, the current author is kept. If no avatar is provided, the avatar shown will be based on the author name.
avatar (str, optional): The new avatar name to display. If None, the current avatar is kept.

Returns:
bool: True if the update was successful and the UI was notified.
"""
# Update author if provided
if author is not None:
self.author = author

# Update avatar name in metadata if provided
if avatar is not None:
if self.metadata is None:
self.metadata = {}
# Remove file extension if present (e.g., "avatar.png" -> "avatar")
clean_avatar_name = os.path.splitext(avatar)[0]
self.metadata["avatarName"] = clean_avatar_name

# Use the existing update mechanism to send changes to UI
return await self.send()


class Message(MessageBase):
"""
Expand Down
93 changes: 93 additions & 0 deletions cypress/e2e/set_author_and_avatar/main.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,93 @@
import chainlit as cl


@cl.on_chat_start
async def on_start():
"""Initialize the chat."""
await cl.Message(
content="""Welcome to the set_author_and_avatar test app!

Available test scenarios:
1. "test author" - Test changing author only
2. "test avatar" - Test changing avatar only
3. "test both" - Test changing both author and avatar
4. "test extension" - Test avatar with file extension
5. "test metadata" - Test message with initial avatarName metadata"""
).send()


@cl.on_message
async def main(message: cl.Message):
"""Handle user messages and demonstrate set_author_and_avatar functionality."""
user_msg = message.content.lower().strip()

if user_msg == "test author":
# Create a fresh message and change only the author
test_msg = cl.Message(
content="Original message from Assistant", author="Assistant"
)
await test_msg.send()

# Change the author
await test_msg.set_author_and_avatar(author="Dr. Watson")
await cl.Message(content="✅ Author changed to 'Dr. Watson'").send()

elif user_msg == "test avatar":
# Create a fresh message and change only the avatar
test_msg = cl.Message(content="Original message from Bob", author="Bob")
await test_msg.send()

# Change the avatar
await test_msg.set_author_and_avatar(avatar="robot")
await cl.Message(content="✅ Avatar changed to 'robot'").send()

elif user_msg == "test both":
# Create a fresh message and change both author and avatar
test_msg = cl.Message(content="Original message from Helper", author="Helper")
await test_msg.send()

# Change both author and avatar
await test_msg.set_author_and_avatar(
author="Sherlock Holmes", avatar="detective"
)
await cl.Message(
content="✅ Changed author to 'Sherlock Holmes' and avatar to 'detective'"
).send()

elif user_msg == "test extension":
# Create a fresh message and test avatar with extension
test_msg = cl.Message(
content="Original message from Researcher", author="Researcher"
)
await test_msg.send()

# Change avatar with .png extension (should be stripped)
await test_msg.set_author_and_avatar(avatar="scientist.png")
await cl.Message(
content="✅ Avatar changed to 'scientist.png' (extension should be stripped to 'scientist')"
).send()

elif user_msg == "test metadata":
# Create a message with initial avatarName in metadata
test_msg = cl.Message(
content="Message created with custom avatar metadata",
author="Custom Bot",
metadata={"avatarName": "robot"},
)
await test_msg.send()
await cl.Message(
content="✅ Message created with avatarName='robot' in metadata"
).send()

else:
# Show available commands for unknown input
await cl.Message(
content="""❓ Unknown command. Available test scenarios:

• **test author** - Test changing author only
• **test avatar** - Test changing avatar only
• **test both** - Test changing both author and avatar
• **test extension** - Test avatar with file extension
• **test metadata** - Test message with initial avatarName metadata
"""
).send()
120 changes: 120 additions & 0 deletions cypress/e2e/set_author_and_avatar/spec.cy.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,120 @@
import { submitMessage } from '../../support/testUtils';

describe('Set Author and Avatar', () => {
beforeEach(() => {
// Visit the test app and wait for welcome message
cy.visit('/');
cy.get('.step').should('have.length', 1);
cy.get('.step')
.eq(0)
.should('contain', 'Welcome to the set_author_and_avatar test app!');
});

it('should change message author only', () => {
// Send command to test author change
submitMessage('test author');

// Should have: welcome + user message + original message + success message
cy.get('.step').should('have.length', 4);
cy.get('.step').eq(2).should('contain', 'Original message from Assistant');
cy.get('.step')
.eq(3)
.should('contain', "✅ Author changed to 'Dr. Watson'");

// Verify the original message now shows the new author in tooltip
cy.get('.step').eq(2).find('.ai-message span').first().trigger('mouseover');
cy.contains('Dr. Watson').should('be.visible');
});

it('should change message avatar only', () => {
// Send command to test avatar change
submitMessage('test avatar');

// Should have: welcome + user message + original message + success message
cy.get('.step').should('have.length', 4);
cy.get('.step').eq(2).should('contain', 'Original message from Bob');
cy.get('.step').eq(3).should('contain', "✅ Avatar changed to 'robot'");

// Verify the original message now uses the robot avatar
cy.get('.step')
.eq(2)
.find('img')
.should('have.attr', 'src')
.and('include', '/avatars/robot');
});

it('should change both author and avatar', () => {
// Send command to test both author and avatar change
submitMessage('test both');

// Should have: welcome + user message + original message + success message
cy.get('.step').should('have.length', 4);
cy.get('.step').eq(2).should('contain', 'Original message from Helper');
cy.get('.step')
.eq(3)
.should(
'contain',
"✅ Changed author to 'Sherlock Holmes' and avatar to 'detective'"
);

// Verify the original message shows both changes
cy.get('.step')
.eq(2)
.find('img')
.should('have.attr', 'src')
.and('include', '/avatars/detective');
cy.get('.step').eq(2).find('.ai-message span').first().trigger('mouseover');
cy.contains('Sherlock Holmes').should('be.visible');
});

it('should handle avatar names with file extensions', () => {
// Send command to test extension stripping
submitMessage('test extension');

// Should have: welcome + user message + original message + success message
cy.get('.step').should('have.length', 4);
cy.get('.step').eq(2).should('contain', 'Original message from Researcher');
cy.get('.step')
.eq(3)
.should(
'contain',
"✅ Avatar changed to 'scientist.png' (extension should be stripped to 'scientist')"
);

// Verify the avatar URL doesn't include the extension
cy.get('.step')
.eq(2)
.find('img')
.should('have.attr', 'src')
.and('include', '/avatars/scientist');
cy.get('.step')
.eq(2)
.find('img')
.should('have.attr', 'src')
.and('not.include', '.png');
});

it('should work with messages created with custom avatar metadata', () => {
// Send command to test initial avatar metadata
submitMessage('test metadata');

// Should have: welcome + user message + test message + success message
cy.get('.step').should('have.length', 4);
cy.get('.step')
.eq(2)
.should('contain', 'Message created with custom avatar metadata');
cy.get('.step')
.eq(3)
.should(
'contain',
"✅ Message created with avatarName='robot' in metadata"
);

// Verify the test message uses the robot avatar from metadata (this is the main test)
cy.get('.step')
.eq(2)
.find('img')
.should('have.attr', 'src')
.and('include', '/avatars/robot');
});
});
9 changes: 6 additions & 3 deletions frontend/src/components/chat/Messages/Message/Avatar.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -19,11 +19,12 @@ import {

interface Props {
author?: string;
avatarName?: string;
hide?: boolean;
isError?: boolean;
}

const MessageAvatar = ({ author, hide, isError }: Props) => {
const MessageAvatar = ({ author, avatarName, hide, isError }: Props) => {
const apiClient = useContext(ChainlitContext);
const { chatProfile } = useChatSession();
const { config } = useConfig();
Expand All @@ -39,8 +40,10 @@ const MessageAvatar = ({ author, hide, isError }: Props) => {
if (isAssistant && selectedChatProfile?.icon) {
return selectedChatProfile.icon;
}
return apiClient?.buildEndpoint(`/avatars/${author || 'default'}`);
}, [apiClient, selectedChatProfile, config, author]);
// Use avatarName if provided, otherwise fall back to author name
const avatarIdentifier = avatarName || author || 'default';
return apiClient?.buildEndpoint(`/avatars/${avatarIdentifier}`);
}, [apiClient, selectedChatProfile, config, author, avatarName]);

if (isError) {
return (
Expand Down
3 changes: 2 additions & 1 deletion frontend/src/components/chat/Messages/Message/index.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -97,7 +97,8 @@ const Message = memo(
<div className="ai-message flex gap-4 w-full">
{!isStep || !indent ? (
<MessageAvatar
author={message.metadata?.avatarName || message.name}
author={message.name}
avatarName={message.metadata?.avatarName}
isError={message.isError}
/>
) : null}
Expand Down
Loading