diff --git a/dashboard/src/components/dynamic-form/DynamicConfigForm.tsx b/dashboard/src/components/dynamic-form/DynamicConfigForm.tsx index abb377c2..105d0f0e 100644 --- a/dashboard/src/components/dynamic-form/DynamicConfigForm.tsx +++ b/dashboard/src/components/dynamic-form/DynamicConfigForm.tsx @@ -157,6 +157,7 @@ export const DynamicConfigForm: React.FC = ({ value={values[key]} onChange={(v) => onChange(key, v)} schema={nestedField ?? nestedSchema} + nestedSchema={nestedSchema} /> ) @@ -169,6 +170,7 @@ export const DynamicConfigForm: React.FC = ({ value={values[key]} onChange={(v) => onChange(key, v)} schema={nestedField ?? nestedSchema} + nestedSchema={nestedSchema} > = ({ {schema.required && *} {schema.description && ( -

{schema.description}

+

{schema.description}

)} = ({ {/* Description */} {schema.description && ( -

{schema.description}

+

{schema.description}

)} ) diff --git a/dashboard/src/index.css b/dashboard/src/index.css index e1256388..5866b625 100644 --- a/dashboard/src/index.css +++ b/dashboard/src/index.css @@ -331,6 +331,23 @@ .border-primary-gradient { border-image: var(--color-primary-gradient, linear-gradient(to right, hsl(var(--color-primary)), hsl(var(--color-primary)))) 1; } + + /* 聊天消息行被回复点击命中时的高亮闪烁。 */ + .chat-message-flash { + animation: chat-message-flash 1.6s ease-out; + border-radius: 0.75rem; + } +} + +@keyframes chat-message-flash { + 0% { + background-color: hsl(var(--color-primary) / 0.18); + box-shadow: 0 0 0 4px hsl(var(--color-primary) / 0.18); + } + 100% { + background-color: transparent; + box-shadow: 0 0 0 0 transparent; + } } /* 禁用动效时的样式 */ diff --git a/dashboard/src/lib/chat-ws-client.ts b/dashboard/src/lib/chat-ws-client.ts index 88e6fa7c..f6f67a61 100644 --- a/dashboard/src/lib/chat-ws-client.ts +++ b/dashboard/src/lib/chat-ws-client.ts @@ -11,10 +11,31 @@ interface ChatSessionOpenPayload { type ChatSessionListener = (message: Record) => void +/** 浅层比较两个 session.open 负载是否完全一致。 */ +function arePayloadsEqual( + left: ChatSessionOpenPayload, + right: ChatSessionOpenPayload +): boolean { + const keys = new Set([ + ...(Object.keys(left) as Array), + ...(Object.keys(right) as Array), + ]) + for (const key of keys) { + if (left[key] !== right[key]) { + return false + } + } + return true +} + class ChatWsClient { private initialized = false private listeners: Map> = new Map() private sessionPayloads: Map = new Map() + // 记录当前 WS 连接上已打开的会话,避免 React StrictMode 双挂载重复发送 ``session.open``。 + private openedSessions: Set = new Set() + // 记录正在进行中的打开请求,使同一会话的重复调用复用同一个 Promise。 + private pendingOpens: Map> = new Map() private initialize(): void { if (this.initialized) { @@ -41,9 +62,20 @@ class ChatWsClient { }) unifiedWsClient.onReconnect(() => { + // 重连后需要重新订阅,清空本地“已打开”标记。 + this.openedSessions.clear() + this.pendingOpens.clear() void this.reopenSessions() }) + unifiedWsClient.onConnectionChange((connected) => { + if (!connected) { + // 连接断开后,下次重新连上需要重新发送 session.open。 + this.openedSessions.clear() + this.pendingOpens.clear() + } + }) + this.initialized = true } @@ -60,6 +92,7 @@ class ChatWsClient { restore: true, } as Record, }) + this.openedSessions.add(sessionId) } catch (error) { console.error(`恢复聊天会话失败 (${sessionId}):`, error) } @@ -68,17 +101,49 @@ class ChatWsClient { async openSession(sessionId: string, payload: ChatSessionOpenPayload): Promise { this.initialize() + + const previousPayload = this.sessionPayloads.get(sessionId) this.sessionPayloads.set(sessionId, payload) - await unifiedWsClient.call({ - domain: 'chat', - method: 'session.open', - session: sessionId, - data: payload as Record, - }) + + // 同一会话上一次打开请求还未完成 → 复用该 Promise,避免重复发送。 + const inflight = this.pendingOpens.get(sessionId) + if (inflight) { + await inflight + return + } + + // 如果该会话在当前 WS 连接上已经打开,且负载未变化,则跳过,避免服务端重复断开/重连。 + if ( + this.openedSessions.has(sessionId) && + previousPayload !== undefined && + arePayloadsEqual(previousPayload, payload) + ) { + return + } + + const openPromise = unifiedWsClient + .call({ + domain: 'chat', + method: 'session.open', + session: sessionId, + data: payload as Record, + }) + .then(() => { + this.openedSessions.add(sessionId) + }) + + this.pendingOpens.set(sessionId, openPromise) + try { + await openPromise + } finally { + this.pendingOpens.delete(sessionId) + } } async closeSession(sessionId: string): Promise { this.sessionPayloads.delete(sessionId) + this.openedSessions.delete(sessionId) + this.pendingOpens.delete(sessionId) if (unifiedWsClient.getStatus() !== 'connected') { return } diff --git a/dashboard/src/lib/field-hooks.ts b/dashboard/src/lib/field-hooks.ts index 9efa691b..438c5d8d 100644 --- a/dashboard/src/lib/field-hooks.ts +++ b/dashboard/src/lib/field-hooks.ts @@ -15,6 +15,12 @@ export interface FieldHookComponentProps { onChange?: (value: unknown) => void children?: ReactNode schema?: ConfigSchema | FieldSchema + /** + * 如果当前字段是 `List[ConfigBase]` 或嵌套 ConfigBase, + * 这里会传入对应子配置类的 ConfigSchema,便于自定义编辑器 + * 直接渲染列表项的字段。 + */ + nestedSchema?: ConfigSchema } /** diff --git a/dashboard/src/lib/version.ts b/dashboard/src/lib/version.ts index 4b85d2b2..a6be3db1 100644 --- a/dashboard/src/lib/version.ts +++ b/dashboard/src/lib/version.ts @@ -5,7 +5,7 @@ * 修改此处的版本号后,所有展示版本的地方都会自动更新 */ -export const APP_VERSION = '1.0.0' +export const APP_VERSION = '1.0.1' export const APP_NAME = 'MaiBot Dashboard' export const APP_FULL_NAME = `${APP_NAME} v${APP_VERSION}` diff --git a/dashboard/src/routes/chat/ChatScrollContext.tsx b/dashboard/src/routes/chat/ChatScrollContext.tsx new file mode 100644 index 00000000..3ef7eed4 --- /dev/null +++ b/dashboard/src/routes/chat/ChatScrollContext.tsx @@ -0,0 +1,14 @@ +import { createContext, useContext } from 'react' + +/** 暴露给消息内容渲染器使用的滚动 / 高亮接口。 */ +export interface ChatScrollContextValue { + /** 滚动并高亮指定消息;若消息不在可视列表中则返回 ``false``。 */ + scrollToMessage: (messageId: string) => boolean +} + +export const ChatScrollContext = createContext(null) + +/** 在消息列表内部使用:访问 ``scrollToMessage`` 等能力。 */ +export function useChatScroll(): ChatScrollContextValue | null { + return useContext(ChatScrollContext) +} diff --git a/dashboard/src/routes/chat/ChatWorkspaceSidebar.tsx b/dashboard/src/routes/chat/ChatWorkspaceSidebar.tsx index 8892db09..e83df5d7 100644 --- a/dashboard/src/routes/chat/ChatWorkspaceSidebar.tsx +++ b/dashboard/src/routes/chat/ChatWorkspaceSidebar.tsx @@ -22,9 +22,8 @@ interface ChatWorkspaceSidebarProps { onUpdateUserName: (name: string) => void } -function getMessagePreview(message: ChatMessage | undefined, fallback: string, thinking: string) { +function getMessagePreview(message: ChatMessage | undefined, fallback: string) { if (!message) return fallback - if (message.type === 'thinking') return thinking if (message.type === 'system' || message.type === 'error') return message.content || fallback return message.content || fallback } @@ -45,8 +44,7 @@ function ConversationItem({ const lastMessage = tab.messages[tab.messages.length - 1] const preview = getMessagePreview( lastMessage, - t('chat.sidebar.emptyPreview'), - t('chat.message.thinking') + t('chat.sidebar.emptyPreview') ) const Icon = isVirtual ? UserCircle2 : Bot diff --git a/dashboard/src/routes/chat/MessageList.tsx b/dashboard/src/routes/chat/MessageList.tsx index f1e0848f..65160556 100644 --- a/dashboard/src/routes/chat/MessageList.tsx +++ b/dashboard/src/routes/chat/MessageList.tsx @@ -1,11 +1,12 @@ import { Bot, Sparkles, User } from 'lucide-react' -import { useEffect, useRef } from 'react' +import { useCallback, useEffect, useMemo, useRef } from 'react' import { useTranslation } from 'react-i18next' -import { Avatar, AvatarFallback } from '@/components/ui/avatar' +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' @@ -13,20 +14,27 @@ interface MessageListProps { messages: ChatMessage[] isLoadingHistory: boolean botDisplayName: string + /** 机器人 QQ 号;存在时会从 QQ 头像公开接口拉取头像作为 bot 头像。 */ + botQq?: string userName: string language: string } interface BubbleAvatarProps { - type: 'user' | 'bot' | 'thinking' + type: 'user' | 'bot' visible: boolean + /** bot 头像 URL(可选);加载失败时自动 fallback 到默认 SVG 图标。 */ + imageUrl?: string } -function BubbleAvatar({ type, visible }: BubbleAvatarProps) { +function BubbleAvatar({ type, visible, imageUrl }: BubbleAvatarProps) { return (
{visible && ( + {type === 'bot' && imageUrl ? ( + + ) : null} - - - - - - {t('chat.message.thinking')} -
- ) -} - function EmptyState({ botName }: { botName: string }) { const { t } = useTranslation() return ( @@ -80,22 +74,42 @@ function EmptyState({ botName }: { botName: string }) { } /** - * 聊天消息列表:支持连续同发送者消息分组、思考占位、富文本与系统/错误信息样式。 + * 聊天消息列表:支持连续同发送者消息分组、富文本与系统/错误信息样式。 */ export function MessageList({ messages, isLoadingHistory, botDisplayName, + botQq, userName, language, }: MessageListProps) { const { t } = useTranslation() const endRef = useRef(null) + const messageRefs = useRef>(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( + () => ({ scrollToMessage }), + [scrollToMessage] + ) + const formatTime = (timestamp: number) => { const date = new Date(timestamp * 1000) return date.toLocaleTimeString(language || 'zh-CN', { @@ -104,6 +118,11 @@ export function MessageList({ }) } + // 优先使用 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 (
@@ -127,7 +146,8 @@ export function MessageList({ scrollbars="vertical" viewportClassName="[&>div]:!block [&>div]:!min-w-0 [&>div]:w-full" > -
+ +
{messages.map((message, index) => { // 系统消息:作为分隔条 if (message.type === 'system') { @@ -154,8 +174,7 @@ export function MessageList({ } const isUser = message.type === 'user' - const isThinking = message.type === 'thinking' - const bubbleType: 'user' | 'bot' | 'thinking' = isUser ? 'user' : isThinking ? 'thinking' : 'bot' + const bubbleType: 'user' | 'bot' = isUser ? 'user' : 'bot' // 是否与上一条消息属于同一发送者(用于分组:仅首条显示头像 + 名字) const previous = messages[index - 1] @@ -171,13 +190,25 @@ export function MessageList({ return (
{ + if (node) { + messageRefs.current.set(message.id, node) + } else { + messageRefs.current.delete(message.id) + } + }} + data-message-id={message.id} className={cn( - 'flex w-full min-w-0 items-end gap-2 sm:gap-3', + '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' )} > - +
)} - {isThinking ? ( - - ) : ( -
- -
- )} +
+ +
) @@ -220,7 +247,8 @@ export function MessageList({ {messages.length > 0 ? t('chat.sidebar.subtitle', { count: messages.length }) : ''} -
+
+
) diff --git a/dashboard/src/routes/chat/MessageRenderer.tsx b/dashboard/src/routes/chat/MessageRenderer.tsx index e60ff1e3..95c7a119 100644 --- a/dashboard/src/routes/chat/MessageRenderer.tsx +++ b/dashboard/src/routes/chat/MessageRenderer.tsx @@ -1,8 +1,30 @@ +import { Reply as ReplyIcon } from 'lucide-react' import { useTranslation } from 'react-i18next' +import { useToast } from '@/hooks/use-toast' import { cn } from '@/lib/utils' -import type { ChatMessage, MessageSegment } from './types' +import { useChatScroll } from './ChatScrollContext' +import type { + AtSegmentData, + ChatMessage, + MessageSegment, + ReplySegmentData, +} from './types' + +function normalizeAtSegment(segment: MessageSegment): AtSegmentData { + if (segment.data && typeof segment.data === 'object') { + return segment.data as AtSegmentData + } + return { target_user_id: segment.data == null ? null : String(segment.data) } +} + +function normalizeReplySegment(segment: MessageSegment): ReplySegmentData { + if (segment.data && typeof segment.data === 'object') { + return segment.data as ReplySegmentData + } + return { target_message_id: segment.data == null ? null : String(segment.data) } +} // 渲染单个消息段 export function RenderMessageSegment({ segment }: { segment: MessageSegment }) { @@ -74,8 +96,27 @@ export function RenderMessageSegment({ segment }: { segment: MessageSegment }) { ) - case 'reply': - return {t('chat.media.reply')} + case 'reply': { + const replyData = normalizeReplySegment(segment) + return + } + + case 'at': { + const atData = normalizeAtSegment(segment) + const atLabel = + atData.target_user_cardname || + atData.target_user_nickname || + atData.target_user_id || + '' + return ( + + @{atLabel || t('chat.media.unknownMessage')} + + ) + } case 'forward': return {t('chat.media.forward')} @@ -103,11 +144,29 @@ export function RenderMessageContent({ }) { // 如果是富文本消息,渲染消息段 if (message.message_type === 'rich' && message.segments && message.segments.length > 0) { + // 将 reply 段与后续内容拆开,避免回复块与文本出现在同一行上。 + const inlineSegments: MessageSegment[] = [] + const replySegments: MessageSegment[] = [] + for (const segment of message.segments) { + if (segment.type === 'reply') { + replySegments.push(segment) + } else { + inlineSegments.push(segment) + } + } + return (
- {message.segments.map((segment, index) => ( - + {replySegments.map((segment, index) => ( + ))} + {inlineSegments.length > 0 && ( +
+ {inlineSegments.map((segment, index) => ( + + ))} +
+ )}
) } @@ -115,3 +174,61 @@ export function RenderMessageContent({ // 普通文本消息 return {message.content} } + +// 回复消息块:点击可跳转到原始消息;如原消息不可见则提示错误。 +function ReplySegmentBlock({ data }: { data: ReplySegmentData }) { + const { t } = useTranslation() + const { toast } = useToast() + const chatScroll = useChatScroll() + + const senderName = + data.target_message_sender_cardname || + data.target_message_sender_nickname || + data.target_message_sender_id || + t('chat.message.replyUnknownSender', { defaultValue: '未知发送者' }) + const previewText = + data.target_message_content?.trim() || + t('chat.media.replyMissing', { defaultValue: '原消息内容不可用' }) + const targetMessageId = data.target_message_id ? String(data.target_message_id) : '' + const isClickable = Boolean(targetMessageId && chatScroll) + + const handleClick = () => { + if (!targetMessageId || !chatScroll) { + return + } + const found = chatScroll.scrollToMessage(targetMessageId) + if (!found) { + toast({ + title: t('chat.toast.replyNotFoundTitle', { defaultValue: '原始消息不在当前视图' }), + description: t('chat.toast.replyNotFoundDescription', { + defaultValue: '该消息可能已被清除、不在本会话中,或者尚未加载。', + }), + variant: 'destructive', + }) + } + } + + const className = cn( + 'group block w-full rounded-md border-l-2 border-primary/60 bg-background/40 px-2 py-1 text-left text-xs', + isClickable && 'cursor-pointer transition hover:bg-background/70' + ) + + const content = ( +
+ +
+
{senderName}
+
{previewText}
+
+
+ ) + + if (isClickable) { + return ( + + ) + } + return
{content}
+} diff --git a/dashboard/src/routes/chat/index.tsx b/dashboard/src/routes/chat/index.tsx index 115fe6e7..22e28270 100644 --- a/dashboard/src/routes/chat/index.tsx +++ b/dashboard/src/routes/chat/index.tsx @@ -214,6 +214,7 @@ export function ChatPage() { user_id: data.user_id, user_name: data.user_name, bot_name: data.bot_name, + bot_qq: data.bot_qq, }, }) break @@ -278,7 +279,6 @@ export function ChatPage() { setTabs((prev) => prev.map((tab) => { if (tab.id !== tabId) return tab - const filteredMessages = tab.messages.filter((msg) => msg.type !== 'thinking') const newMessage: ChatMessage = { id: generateMessageId('bot'), type: 'bot', @@ -290,7 +290,7 @@ export function ChatPage() { } return { ...tab, - messages: [...filteredMessages, newMessage], + messages: [...tab.messages, newMessage], } }) ) @@ -305,11 +305,10 @@ export function ChatPage() { setTabs((prev) => prev.map((tab) => { if (tab.id !== tabId) return tab - const filteredMessages = tab.messages.filter((msg) => msg.type !== 'thinking') return { ...tab, messages: [ - ...filteredMessages, + ...tab.messages, { id: generateMessageId('error'), type: 'error' as const, @@ -330,33 +329,27 @@ export function ChatPage() { case 'history': { const historyMessages = data.messages || [] const processedSet = new Set() - const formattedMessages: ChatMessage[] = historyMessages.map( - (msg: { - id?: string - content: string - timestamp: number - sender_name?: string - sender_id?: string - is_bot?: boolean - }) => { - const isBot = msg.is_bot || false - const msgId = msg.id || generateMessageId(isBot ? 'bot' : 'user') - const contentHash = `${isBot ? 'bot' : 'user'}-${msg.content}-${Math.floor(msg.timestamp * 1000)}` - processedSet.add(contentHash) - return { - id: msgId, - type: isBot ? 'bot' : ('user' as const), - content: msg.content, - timestamp: msg.timestamp, - sender: { - name: - msg.sender_name || (isBot ? t('chat.botNameFallback') : t('chat.userFallback')), - user_id: msg.sender_id, - is_bot: isBot, - }, - } + const formattedMessages: ChatMessage[] = historyMessages.map((msg) => { + const isBot = msg.is_bot || false + const msgId = msg.id || generateMessageId(isBot ? 'bot' : 'user') + const contentHash = `${isBot ? 'bot' : 'user'}-${msg.content}-${Math.floor(msg.timestamp * 1000)}` + processedSet.add(contentHash) + const isRich = msg.message_type === 'rich' && Array.isArray(msg.segments) && msg.segments.length > 0 + return { + id: msgId, + type: isBot ? 'bot' : ('user' as const), + content: msg.content, + timestamp: msg.timestamp, + message_type: isRich ? 'rich' : 'text', + segments: isRich ? (msg.segments ?? undefined) : undefined, + sender: { + name: + msg.sender_name || (isBot ? t('chat.botNameFallback') : t('chat.userFallback')), + user_id: msg.sender_id, + is_bot: isBot, + }, } - ) + }) processedMessagesMapRef.current.set(tabId, processedSet) updateTab(tabId, { messages: formattedMessages }) @@ -509,19 +502,6 @@ export function ChatPage() { } addMessageToTab(activeTabId, userMessage) - // 再添加"思考中"占位消息 - const thinkingMessage: ChatMessage = { - id: generateMessageId('thinking'), - type: 'thinking', - content: '', - timestamp: currentTimestamp + 0.001, // 稍微晚一点确保顺序 - sender: { - name: activeTab?.sessionInfo.bot_name || t('chat.botNameFallback'), - is_bot: true, - }, - } - addMessageToTab(activeTabId, thinkingMessage) - setInputValue('') try { @@ -534,7 +514,6 @@ export function ChatPage() { return { ...tab, isTyping: false, - messages: tab.messages.filter((msg) => msg.type !== 'thinking'), } }) ) @@ -759,6 +738,7 @@ export function ChatPage() { messages={activeTab?.messages ?? []} isLoadingHistory={isLoadingHistory} botDisplayName={botDisplayName} + botQq={activeTab?.sessionInfo.bot_qq} userName={userName} language={i18n.language} /> diff --git a/dashboard/src/routes/chat/types.ts b/dashboard/src/routes/chat/types.ts index a8778ba6..dc75c8c8 100644 --- a/dashboard/src/routes/chat/types.ts +++ b/dashboard/src/routes/chat/types.ts @@ -49,20 +49,49 @@ export interface ChatTab { user_id?: string user_name?: string bot_name?: string + bot_qq?: string } } // 消息段类型 export interface MessageSegment { - type: 'text' | 'image' | 'emoji' | 'face' | 'voice' | 'video' | 'music' | 'file' | 'reply' | 'forward' | 'unknown' + type: + | 'text' + | 'image' + | 'emoji' + | 'face' + | 'voice' + | 'video' + | 'music' + | 'file' + | 'reply' + | 'at' + | 'forward' + | 'unknown' data: string | number | object original_type?: string } +// @某人 消息段的负载 +export interface AtSegmentData { + target_user_id?: string | null + target_user_nickname?: string | null + target_user_cardname?: string | null +} + +// 回复消息段的负载 +export interface ReplySegmentData { + target_message_id?: string | null + target_message_content?: string | null + target_message_sender_id?: string | null + target_message_sender_nickname?: string | null + target_message_sender_cardname?: string | null +} + // 消息类型 export interface ChatMessage { id: string - type: 'user' | 'bot' | 'system' | 'error' | 'thinking' + type: 'user' | 'bot' | 'system' | 'error' content: string timestamp: number message_type?: 'text' | 'rich' // 消息格式类型 @@ -85,6 +114,7 @@ export interface WsMessage { user_id?: string user_name?: string bot_name?: string + bot_qq?: string sender?: { name: string user_id?: string @@ -98,6 +128,8 @@ export interface WsMessage { sender_name?: string sender_id?: string is_bot?: boolean + message_type?: string + segments?: MessageSegment[] | null }> group_id?: string // 富文本消息 diff --git a/dashboard/src/routes/config/bot/hooks/JsonFieldHookFactory.tsx b/dashboard/src/routes/config/bot/hooks/JsonFieldHookFactory.tsx index 71692ca3..90c213d7 100644 --- a/dashboard/src/routes/config/bot/hooks/JsonFieldHookFactory.tsx +++ b/dashboard/src/routes/config/bot/hooks/JsonFieldHookFactory.tsx @@ -67,7 +67,7 @@ export function createJsonFieldHook(options: JsonFieldHookOptions): FieldHookCom

{label}

{description && ( -

{description}

+

{description}

)}

{options.helperText}

diff --git a/dashboard/src/routes/config/bot/hooks/ListItemEditorHookFactory.tsx b/dashboard/src/routes/config/bot/hooks/ListItemEditorHookFactory.tsx new file mode 100644 index 00000000..c08f1cc3 --- /dev/null +++ b/dashboard/src/routes/config/bot/hooks/ListItemEditorHookFactory.tsx @@ -0,0 +1,283 @@ +import { useCallback, useMemo } from 'react' +import * as LucideIcons from 'lucide-react' +import { Plus, Trash2 } from 'lucide-react' + +import { Button } from '@/components/ui/button' +import { + Card, + CardContent, + CardDescription, + CardHeader, + CardTitle, +} from '@/components/ui/card' +import { DynamicConfigForm } from '@/components/dynamic-form/DynamicConfigForm' +import type { FieldHookComponent } from '@/lib/field-hooks' +import type { ConfigSchema, FieldSchema } from '@/types/config-schema' + +/** + * createListItemEditorHook + * + * 通过 nestedSchema 渲染列表项式的富 UI 编辑器,替换原来直接展示 JSON 文本的 fallback。 + * 适用于 `List[ConfigBase]` 类型字段(schema.nested 中存在对应子配置类)。 + */ +export interface ListItemEditorOptions { + /** 用于生成每个 item 的标题,如 `${index+1} · ${item.platform}` */ + itemTitle?: (item: Record, index: number) => string + /** 添加按钮文案 */ + addLabel?: string + /** 顶部辅助说明 */ + helperText?: string + /** 列表为空时的占位说明 */ + emptyText?: string + /** 顶部图标(覆盖 schema 自带的 x-icon) */ + iconName?: string +} + +function resolveLabel(schema?: ConfigSchema | FieldSchema, fieldPath?: string): string { + if (!schema) { + return fieldPath?.split('.').at(-1) ?? '列表配置' + } + if ('label' in schema && schema.label) { + return schema.label + } + if ('uiLabel' in schema && schema.uiLabel) { + return schema.uiLabel + } + if ('classDoc' in schema && schema.classDoc) { + return schema.classDoc + } + if ('className' in schema && schema.className) { + return schema.className + } + return fieldPath?.split('.').at(-1) ?? '列表配置' +} + +function resolveDescription(schema?: ConfigSchema | FieldSchema): string { + if (!schema) return '' + if ('description' in schema && schema.description) return schema.description + if ('classDoc' in schema && schema.classDoc) return schema.classDoc + return '' +} + +function resolveIconName( + iconOverride: string | undefined, + schema?: ConfigSchema | FieldSchema, + nested?: ConfigSchema, +): string | undefined { + if (iconOverride) return iconOverride + if (schema && 'x-icon' in schema && schema['x-icon']) return schema['x-icon'] + if (nested?.uiIcon) return nested.uiIcon + return undefined +} + +function renderLucideIcon(iconName: string | undefined, className: string) { + if (!iconName) return null + const Icon = LucideIcons[iconName as keyof typeof LucideIcons] as + | React.ComponentType<{ className?: string }> + | undefined + if (!Icon) return null + return +} + +/** 根据 itemSchema 字段默认值构造一个新 item */ +function buildDefaultItem(itemSchema: ConfigSchema | undefined): Record { + if (!itemSchema?.fields) return {} + const next: Record = {} + for (const field of itemSchema.fields) { + if ('default' in field && field.default !== undefined) { + // 数组/对象需要做一次浅拷贝,避免多个 item 共享同一引用 + if (Array.isArray(field.default)) { + next[field.name] = [...field.default] + } else if ( + field.default !== null && + typeof field.default === 'object' + ) { + next[field.name] = { ...(field.default as Record) } + } else { + next[field.name] = field.default + } + continue + } + switch (field.type) { + case 'boolean': + next[field.name] = false + break + case 'integer': + case 'number': + next[field.name] = 0 + break + case 'array': + next[field.name] = [] + break + case 'object': + next[field.name] = {} + break + case 'select': + next[field.name] = field.options?.[0] ?? '' + break + default: + next[field.name] = '' + } + } + return next +} + +/** + * 把 dotted-path 写入 item 对象(兼容 DynamicConfigForm 的 onChange) + */ +function setNested(target: Record, path: string, value: unknown) { + const keys = path.split('.') + if (keys.length === 1) { + target[keys[0]] = value + return + } + let cursor: Record = target + for (let i = 0; i < keys.length - 1; i++) { + const key = keys[i] + const existing = cursor[key] + if (existing && typeof existing === 'object' && !Array.isArray(existing)) { + cursor[key] = { ...(existing as Record) } + } else { + cursor[key] = {} + } + cursor = cursor[key] as Record + } + cursor[keys[keys.length - 1]] = value +} + +export function createListItemEditorHook( + options: ListItemEditorOptions = {}, +): FieldHookComponent { + const ListItemEditorHook: FieldHookComponent = ({ + fieldPath, + onChange, + schema, + nestedSchema, + value, + }) => { + const items = useMemo[]>(() => { + if (!Array.isArray(value)) return [] + return value.map((item) => + item && typeof item === 'object' && !Array.isArray(item) + ? (item as Record) + : {}, + ) + }, [value]) + + const handleAdd = useCallback(() => { + const next = [...items, buildDefaultItem(nestedSchema)] + onChange?.(next) + }, [items, nestedSchema, onChange]) + + const handleRemove = useCallback( + (index: number) => { + const next = items.filter((_, idx) => idx !== index) + onChange?.(next) + }, + [items, onChange], + ) + + const handleItemFieldChange = useCallback( + (index: number, fieldName: string, fieldValue: unknown) => { + const next = items.map((item, idx) => { + if (idx !== index) return item + const cloned = { ...item } + setNested(cloned, fieldName, fieldValue) + return cloned + }) + onChange?.(next) + }, + [items, onChange], + ) + + const label = resolveLabel(schema, fieldPath) + const description = resolveDescription(schema) + const iconName = resolveIconName(options.iconName, schema, nestedSchema) + + if (!nestedSchema) { + return ( + + + {label} + 未获取到子配置 schema,无法渲染富编辑器。 + + + ) + } + + return ( + + +
+ {renderLucideIcon(iconName, 'h-5 w-5 text-muted-foreground')} + {label} +
+ {description && ( + {description} + )} + {options.helperText && ( +

{options.helperText}

+ )} +
+ + {items.length === 0 ? ( +
+ {options.emptyText ?? '尚未添加任何条目,点击下方按钮新增。'} +
+ ) : ( + items.map((item, index) => { + const title = + options.itemTitle?.(item, index) ?? `条目 ${index + 1}` + return ( +
+
+
+ + {index + 1} + + {title} +
+ +
+ + handleItemFieldChange(index, field, fieldValue) + } + basePath="" + level={1} + /> +
+ ) + }) + )} + +
+
+ ) + } + + return ListItemEditorHook +} diff --git a/dashboard/src/routes/config/bot/hooks/complexFieldHooks.tsx b/dashboard/src/routes/config/bot/hooks/complexFieldHooks.tsx index 63da5ba3..a39e5cb4 100644 --- a/dashboard/src/routes/config/bot/hooks/complexFieldHooks.tsx +++ b/dashboard/src/routes/config/bot/hooks/complexFieldHooks.tsx @@ -1,15 +1,98 @@ import { createJsonFieldHook } from './JsonFieldHookFactory' +import { createListItemEditorHook } from './ListItemEditorHookFactory' -export const ChatTalkValueRulesHook = createJsonFieldHook({ - emptyValue: [], - helperText: '复杂对象数组使用 JSON 编辑。每一项对应一个聊天频率规则对象。', - placeholder: '[\n {\n "platform": "",\n "item_id": "",\n "rule_type": "group",\n "time": "00:00-23:59",\n "value": 1.0\n }\n]', +const ruleTypeLabel = (rule: unknown) => { + if (rule === 'private') return '私聊' + if (rule === 'group') return '群聊' + return rule ? String(rule) : '未指定' +} + +const platformLabel = (item: Record) => { + const platform = typeof item.platform === 'string' ? item.platform.trim() : '' + const itemId = typeof item.item_id === 'string' ? item.item_id.trim() : '' + if (!platform && !itemId) return '全局' + if (!platform) return itemId + if (!itemId) return platform + return `${platform}:${itemId}` +} + +const truncate = (text: string, max = 32) => { + if (text.length <= max) return text + return `${text.slice(0, max)}…` +} + +const collectStringList = (value: unknown): string[] => { + if (!Array.isArray(value)) return [] + return value + .map((item) => (typeof item === 'string' ? item.trim() : '')) + .filter((item) => item.length > 0) +} + +export const ChatTalkValueRulesHook = createListItemEditorHook({ + addLabel: '添加发言频率规则', + helperText: '可按平台/聊天流/时段分别配置发言频率,留空表示全局。', + emptyText: '尚未配置任何规则,将使用全局默认频率。', + itemTitle: (item) => { + const time = + typeof item.time === 'string' && item.time.trim() + ? item.time.trim() + : '全天' + const value = + typeof item.value === 'number' ? item.value.toFixed(2) : '—' + return `${platformLabel(item)} · ${ruleTypeLabel(item.rule_type)} · ${time} · 频率 ${value}` + }, }) -export const ExpressionLearningListHook = createJsonFieldHook({ - emptyValue: [], - helperText: '表达学习配置较复杂,使用 JSON 编辑更稳妥。每一项对应一个学习规则。', - placeholder: '[\n {\n "platform": "",\n "item_id": "",\n "rule_type": "group",\n "use_expression": true,\n "enable_learning": true,\n "enable_jargon_learning": true\n }\n]', +export const ExpressionLearningListHook = createListItemEditorHook({ + addLabel: '添加表达学习规则', + helperText: '为不同聊天流单独配置是否启用表达/jargon 学习。', + emptyText: '尚未配置任何学习规则。', + itemTitle: (item) => { + const flags: string[] = [] + if (item.use_expression) flags.push('表达') + if (item.enable_learning) flags.push('优化学习') + if (item.enable_jargon_learning) flags.push('jargon') + const flagText = flags.length ? flags.join(' / ') : '全部关闭' + return `${platformLabel(item)} · ${ruleTypeLabel(item.rule_type)} · ${flagText}` + }, +}) + +export const KeywordRulesHook = createListItemEditorHook({ + addLabel: '添加关键词规则', + helperText: '匹配命中后会用 reaction 内容作为额外上下文。keywords 至少填一条,或使用正则模式。', + emptyText: '尚未添加任何关键词规则。', + itemTitle: (item) => { + const keywords = collectStringList(item.keywords) + const regex = collectStringList(item.regex) + const reaction = + typeof item.reaction === 'string' ? item.reaction.trim() : '' + const left = keywords.length + ? `关键词 ${keywords.length} 条` + : regex.length + ? `正则 ${regex.length} 条` + : '未配置匹配项' + const right = reaction ? `→ ${truncate(reaction)}` : '→ 未填写反应' + return `${left} ${right}` + }, +}) + +export const RegexRulesHook = createListItemEditorHook({ + addLabel: '添加正则规则', + helperText: '正则模式按 Python 语法编写,命中时把 reaction 作为提示注入。', + emptyText: '尚未添加任何正则规则。', + itemTitle: (item) => { + const regex = collectStringList(item.regex) + const keywords = collectStringList(item.keywords) + const reaction = + typeof item.reaction === 'string' ? item.reaction.trim() : '' + const left = regex.length + ? `正则 ${regex.length} 条` + : keywords.length + ? `关键词 ${keywords.length} 条` + : '未配置匹配项' + const right = reaction ? `→ ${truncate(reaction)}` : '→ 未填写反应' + return `${left} ${right}` + }, }) export const ExpressionGroupsHook = createJsonFieldHook({ @@ -24,18 +107,6 @@ export const ExperimentalChatPromptsHook = createJsonFieldHook({ placeholder: '[\n {\n "platform": "qq",\n "item_id": "123456",\n "rule_type": "group",\n "prompt": "这里填写额外提示词"\n }\n]', }) -export const KeywordRulesHook = createJsonFieldHook({ - emptyValue: [], - helperText: '关键词规则为对象数组,建议直接编辑 JSON。', - placeholder: '[\n {\n "keywords": ["早安"],\n "regex": [],\n "reaction": "早安呀"\n }\n]', -}) - -export const RegexRulesHook = createJsonFieldHook({ - emptyValue: [], - helperText: '正则规则为对象数组,建议直接编辑 JSON。', - placeholder: '[\n {\n "keywords": [],\n "regex": ["https?://[^\\\\s]+"],\n "reaction": "检测到链接:[0]"\n }\n]', -}) - export const MCPRootItemsHook = createJsonFieldHook({ emptyValue: [], helperText: 'MCP Roots 条目为对象数组,使用 JSON 编辑。', diff --git a/src/chat/message_receive/uni_message_sender.py b/src/chat/message_receive/uni_message_sender.py index 5a19bc46..53157909 100644 --- a/src/chat/message_receive/uni_message_sender.py +++ b/src/chat/message_receive/uni_message_sender.py @@ -36,9 +36,9 @@ def get_webui_chat_broadcaster() -> Tuple[Any, Optional[str], Optional[str]]: if _webui_chat_broadcaster is None: try: from src.webui.routers.chat import WEBUI_CHAT_PLATFORM, chat_manager - from src.webui.routers.chat.service import WEBUI_CHAT_GROUP_ID - _webui_chat_broadcaster = (chat_manager, WEBUI_CHAT_PLATFORM, WEBUI_CHAT_GROUP_ID) + # 默认不再强制虚拟群聊;WebUI 默认走私聊频道,需要的话由调用者传入虚拟群 ID。 + _webui_chat_broadcaster = (chat_manager, WEBUI_CHAT_PLATFORM, None) except ImportError: _webui_chat_broadcaster = (None, None, None) return _webui_chat_broadcaster @@ -98,6 +98,14 @@ async def _send_message(message: SessionMessage, show_log: bool = True) -> bool: message_type = "rich" segments = message_segments + # 私聊场景下出站消息的 user_info 是机器人自己的身份, + # 真正的接收者用户 ID 由 send_service 写入 ``platform_io_target_user_id``。 + target_user_id = "" + additional_config = message.message_info.additional_config or {} + raw_target_user_id = additional_config.get("platform_io_target_user_id") + if raw_target_user_id: + target_user_id = str(raw_target_user_id).strip() + await chat_manager.broadcast_to_group( group_id=group_id or default_group_id or "", message={ @@ -113,6 +121,7 @@ async def _send_message(message: SessionMessage, show_log: bool = True) -> bool: "is_bot": True, }, }, + user_id=target_user_id, ) # 注意:机器人消息会由 MessageStorage.store_message 自动保存到数据库 diff --git a/src/webui/config_schema.py b/src/webui/config_schema.py index 862da1e5..1f11faa2 100644 --- a/src/webui/config_schema.py +++ b/src/webui/config_schema.py @@ -70,11 +70,15 @@ class ConfigSchemaGenerator: ) -> Dict[str, Any]: field_docs = config_class.get_class_field_docs() field_type = cls._map_field_type(annotation) + raw_description = field_docs.get(field_name, field_info.description or "") + # `_wrap_` 标记在配置类 docstring 中表示该说明应作为块级注释(独立成行) + # 在前端展示时把它转为换行符,使描述以新行起始或在中间换行 + description = raw_description.replace("_wrap_", "\n").strip("\n") schema: Dict[str, Any] = { "name": field_name, "type": field_type, "label": field_name, - "description": field_docs.get(field_name, field_info.description or ""), + "description": description, "required": field_info.is_required(), } diff --git a/src/webui/routers/chat/routes.py b/src/webui/routers/chat/routes.py index 40988391..3c03227a 100644 --- a/src/webui/routers/chat/routes.py +++ b/src/webui/routers/chat/routes.py @@ -13,10 +13,10 @@ from src.config.config import global_config from src.webui.dependencies import require_auth from .service import ( - WEBUI_CHAT_GROUP_ID, WEBUI_CHAT_PLATFORM, chat_history, chat_manager, + normalize_webui_user_id, ) logger = get_logger("webui.chat") @@ -30,10 +30,15 @@ async def get_chat_history( user_id: Optional[str] = Query(default=None), group_id: Optional[str] = Query(default=None), ) -> Dict[str, object]: - """获取聊天历史记录。""" - del user_id - target_group_id = group_id or WEBUI_CHAT_GROUP_ID - history = chat_history.get_history(limit, target_group_id) + """获取聊天历史记录。 + + 优先按 ``group_id`` 加载虚拟群聊历史;未提供时使用规范化后的 ``user_id`` 加载 WebUI 私聊历史。 + """ + if group_id: + history = chat_history.get_history(limit, group_id=group_id) + else: + normalized_user_id = normalize_webui_user_id(user_id) + history = chat_history.get_history(limit, user_id=normalized_user_id) return {"success": True, "messages": history, "total": len(history)} @@ -100,10 +105,18 @@ async def get_persons_by_platform( @router.delete("/history") async def clear_chat_history( + user_id: Optional[str] = Query(default=None), group_id: Optional[str] = Query(default=None), ) -> Dict[str, object]: - """清空聊天历史记录。""" - deleted = chat_history.clear_history(group_id) + """清空聊天历史记录。 + + 优先按 ``group_id`` 清理虚拟群聊历史;未提供时使用规范化后的 ``user_id`` 清理 WebUI 私聊历史。 + """ + if group_id: + deleted = chat_history.clear_history(group_id=group_id) + else: + normalized_user_id = normalize_webui_user_id(user_id) + deleted = chat_history.clear_history(user_id=normalized_user_id) return {"success": True, "message": f"已清空 {deleted} 条聊天记录"} @@ -113,6 +126,5 @@ async def get_chat_info() -> Dict[str, object]: return { "bot_name": global_config.bot.nickname, "platform": WEBUI_CHAT_PLATFORM, - "group_id": WEBUI_CHAT_GROUP_ID, "active_sessions": len(chat_manager.active_connections), } diff --git a/src/webui/routers/chat/service.py b/src/webui/routers/chat/service.py index b8433b92..168d3190 100644 --- a/src/webui/routers/chat/service.py +++ b/src/webui/routers/chat/service.py @@ -18,6 +18,8 @@ from src.common.message_repository import find_messages from src.common.utils.utils_session import SessionUtils from src.config.config import global_config +from .serializers import serialize_message_sequence + logger = get_logger("webui.chat") WEBUI_CHAT_GROUP_ID = "webui_local_chat" @@ -61,7 +63,7 @@ class ChatSessionConnection: client_session_id: str user_id: str user_name: str - active_group_id: str + channel_key: str virtual_config: Optional[VirtualIdentityConfig] sender: AsyncMessageSender @@ -92,6 +94,21 @@ class ChatHistoryManager: user_id = user_info.user_id or "" is_bot = is_bot_self(msg.platform, user_id) + # 将存库中的 raw_message 序列化为前端可识别的富文本消息段, + # 避免“刚刚收到的机器人回复是富文本,刷新后变成纯文本”的体验不一致。 + segments: List[Dict[str, Any]] = [] + try: + raw_message = getattr(msg, "raw_message", None) + if raw_message is not None and getattr(raw_message, "components", None): + segments = serialize_message_sequence(raw_message) + except Exception as exc: # 仅记录警告,退化为纯文本 + logger.debug(f"序列化历史消息段失败,退化为纯文本: {exc}") + segments = [] + + is_rich = bool(segments) and not ( + len(segments) == 1 and segments[0].get("type") == "text" + ) + return { "id": msg.message_id, "type": "bot" if is_bot else "user", @@ -100,32 +117,119 @@ class ChatHistoryManager: "sender_name": user_info.user_nickname or (global_config.bot.nickname if is_bot else "未知用户"), "sender_id": "bot" if is_bot else user_id, "is_bot": is_bot, + "message_type": "rich" if is_rich else "text", + "segments": segments if is_rich else None, } - def _resolve_session_id(self, group_id: Optional[str]) -> str: - """根据群组标识解析聊天会话 ID。 + def _enrich_reply_segments( + self, + segments: List[Dict[str, Any]], + message_index: Dict[str, SessionMessage], + session_id: Optional[str], + ) -> None: + """回填历史消息中 reply 段缺失的发送者/原内容字段。 + + DB 中持久化的 ReplyComponent 通常只保留了 ``target_message_id``, + ``target_message_content`` / ``target_message_sender_*`` 字段为空。 + 这里基于当前会话已加载的消息列表(必要时回查数据库)进行补全。 Args: - group_id: 群组标识。 + segments: 单条历史消息的消息段列表,原地修改。 + message_index: 当前会话已加载消息的 ``message_id -> SessionMessage`` 索引。 + session_id: 当前会话 ID,用于按 ID 单查时缩小范围。 + """ + for segment in segments: + if not isinstance(segment, dict) or segment.get("type") != "reply": + continue + data = segment.get("data") + if not isinstance(data, dict): + continue + target_message_id = data.get("target_message_id") + if not target_message_id: + continue + + has_content = bool(str(data.get("target_message_content") or "").strip()) + has_sender = any( + str(data.get(key) or "").strip() + for key in ( + "target_message_sender_id", + "target_message_sender_nickname", + "target_message_sender_cardname", + ) + ) + if has_content and has_sender: + continue + + target_msg = message_index.get(str(target_message_id)) + if target_msg is None: + # 退化为按 ID 单查(仅当不在当前窗口内时才付出 DB 代价) + try: + from src.services.message_service import get_message_by_id + + target_msg = get_message_by_id(str(target_message_id), session_id or None) + except Exception as exc: + logger.debug(f"按 ID 回查 reply 目标消息失败: {exc}") + target_msg = None + if target_msg is None: + continue + + user_info = target_msg.message_info.user_info + if not has_content: + content_text = ( + target_msg.processed_plain_text + or target_msg.display_message + or "" + ) + data["target_message_content"] = content_text + if not has_sender: + data["target_message_sender_id"] = user_info.user_id or "" + data["target_message_sender_nickname"] = user_info.user_nickname or "" + data["target_message_sender_cardname"] = ( + getattr(user_info, "user_cardname", "") or "" + ) + + def _resolve_session_id( + self, + group_id: Optional[str] = None, + user_id: Optional[str] = None, + ) -> Optional[str]: + """根据会话标识解析内部聊天会话 ID。 + + 优先按虚拟群聊解析;否则按 WebUI 私聊解析。 + + Args: + group_id: 群组标识(虚拟群聊模式)。 + user_id: 用户标识(私聊模式)。 Returns: - str: 内部聊天会话 ID。 + Optional[str]: 内部聊天会话 ID;当 group_id 与 user_id 均未提供时返回 ``None``。 """ - target_group_id = group_id or WEBUI_CHAT_GROUP_ID - return SessionUtils.calculate_session_id(WEBUI_CHAT_PLATFORM, group_id=target_group_id) + if group_id: + return SessionUtils.calculate_session_id(WEBUI_CHAT_PLATFORM, group_id=group_id) + if user_id: + return SessionUtils.calculate_session_id(WEBUI_CHAT_PLATFORM, user_id=user_id) + return None - def get_history(self, limit: int = 50, group_id: Optional[str] = None) -> List[Dict[str, Any]]: + def get_history( + self, + limit: int = 50, + group_id: Optional[str] = None, + user_id: Optional[str] = None, + ) -> List[Dict[str, Any]]: """获取指定会话的历史消息。 Args: limit: 最大返回条数。 - group_id: 群组标识。 + group_id: 群组标识(虚拟群聊模式)。 + user_id: 用户标识(私聊模式)。 Returns: List[Dict[str, Any]]: 历史消息列表。 """ - target_group_id = group_id or WEBUI_CHAT_GROUP_ID - session_id = self._resolve_session_id(target_group_id) + session_id = self._resolve_session_id(group_id=group_id, user_id=user_id) + if session_id is None: + logger.debug("获取聊天历史时缺少 group_id 与 user_id,返回空列表") + return [] try: messages = find_messages( session_id=session_id, @@ -133,30 +237,54 @@ class ChatHistoryManager: limit_mode="latest", filter_command=False, ) - result = [self._message_to_dict(msg, target_group_id) for msg in messages] - logger.debug(f"从数据库加载了 {len(result)} 条聊天记录 (group_id={target_group_id})") + # 构建 message_id -> SessionMessage 索引,用于回填历史中 reply 段的发送者/内容 + # (DB 中通常只存了 target_message_id,target_message_content/sender_* 缺失)。 + message_index: Dict[str, SessionMessage] = {} + for m in messages: + mid = getattr(m, "message_id", None) + if mid: + message_index[str(mid)] = m + + result: List[Dict[str, Any]] = [] + for msg in messages: + item = self._message_to_dict(msg, group_id) + segments = item.get("segments") + if segments: + self._enrich_reply_segments(segments, message_index, session_id) + result.append(item) + logger.debug( + f"从数据库加载了 {len(result)} 条聊天记录 (group_id={group_id}, user_id={user_id})" + ) return result except Exception as exc: logger.error(f"从数据库加载聊天记录失败: {exc}") return [] - def clear_history(self, group_id: Optional[str] = None) -> int: + def clear_history( + self, + group_id: Optional[str] = None, + user_id: Optional[str] = None, + ) -> int: """清空指定会话的历史消息。 Args: - group_id: 群组标识。 + group_id: 群组标识(虚拟群聊模式)。 + user_id: 用户标识(私聊模式)。 Returns: int: 被删除的消息数量。 """ - target_group_id = group_id or WEBUI_CHAT_GROUP_ID - session_id = self._resolve_session_id(target_group_id) + session_id = self._resolve_session_id(group_id=group_id, user_id=user_id) + if session_id is None: + return 0 try: with get_db_session() as session: statement = delete(Messages).where(col(Messages.session_id) == session_id) result = session.exec(statement) deleted = result.rowcount or 0 - logger.info(f"已清空 {deleted} 条聊天记录 (group_id={target_group_id})") + logger.info( + f"已清空 {deleted} 条聊天记录 (group_id={group_id}, user_id={user_id})" + ) return deleted except Exception as exc: logger.error(f"清空聊天记录失败: {exc}") @@ -174,30 +302,30 @@ class ChatConnectionManager: self.group_sessions: Dict[str, Set[str]] = {} self.user_sessions: Dict[str, Set[str]] = {} - def _bind_group(self, session_id: str, group_id: str) -> None: - """为会话绑定群组索引。 + def _bind_channel(self, session_id: str, channel_key: str) -> None: + """为会话绑定逻辑频道索引。 Args: session_id: 内部会话 ID。 - group_id: 群组标识。 + channel_key: 频道键(``group:`` 或 ``private:``)。 """ - group_session_ids = self.group_sessions.setdefault(group_id, set()) - group_session_ids.add(session_id) + channel_session_ids = self.group_sessions.setdefault(channel_key, set()) + channel_session_ids.add(session_id) - def _unbind_group(self, session_id: str, group_id: str) -> None: - """移除会话与群组的索引关系。 + def _unbind_channel(self, session_id: str, channel_key: str) -> None: + """移除会话与逻辑频道的索引关系。 Args: session_id: 内部会话 ID。 - group_id: 群组标识。 + channel_key: 频道键。 """ - group_session_ids = self.group_sessions.get(group_id) - if group_session_ids is None: + channel_session_ids = self.group_sessions.get(channel_key) + if channel_session_ids is None: return - group_session_ids.discard(session_id) - if not group_session_ids: - del self.group_sessions[group_id] + channel_session_ids.discard(session_id) + if not channel_session_ids: + del self.group_sessions[channel_key] async def connect( self, @@ -220,18 +348,39 @@ class ChatConnectionManager: virtual_config: 当前虚拟身份配置。 sender: 发送消息到前端的异步回调。 """ + channel_key = compute_channel_key(virtual_config, user_id) existing_session_id = self.client_sessions.get((connection_id, client_session_id)) + if existing_session_id is not None and existing_session_id == session_id: + # 同一物理连接 + 前端会话重复打开(常见于 React StrictMode 双挂载或客户端去抖失败), + # 直接复用现有会话并仅刷新可变字段,避免反复断开/重连产生噪声日志。 + existing = self.active_connections.get(existing_session_id) + if existing is not None: + if existing.channel_key != channel_key: + self._unbind_channel(existing_session_id, existing.channel_key) + self._bind_channel(existing_session_id, channel_key) + existing.channel_key = channel_key + existing.user_id = user_id + existing.user_name = user_name + existing.virtual_config = virtual_config + existing.sender = sender + logger.debug( + "WebUI 聊天会话复用: session=%s, connection=%s, client_session=%s, channel=%s", + session_id, + connection_id, + client_session_id, + channel_key, + ) + return if existing_session_id is not None: self.disconnect(existing_session_id) - active_group_id = get_current_group_id(virtual_config) session_connection = ChatSessionConnection( session_id=session_id, connection_id=connection_id, client_session_id=client_session_id, user_id=user_id, user_name=user_name, - active_group_id=active_group_id, + channel_key=channel_key, virtual_config=virtual_config, sender=sender, ) @@ -240,14 +389,14 @@ class ChatConnectionManager: self.client_sessions[(connection_id, client_session_id)] = session_id self.connection_sessions.setdefault(connection_id, set()).add(session_id) self.user_sessions.setdefault(user_id, set()).add(session_id) - self._bind_group(session_id, active_group_id) + self._bind_channel(session_id, channel_key) logger.info( - "WebUI 聊天会话已连接: session=%s, connection=%s, client_session=%s, user=%s, group=%s", + "WebUI 聊天会话已连接: session=%s, connection=%s, client_session=%s, user=%s, channel=%s", session_id, connection_id, client_session_id, user_id, - active_group_id, + channel_key, ) def disconnect(self, session_id: str) -> None: @@ -261,7 +410,7 @@ class ChatConnectionManager: return self.client_sessions.pop((session_connection.connection_id, session_connection.client_session_id), None) - self._unbind_group(session_id, session_connection.active_group_id) + self._unbind_channel(session_id, session_connection.channel_key) connection_session_ids = self.connection_sessions.get(session_connection.connection_id) if connection_session_ids is not None: @@ -327,11 +476,11 @@ class ChatConnectionManager: if session_connection is None: return - next_group_id = get_current_group_id(virtual_config) - if next_group_id != session_connection.active_group_id: - self._unbind_group(session_id, session_connection.active_group_id) - self._bind_group(session_id, next_group_id) - session_connection.active_group_id = next_group_id + next_channel_key = compute_channel_key(virtual_config, session_connection.user_id) + if next_channel_key != session_connection.channel_key: + self._unbind_channel(session_id, session_connection.channel_key) + self._bind_channel(session_id, next_channel_key) + session_connection.channel_key = next_channel_key session_connection.user_name = user_name session_connection.virtual_config = virtual_config @@ -361,16 +510,40 @@ class ChatConnectionManager: for session_id in list(self.active_connections.keys()): await self.send_message(session_id, message) - async def broadcast_to_group(self, group_id: str, message: Dict[str, Any]) -> None: - """向指定群组下的全部逻辑会话广播消息。 + async def broadcast_to_channel(self, channel_key: str, message: Dict[str, Any]) -> None: + """向指定逻辑频道下的全部会话广播消息。 Args: - group_id: 群组标识。 + channel_key: 频道键(``group:`` 或 ``private:``)。 message: 待广播的消息内容。 """ - for session_id in list(self.group_sessions.get(group_id, set())): + for session_id in list(self.group_sessions.get(channel_key, set())): await self.send_message(session_id, message) + async def broadcast_to_group( + self, + group_id: Optional[str], + message: Dict[str, Any], + *, + user_id: Optional[str] = None, + ) -> None: + """向指定群组或私聊会话广播消息。 + + 当 ``group_id`` 非空时按群聊广播;否则按 ``user_id`` 私聊广播。 + + Args: + group_id: 群组标识;为空时使用 ``user_id``。 + message: 待广播的消息内容。 + user_id: 私聊接收方用户 ID。 + """ + if group_id: + channel_key = f"group:{group_id}" + elif user_id: + channel_key = f"private:{user_id}" + else: + return + await self.broadcast_to_channel(channel_key, message) + chat_history = ChatHistoryManager() chat_manager = ChatConnectionManager() @@ -388,6 +561,24 @@ def is_virtual_mode_enabled(virtual_config: Optional[VirtualIdentityConfig]) -> return bool(virtual_config and virtual_config.enabled) +def compute_channel_key(virtual_config: Optional[VirtualIdentityConfig], user_id: str) -> str: + """计算当前会话的逻辑频道键。 + + 虚拟身份启用时使用虚拟群聊 ID,否则使用当前 WebUI 用户 ID 作为私聊频道。 + + Args: + virtual_config: 虚拟身份配置。 + user_id: 当前 WebUI 用户 ID。 + + Returns: + str: 频道键,格式为 ``group:`` 或 ``private:``。 + """ + if is_virtual_mode_enabled(virtual_config): + assert virtual_config is not None + return f"group:{virtual_config.group_id}" + return f"private:{user_id}" + + def normalize_webui_user_id(user_id: Optional[str]) -> str: """标准化 WebUI 用户 ID。 @@ -500,6 +691,8 @@ def build_session_info_message( Returns: Dict[str, Any]: 会话信息消息。 """ + # bot_qq 用于前端从 QQ 头像公开接口拉取机器人头像(qq_account == 0 表示未配置,不推送)。 + bot_qq_account = int(getattr(global_config.bot, "qq_account", 0) or 0) session_info_data: Dict[str, Any] = { "type": "session_info", "session_id": session_id, @@ -507,6 +700,8 @@ def build_session_info_message( "user_name": user_name, "bot_name": global_config.bot.nickname, } + if bot_qq_account > 0: + session_info_data["bot_qq"] = str(bot_qq_account) if is_virtual_mode_enabled(virtual_config): assert virtual_config is not None @@ -529,7 +724,7 @@ def get_active_history_group_id(virtual_config: Optional[VirtualIdentityConfig]) virtual_config: 虚拟身份配置。 Returns: - Optional[str]: 虚拟身份启用时返回对应群组 ID。 + Optional[str]: 虚拟身份启用时返回对应群组 ID;否则返回 ``None`` 表示使用私聊。 """ if is_virtual_mode_enabled(virtual_config): assert virtual_config is not None @@ -537,16 +732,16 @@ def get_active_history_group_id(virtual_config: Optional[VirtualIdentityConfig]) return None -def get_current_group_id(virtual_config: Optional[VirtualIdentityConfig]) -> str: +def get_current_group_id(virtual_config: Optional[VirtualIdentityConfig]) -> Optional[str]: """获取当前会话的有效群组 ID。 Args: virtual_config: 虚拟身份配置。 Returns: - str: 当前会话应使用的群组 ID。 + Optional[str]: 虚拟身份启用时返回对应群组 ID;否则返回 ``None``(默认私聊模式)。 """ - return get_active_history_group_id(virtual_config) or WEBUI_CHAT_GROUP_ID + return get_active_history_group_id(virtual_config) def build_welcome_message(virtual_config: Optional[VirtualIdentityConfig]) -> str: @@ -611,7 +806,12 @@ async def send_initial_chat_state( ) history_group_id = get_active_history_group_id(virtual_config) - history = chat_history.get_history(50, history_group_id) + history_user_id = None if history_group_id else user_id + history = chat_history.get_history( + 50, + group_id=history_group_id, + user_id=history_user_id, + ) await chat_manager.send_message( session_id, { @@ -679,37 +879,42 @@ def create_message_data( if virtual_config and virtual_config.enabled: platform = virtual_config.platform or WEBUI_CHAT_PLATFORM - group_id = virtual_config.group_id or f"{VIRTUAL_GROUP_ID_PREFIX}{uuid.uuid4().hex[:8]}" - group_name = virtual_config.group_name or "WebUI虚拟群聊" + group_id: Optional[str] = ( + virtual_config.group_id or f"{VIRTUAL_GROUP_ID_PREFIX}{uuid.uuid4().hex[:8]}" + ) + group_name: Optional[str] = virtual_config.group_name or "WebUI虚拟群聊" actual_user_id = virtual_config.user_id or user_id - actual_user_name = virtual_config.user_nickname or user_name + actual_user_nickname = virtual_config.user_nickname or user_name else: platform = WEBUI_CHAT_PLATFORM - group_id = WEBUI_CHAT_GROUP_ID - group_name = "WebUI本地聊天室" + group_id = None + group_name = None actual_user_id = user_id - actual_user_name = user_name + actual_user_nickname = user_name + + message_info: Dict[str, Any] = { + "platform": platform, + "message_id": message_id, + "time": time.time(), + "user_info": { + "user_id": actual_user_id, + "user_nickname": actual_user_nickname, + "user_cardname": actual_user_nickname, + "platform": platform, + }, + "additional_config": { + "at_bot": is_at_bot, + }, + } + if group_id is not None: + message_info["group_info"] = { + "group_id": group_id, + "group_name": group_name, + "platform": platform, + } return { - "message_info": { - "platform": platform, - "message_id": message_id, - "time": time.time(), - "group_info": { - "group_id": group_id, - "group_name": group_name, - "platform": platform, - }, - "user_info": { - "user_id": actual_user_id, - "user_nickname": actual_user_name, - "user_cardname": actual_user_name, - "platform": platform, - }, - "additional_config": { - "at_bot": is_at_bot, - }, - }, + "message_info": message_info, "message_segment": { "type": "seglist", "data": [ @@ -717,10 +922,6 @@ def create_message_data( "type": "text", "data": content, }, - { - "type": "mention_bot", - "data": "1.0", - }, ], }, "raw_message": content, @@ -776,6 +977,7 @@ async def handle_chat_message( }, "virtual_mode": is_virtual_mode_enabled(current_virtual_config), }, + user_id=normalized_user_id, ) message_data = create_message_data( @@ -788,13 +990,21 @@ async def handle_chat_message( ) try: - await chat_manager.broadcast_to_group(target_group_id, {"type": "typing", "is_typing": True}) + await chat_manager.broadcast_to_group( + target_group_id, + {"type": "typing", "is_typing": True}, + user_id=normalized_user_id, + ) await chat_bot.message_process(message_data) except Exception as exc: logger.error(f"处理消息时出错: {exc}") await send_chat_error(session_id, f"处理消息时出错: {str(exc)}") finally: - await chat_manager.broadcast_to_group(target_group_id, {"type": "typing", "is_typing": False}) + await chat_manager.broadcast_to_group( + target_group_id, + {"type": "typing", "is_typing": False}, + user_id=normalized_user_id, + ) return next_user_name @@ -915,11 +1125,12 @@ async def enable_virtual_identity( return None -async def disable_virtual_identity(session_id: str) -> None: +async def disable_virtual_identity(session_id: str, normalized_user_id: str) -> None: """关闭虚拟身份模式。 Args: session_id: 内部逻辑会话 ID。 + normalized_user_id: 规范化后的 WebUI 用户 ID,用于加载私聊历史。 """ await chat_manager.send_message( session_id, @@ -933,8 +1144,8 @@ async def disable_virtual_identity(session_id: str) -> None: session_id, { "type": "history", - "messages": chat_history.get_history(50, WEBUI_CHAT_GROUP_ID), - "group_id": WEBUI_CHAT_GROUP_ID, + "messages": chat_history.get_history(50, user_id=normalized_user_id), + "group_id": None, }, ) await chat_manager.send_message( @@ -952,6 +1163,7 @@ async def handle_virtual_identity_update( session_id_prefix: str, data: Dict[str, Any], current_virtual_config: Optional[VirtualIdentityConfig], + normalized_user_id: str, ) -> Optional[VirtualIdentityConfig]: """处理虚拟身份切换请求。 @@ -960,6 +1172,7 @@ async def handle_virtual_identity_update( session_id_prefix: 会话前缀。 data: 前端提交的数据。 current_virtual_config: 当前虚拟身份配置。 + normalized_user_id: 规范化后的 WebUI 用户 ID。 Returns: Optional[VirtualIdentityConfig]: 更新后的虚拟身份配置。 @@ -969,7 +1182,7 @@ async def handle_virtual_identity_update( next_config = await enable_virtual_identity(session_id, session_id_prefix, virtual_data) return next_config if next_config is not None else current_virtual_config - await disable_virtual_identity(session_id) + await disable_virtual_identity(session_id, normalized_user_id) return None @@ -1019,6 +1232,7 @@ async def dispatch_chat_event( session_id_prefix=session_id_prefix, data=data, current_virtual_config=current_virtual_config, + normalized_user_id=normalized_user_id, ) return current_user_name, next_virtual_config