- Updated `uni_message_sender.py` to allow for private messaging by removing the mandatory group ID and adding user ID handling. - Enhanced chat history retrieval and clearing functions in `routes.py` and `service.py` to support both group and private chat scenarios. - Introduced a new `ChatScrollContext` for managing message scrolling and highlighting in the chat UI. - Created a `ListItemEditorHookFactory` for rendering a rich UI editor for list items in configuration settings, replacing the previous JSON text display. - Improved message serialization for consistent display in chat history. - Added detailed logging for chat history operations and error handling. Co-authored-by: Copilot <copilot@github.com>
256 lines
8.9 KiB
TypeScript
256 lines
8.9 KiB
TypeScript
import { Bot, Sparkles, User } from 'lucide-react'
|
||
import { useCallback, useEffect, useMemo, useRef } from 'react'
|
||
import { useTranslation } from 'react-i18next'
|
||
|
||
import { Avatar, AvatarFallback, AvatarImage } from '@/components/ui/avatar'
|
||
import { ScrollArea } from '@/components/ui/scroll-area'
|
||
import { cn } from '@/lib/utils'
|
||
|
||
import { ChatScrollContext, type ChatScrollContextValue } from './ChatScrollContext'
|
||
import { RenderMessageContent } from './MessageRenderer'
|
||
import type { ChatMessage } from './types'
|
||
|
||
interface MessageListProps {
|
||
messages: ChatMessage[]
|
||
isLoadingHistory: boolean
|
||
botDisplayName: string
|
||
/** 机器人 QQ 号;存在时会从 QQ 头像公开接口拉取头像作为 bot 头像。 */
|
||
botQq?: string
|
||
userName: string
|
||
language: string
|
||
}
|
||
|
||
interface BubbleAvatarProps {
|
||
type: 'user' | 'bot'
|
||
visible: boolean
|
||
/** bot 头像 URL(可选);加载失败时自动 fallback 到默认 SVG 图标。 */
|
||
imageUrl?: string
|
||
}
|
||
|
||
function BubbleAvatar({ type, visible, imageUrl }: BubbleAvatarProps) {
|
||
return (
|
||
<div className="h-8 w-8 shrink-0 sm:h-9 sm:w-9">
|
||
{visible && (
|
||
<Avatar className="h-full w-full ring-1 ring-border/60">
|
||
{type === 'bot' && imageUrl ? (
|
||
<AvatarImage src={imageUrl} alt="" className="object-cover" />
|
||
) : null}
|
||
<AvatarFallback
|
||
className={cn(
|
||
'text-xs',
|
||
type === 'user'
|
||
? 'bg-secondary text-secondary-foreground'
|
||
: 'bg-primary-gradient text-primary-foreground'
|
||
)}
|
||
>
|
||
{type === 'user' ? (
|
||
<User className="h-4 w-4" />
|
||
) : (
|
||
<Bot className="h-4 w-4" />
|
||
)}
|
||
</AvatarFallback>
|
||
</Avatar>
|
||
)}
|
||
</div>
|
||
)
|
||
}
|
||
|
||
function EmptyState({ botName }: { botName: string }) {
|
||
const { t } = useTranslation()
|
||
return (
|
||
<div className="flex h-full flex-col items-center justify-center gap-3 px-6 py-16 text-center">
|
||
<div className="bg-primary-gradient text-primary-foreground relative flex h-16 w-16 items-center justify-center rounded-2xl shadow-lg">
|
||
<Sparkles className="h-7 w-7" />
|
||
<span className="bg-primary/30 absolute inset-0 -z-10 animate-pulse rounded-2xl blur-xl" />
|
||
</div>
|
||
<div className="space-y-1">
|
||
<h2 className="text-base font-semibold sm:text-lg">
|
||
{t('chat.message.empty', { bot: botName })}
|
||
</h2>
|
||
<p className="text-muted-foreground text-xs sm:text-sm">{t('chat.message.emptyHint')}</p>
|
||
</div>
|
||
</div>
|
||
)
|
||
}
|
||
|
||
/**
|
||
* 聊天消息列表:支持连续同发送者消息分组、富文本与系统/错误信息样式。
|
||
*/
|
||
export function MessageList({
|
||
messages,
|
||
isLoadingHistory,
|
||
botDisplayName,
|
||
botQq,
|
||
userName,
|
||
language,
|
||
}: MessageListProps) {
|
||
const { t } = useTranslation()
|
||
const endRef = useRef<HTMLDivElement>(null)
|
||
const messageRefs = useRef<Map<string, HTMLDivElement>>(new Map())
|
||
|
||
useEffect(() => {
|
||
endRef.current?.scrollIntoView({ behavior: 'smooth' })
|
||
}, [messages])
|
||
|
||
const scrollToMessage = useCallback((messageId: string) => {
|
||
const target = messageRefs.current.get(messageId)
|
||
if (!target) {
|
||
return false
|
||
}
|
||
target.scrollIntoView({ behavior: 'smooth', block: 'center' })
|
||
target.classList.add('chat-message-flash')
|
||
window.setTimeout(() => {
|
||
target.classList.remove('chat-message-flash')
|
||
}, 1600)
|
||
return true
|
||
}, [])
|
||
|
||
const scrollContextValue = useMemo<ChatScrollContextValue>(
|
||
() => ({ scrollToMessage }),
|
||
[scrollToMessage]
|
||
)
|
||
|
||
const formatTime = (timestamp: number) => {
|
||
const date = new Date(timestamp * 1000)
|
||
return date.toLocaleTimeString(language || 'zh-CN', {
|
||
hour: '2-digit',
|
||
minute: '2-digit',
|
||
})
|
||
}
|
||
|
||
// 优先使用 q1.qlogo.cn s=640(高清),QQ 公开头像接口。
|
||
const botAvatarUrl = botQq && /^\d+$/.test(botQq)
|
||
? `https://q1.qlogo.cn/g?b=qq&nk=${botQq}&s=640`
|
||
: undefined
|
||
|
||
if (messages.length === 0 && !isLoadingHistory) {
|
||
return (
|
||
<div className="min-w-0 min-h-0 flex-1 overflow-hidden">
|
||
<ScrollArea
|
||
className="h-full w-full"
|
||
contentClassName="!block w-full min-w-0"
|
||
scrollbars="vertical"
|
||
viewportClassName="[&>div]:!block [&>div]:!min-w-0 [&>div]:w-full"
|
||
>
|
||
<EmptyState botName={botDisplayName} />
|
||
</ScrollArea>
|
||
</div>
|
||
)
|
||
}
|
||
|
||
return (
|
||
<div className="min-w-0 min-h-0 flex-1 overflow-hidden">
|
||
<ScrollArea
|
||
className="h-full w-full"
|
||
contentClassName="!block w-full min-w-0"
|
||
scrollbars="vertical"
|
||
viewportClassName="[&>div]:!block [&>div]:!min-w-0 [&>div]:w-full"
|
||
>
|
||
<ChatScrollContext.Provider value={scrollContextValue}>
|
||
<div className="mx-auto flex w-full max-w-4xl min-w-0 flex-col gap-1 px-3 py-5 sm:px-6 sm:py-6">
|
||
{messages.map((message, index) => {
|
||
// 系统消息:作为分隔条
|
||
if (message.type === 'system') {
|
||
return (
|
||
<div key={message.id} className="my-2 flex items-center gap-3">
|
||
<div className="bg-border/60 h-px flex-1" />
|
||
<span className="text-muted-foreground bg-card/70 rounded-full border px-3 py-0.5 text-[11px]">
|
||
{message.content}
|
||
</span>
|
||
<div className="bg-border/60 h-px flex-1" />
|
||
</div>
|
||
)
|
||
}
|
||
|
||
// 错误消息
|
||
if (message.type === 'error') {
|
||
return (
|
||
<div key={message.id} className="my-2 flex justify-center">
|
||
<div className="bg-destructive/10 text-destructive border-destructive/30 rounded-full border px-3 py-1 text-xs">
|
||
{message.content}
|
||
</div>
|
||
</div>
|
||
)
|
||
}
|
||
|
||
const isUser = message.type === 'user'
|
||
const bubbleType: 'user' | 'bot' = isUser ? 'user' : 'bot'
|
||
|
||
// 是否与上一条消息属于同一发送者(用于分组:仅首条显示头像 + 名字)
|
||
const previous = messages[index - 1]
|
||
const sameGroup =
|
||
previous &&
|
||
previous.type === message.type &&
|
||
(previous.sender?.user_id ?? previous.sender?.name) ===
|
||
(message.sender?.user_id ?? message.sender?.name)
|
||
|
||
const senderName =
|
||
message.sender?.name || (isUser ? userName : botDisplayName)
|
||
|
||
return (
|
||
<div
|
||
key={message.id}
|
||
ref={(node) => {
|
||
if (node) {
|
||
messageRefs.current.set(message.id, node)
|
||
} else {
|
||
messageRefs.current.delete(message.id)
|
||
}
|
||
}}
|
||
data-message-id={message.id}
|
||
className={cn(
|
||
'chat-message-row flex w-full min-w-0 items-end gap-2 sm:gap-3',
|
||
isUser ? 'flex-row-reverse' : 'flex-row',
|
||
sameGroup ? 'mt-0.5' : 'mt-3 first:mt-0'
|
||
)}
|
||
>
|
||
<BubbleAvatar
|
||
type={bubbleType}
|
||
visible={!sameGroup}
|
||
imageUrl={bubbleType === 'bot' ? botAvatarUrl : undefined}
|
||
/>
|
||
|
||
<div
|
||
className={cn(
|
||
'flex min-w-0 max-w-[80%] flex-col sm:max-w-[70%]',
|
||
isUser ? 'items-end' : 'items-start'
|
||
)}
|
||
>
|
||
{!sameGroup && (
|
||
<div
|
||
className={cn(
|
||
'text-muted-foreground mb-1 flex items-center gap-2 px-1 text-[11px]',
|
||
isUser && 'flex-row-reverse'
|
||
)}
|
||
>
|
||
<span className="hidden font-medium sm:inline">{senderName}</span>
|
||
<span>{formatTime(message.timestamp)}</span>
|
||
</div>
|
||
)}
|
||
|
||
<div
|
||
className={cn(
|
||
'shadow-sm/30 wrap-break-word min-w-0 max-w-full overflow-hidden px-3.5 py-2 text-sm leading-relaxed',
|
||
isUser
|
||
? 'bg-primary text-primary-foreground rounded-2xl rounded-br-md'
|
||
: 'bg-muted text-foreground rounded-2xl rounded-bl-md'
|
||
)}
|
||
>
|
||
<RenderMessageContent message={message} isBot={!isUser} />
|
||
</div>
|
||
</div>
|
||
</div>
|
||
)
|
||
})}
|
||
<div ref={endRef} />
|
||
{/* 用于读屏 / 避免悬空 */}
|
||
<span className="sr-only" aria-live="polite">
|
||
{messages.length > 0 ? t('chat.sidebar.subtitle', { count: messages.length }) : ''}
|
||
</span>
|
||
</div>
|
||
</ChatScrollContext.Provider>
|
||
</ScrollArea>
|
||
</div>
|
||
)
|
||
}
|