display user avatar in shared chat messages

This commit is contained in:
CREDO23 2026-01-14 19:46:37 +02:00
parent dcd36166fc
commit 83d6b735fa
4 changed files with 98 additions and 107 deletions

View file

@ -411,13 +411,9 @@ async def get_thread_messages(
Requires CHATS_READ permission. Requires CHATS_READ permission.
""" """
try: try:
# Get thread with messages and their authors # Get thread first
result = await session.execute( result = await session.execute(
select(NewChatThread) select(NewChatThread).filter(NewChatThread.id == thread_id)
.options(
selectinload(NewChatThread.messages).selectinload(NewChatMessage.author)
)
.filter(NewChatThread.id == thread_id)
) )
thread = result.scalars().first() thread = result.scalars().first()
@ -436,6 +432,15 @@ async def get_thread_messages(
# Check thread-level access based on visibility # Check thread-level access based on visibility
await check_thread_access(session, thread, user) await check_thread_access(session, thread, user)
# Get messages with their authors loaded
messages_result = await session.execute(
select(NewChatMessage)
.options(selectinload(NewChatMessage.author))
.filter(NewChatMessage.thread_id == thread_id)
.order_by(NewChatMessage.created_at)
)
db_messages = messages_result.scalars().all()
# Return messages in the format expected by assistant-ui # Return messages in the format expected by assistant-ui
messages = [ messages = [
NewChatMessageRead( NewChatMessageRead(
@ -448,7 +453,7 @@ async def get_thread_messages(
author_display_name=msg.author.display_name if msg.author else None, author_display_name=msg.author.display_name if msg.author else None,
author_avatar_url=msg.author.avatar_url if msg.author else None, author_avatar_url=msg.author.avatar_url if msg.author else None,
) )
for msg in thread.messages for msg in db_messages
] ]
return ThreadHistoryLoadResponse(messages=messages) return ThreadHistoryLoadResponse(messages=messages)

View file

@ -185,12 +185,25 @@ function convertToThreadMessage(msg: MessageRecord): ThreadMessageLike {
} }
} }
// Build metadata.custom for author display in shared chats
const metadata = msg.author_id
? {
custom: {
author: {
displayName: msg.author_display_name ?? null,
avatarUrl: msg.author_avatar_url ?? null,
},
},
}
: undefined;
return { return {
id: `msg-${msg.id}`, id: `msg-${msg.id}`,
role: msg.role, role: msg.role,
content, content,
createdAt: new Date(msg.created_at), createdAt: new Date(msg.created_at),
attachments, attachments,
metadata,
}; };
} }
@ -306,12 +319,6 @@ export default function NewChatPage() {
if (steps.length > 0) { if (steps.length > 0) {
restoredThinkingSteps.set(`msg-${msg.id}`, steps); restoredThinkingSteps.set(`msg-${msg.id}`, steps);
} }
// Hydrate write_todos plan state from persisted tool calls
// Disabled for now
// const writeTodosCalls = extractWriteTodosFromContent(msg.content);
// for (const todoData of writeTodosCalls) {
// hydratePlanState(todoData);
// }
} }
if (msg.role === "user") { if (msg.role === "user") {
const docs = extractMentionedDocuments(msg.content); const docs = extractMentionedDocuments(msg.content);

View file

@ -19,9 +19,7 @@ import {
ChevronRightIcon, ChevronRightIcon,
CopyIcon, CopyIcon,
DownloadIcon, DownloadIcon,
FileText,
Loader2, Loader2,
PencilIcon,
RefreshCwIcon, RefreshCwIcon,
SquareIcon, SquareIcon,
} from "lucide-react"; } from "lucide-react";
@ -31,7 +29,6 @@ import { createPortal } from "react-dom";
import { import {
mentionedDocumentIdsAtom, mentionedDocumentIdsAtom,
mentionedDocumentsAtom, mentionedDocumentsAtom,
messageDocumentsMapAtom,
} from "@/atoms/chat/mentioned-documents.atom"; } from "@/atoms/chat/mentioned-documents.atom";
import { import {
globalNewLLMConfigsAtom, globalNewLLMConfigsAtom,
@ -42,8 +39,8 @@ import { currentUserAtom } from "@/atoms/user/user-query.atoms";
import { import {
ComposerAddAttachment, ComposerAddAttachment,
ComposerAttachments, ComposerAttachments,
UserMessageAttachments,
} from "@/components/assistant-ui/attachment"; } from "@/components/assistant-ui/attachment";
import { UserMessage } from "@/components/assistant-ui/user-message";
import { ConnectorIndicator } from "@/components/assistant-ui/connector-popup"; import { ConnectorIndicator } from "@/components/assistant-ui/connector-popup";
import { import {
InlineMentionEditor, InlineMentionEditor,
@ -639,69 +636,6 @@ const AssistantActionBar: FC = () => {
); );
}; };
const UserMessage: FC = () => {
const messageId = useAssistantState(({ message }) => message?.id);
const messageDocumentsMap = useAtomValue(messageDocumentsMapAtom);
const mentionedDocs = messageId ? messageDocumentsMap[messageId] : undefined;
const hasAttachments = useAssistantState(
({ message }) => message?.attachments && message.attachments.length > 0
);
return (
<MessagePrimitive.Root
className="aui-user-message-root fade-in slide-in-from-bottom-1 mx-auto grid w-full max-w-(--thread-max-width) animate-in auto-rows-auto grid-cols-[minmax(72px,1fr)_auto] content-start gap-y-2 px-2 py-3 duration-150 [&:where(>*)]:col-start-2"
data-role="user"
>
<div className="aui-user-message-content-wrapper col-start-2 min-w-0">
{/* Display attachments and mentioned documents */}
{(hasAttachments || (mentionedDocs && mentionedDocs.length > 0)) && (
<div className="flex flex-wrap items-end gap-2 mb-2 justify-end">
{/* Attachments (images show as thumbnails, documents as chips) */}
<UserMessageAttachments />
{/* Mentioned documents as chips */}
{mentionedDocs?.map((doc) => (
<span
key={`${doc.document_type}:${doc.id}`}
className="inline-flex items-center gap-1 px-2 py-0.5 rounded-full bg-primary/10 text-xs font-medium text-primary border border-primary/20"
title={doc.title}
>
<FileText className="size-3" />
<span className="max-w-[150px] truncate">{doc.title}</span>
</span>
))}
</div>
)}
{/* Message bubble with action bar positioned relative to it */}
<div className="relative">
<div className="aui-user-message-content wrap-break-word rounded-2xl bg-muted px-4 py-2.5 text-foreground">
<MessagePrimitive.Parts />
</div>
<div className="aui-user-action-bar-wrapper absolute top-1/2 right-full -translate-y-1/2 pr-1">
<UserActionBar />
</div>
</div>
</div>
<BranchPicker className="aui-user-branch-picker -mr-1 col-span-full col-start-1 row-start-3 justify-end" />
</MessagePrimitive.Root>
);
};
const UserActionBar: FC = () => {
return (
<ActionBarPrimitive.Root
hideWhenRunning
autohide="not-last"
className="aui-user-action-bar-root flex flex-col items-end"
>
<ActionBarPrimitive.Edit asChild>
<TooltipIconButton tooltip="Edit" className="aui-user-action-edit p-4">
<PencilIcon />
</TooltipIconButton>
</ActionBarPrimitive.Edit>
</ActionBarPrimitive.Root>
);
};
const EditComposer: FC = () => { const EditComposer: FC = () => {
return ( return (

View file

@ -1,16 +1,53 @@
import { ActionBarPrimitive, MessagePrimitive, useAssistantState } from "@assistant-ui/react"; import { ActionBarPrimitive, MessagePrimitive, useAssistantState } from "@assistant-ui/react";
import { useAtomValue } from "jotai"; import { useAtomValue } from "jotai";
import { FileText, PencilIcon } from "lucide-react"; import { FileText, PencilIcon } from "lucide-react";
import type { FC } from "react"; import { type FC, useState } from "react";
import { messageDocumentsMapAtom } from "@/atoms/chat/mentioned-documents.atom"; import { messageDocumentsMapAtom } from "@/atoms/chat/mentioned-documents.atom";
import { UserMessageAttachments } from "@/components/assistant-ui/attachment"; import { UserMessageAttachments } from "@/components/assistant-ui/attachment";
import { BranchPicker } from "@/components/assistant-ui/branch-picker"; import { BranchPicker } from "@/components/assistant-ui/branch-picker";
import { TooltipIconButton } from "@/components/assistant-ui/tooltip-icon-button"; import { TooltipIconButton } from "@/components/assistant-ui/tooltip-icon-button";
interface AuthorMetadata {
displayName: string | null;
avatarUrl: string | null;
}
const UserAvatar: FC<AuthorMetadata> = ({ displayName, avatarUrl }) => {
const [hasError, setHasError] = useState(false);
const initials = displayName
? displayName
.split(" ")
.map((n) => n[0])
.join("")
.toUpperCase()
.slice(0, 2)
: "U";
if (avatarUrl && !hasError) {
return (
<img
src={avatarUrl}
alt={displayName || "User"}
className="size-8 rounded-full object-cover"
onError={() => setHasError(true)}
/>
);
}
return (
<div className="flex size-8 items-center justify-center rounded-full bg-primary/10 text-xs font-medium text-primary">
{initials}
</div>
);
};
export const UserMessage: FC = () => { export const UserMessage: FC = () => {
const messageId = useAssistantState(({ message }) => message?.id); const messageId = useAssistantState(({ message }) => message?.id);
const messageDocumentsMap = useAtomValue(messageDocumentsMapAtom); const messageDocumentsMap = useAtomValue(messageDocumentsMapAtom);
const mentionedDocs = messageId ? messageDocumentsMap[messageId] : undefined; const mentionedDocs = messageId ? messageDocumentsMap[messageId] : undefined;
const metadata = useAssistantState(({ message }) => message?.metadata);
const author = metadata?.custom?.author as AuthorMetadata | undefined;
const hasAttachments = useAssistantState( const hasAttachments = useAssistantState(
({ message }) => message?.attachments && message.attachments.length > 0 ({ message }) => message?.attachments && message.attachments.length > 0
); );
@ -20,34 +57,42 @@ export const UserMessage: FC = () => {
className="aui-user-message-root fade-in slide-in-from-bottom-1 mx-auto grid w-full max-w-(--thread-max-width) animate-in auto-rows-auto grid-cols-[minmax(72px,1fr)_auto] content-start gap-y-2 px-2 py-3 duration-150 [&:where(>*)]:col-start-2" className="aui-user-message-root fade-in slide-in-from-bottom-1 mx-auto grid w-full max-w-(--thread-max-width) animate-in auto-rows-auto grid-cols-[minmax(72px,1fr)_auto] content-start gap-y-2 px-2 py-3 duration-150 [&:where(>*)]:col-start-2"
data-role="user" data-role="user"
> >
<div className="aui-user-message-content-wrapper col-start-2 min-w-0"> <div className="aui-user-message-content-wrapper col-start-2 min-w-0 flex items-end gap-2">
{/* Display attachments and mentioned documents */} <div className="flex-1 min-w-0">
{(hasAttachments || (mentionedDocs && mentionedDocs.length > 0)) && ( {/* Display attachments and mentioned documents */}
<div className="flex flex-wrap items-end gap-2 mb-2 justify-end"> {(hasAttachments || (mentionedDocs && mentionedDocs.length > 0)) && (
{/* Attachments (images show as thumbnails, documents as chips) */} <div className="flex flex-wrap items-end gap-2 mb-2 justify-end">
<UserMessageAttachments /> {/* Attachments (images show as thumbnails, documents as chips) */}
{/* Mentioned documents as chips */} <UserMessageAttachments />
{mentionedDocs?.map((doc) => ( {/* Mentioned documents as chips */}
<span {mentionedDocs?.map((doc) => (
key={`${doc.document_type}:${doc.id}`} <span
className="inline-flex items-center gap-1 px-2 py-0.5 rounded-full bg-primary/10 text-xs font-medium text-primary border border-primary/20" key={`${doc.document_type}:${doc.id}`}
title={doc.title} className="inline-flex items-center gap-1 px-2 py-0.5 rounded-full bg-primary/10 text-xs font-medium text-primary border border-primary/20"
> title={doc.title}
<FileText className="size-3" /> >
<span className="max-w-[150px] truncate">{doc.title}</span> <FileText className="size-3" />
</span> <span className="max-w-[150px] truncate">{doc.title}</span>
))} </span>
</div> ))}
)} </div>
{/* Message bubble with action bar positioned relative to it */} )}
<div className="relative"> {/* Message bubble with action bar positioned relative to it */}
<div className="aui-user-message-content wrap-break-word rounded-2xl bg-muted px-4 py-2.5 text-foreground"> <div className="relative">
<MessagePrimitive.Parts /> <div className="aui-user-message-content wrap-break-word rounded-2xl bg-muted px-4 py-2.5 text-foreground">
</div> <MessagePrimitive.Parts />
<div className="aui-user-action-bar-wrapper absolute top-1/2 right-full -translate-y-1/2 pr-1"> </div>
<UserActionBar /> <div className="aui-user-action-bar-wrapper absolute top-1/2 right-full -translate-y-1/2 pr-1">
<UserActionBar />
</div>
</div> </div>
</div> </div>
{/* User avatar - only shown in shared chats */}
{author && (
<div className="shrink-0">
<UserAvatar displayName={author.displayName} avatarUrl={author.avatarUrl} />
</div>
)}
</div> </div>
<BranchPicker className="aui-user-branch-picker -mr-1 col-span-full col-start-1 row-start-3 justify-end" /> <BranchPicker className="aui-user-branch-picker -mr-1 col-span-full col-start-1 row-start-3 justify-end" />