diff --git a/dashboard/src/router.tsx b/dashboard/src/router.tsx index bf9e2835..4ef072b2 100644 --- a/dashboard/src/router.tsx +++ b/dashboard/src/router.tsx @@ -22,7 +22,7 @@ import { ModelPresetsPage } from './routes/model-presets' import { PluginConfigPage } from './routes/plugin-config' import { PluginMirrorsPage } from './routes/plugin-mirrors' import { PluginDetailPage } from './routes/plugin-detail' -import { ChatPage } from './routes/chat' +import { ChatPage } from './routes/chat/index' import { WebUIFeedbackSurveyPage, MaiBotFeedbackSurveyPage } from './routes/survey' import { AnnualReportPage } from './routes/annual-report' import PackMarketPage from './routes/config/pack-market' diff --git a/dashboard/src/routes/chat/ChatTabBar.tsx b/dashboard/src/routes/chat/ChatTabBar.tsx new file mode 100644 index 00000000..0c867e87 --- /dev/null +++ b/dashboard/src/routes/chat/ChatTabBar.tsx @@ -0,0 +1,79 @@ +import { cn } from '@/lib/utils' +import { MessageSquare, Plus, UserCircle2, X } from 'lucide-react' + +import type { ChatTab } from './types' + +interface ChatTabBarProps { + tabs: ChatTab[] + activeTabId: string + onSwitch: (tabId: string) => void + onClose: (tabId: string, e?: React.MouseEvent) => void + onAddVirtual: () => void +} + +export function ChatTabBar({ + tabs, + activeTabId, + onSwitch, + onClose, + onAddVirtual, +}: ChatTabBarProps) { + return ( +
+
+
+ {tabs.map((tab) => ( +
onSwitch(tab.id)} + > + {tab.type === 'webui' ? ( + + ) : ( + + )} + {tab.label} + {/* 连接状态指示器 */} + + {/* 关闭按钮(非默认标签页) */} + {tab.id !== 'webui-default' && ( + onClose(tab.id, e)} + className="ml-0.5 p-0.5 rounded hover:bg-muted-foreground/20 cursor-pointer" + role="button" + tabIndex={0} + onKeyDown={(e) => { + if (e.key === 'Enter' || e.key === ' ') { + e.preventDefault() + onClose(tab.id, e as any) + } + }} + > + + + )} +
+ ))} + {/* 新建虚拟身份标签页按钮 */} + +
+
+
+ ) +} diff --git a/dashboard/src/routes/chat/MessageRenderer.tsx b/dashboard/src/routes/chat/MessageRenderer.tsx new file mode 100644 index 00000000..32026db5 --- /dev/null +++ b/dashboard/src/routes/chat/MessageRenderer.tsx @@ -0,0 +1,96 @@ +import { cn } from '@/lib/utils' + +import type { ChatMessage, MessageSegment } from './types' + +// 渲染单个消息段 +export function RenderMessageSegment({ segment }: { segment: MessageSegment }) { + switch (segment.type) { + case 'text': + return {String(segment.data)} + + case 'image': + case 'emoji': + return ( + {segment.type { + // 图片加载失败时显示占位符 + const target = e.target as HTMLImageElement + target.style.display = 'none' + target.parentElement?.insertAdjacentHTML( + 'beforeend', + `[${segment.type === 'emoji' ? '表情包' : '图片'}加载失败]` + ) + }} + /> + ) + + case 'voice': + return ( +
+ +
+ ) + + case 'video': + return ( + + ) + + case 'face': + // QQ 原生表情,显示为文本 + return [表情:{String(segment.data)}] + + case 'music': + return [音乐分享] + + case 'file': + return [文件: {String(segment.data)}] + + case 'reply': + return [回复消息] + + case 'forward': + return [转发消息] + + case 'unknown': + default: + return [{segment.original_type || '未知消息'}] + } +} + +// 渲染消息内容(支持富文本) +// eslint-disable-next-line @typescript-eslint/no-unused-vars +export function RenderMessageContent({ message, isBot: _isBot }: { message: ChatMessage; isBot: boolean }) { + // 如果是富文本消息,渲染消息段 + if (message.message_type === 'rich' && message.segments && message.segments.length > 0) { + return ( +
+ {message.segments.map((segment, index) => ( + + ))} +
+ ) + } + + // 普通文本消息 + return {message.content} +} diff --git a/dashboard/src/routes/chat/VirtualIdentityDialog.tsx b/dashboard/src/routes/chat/VirtualIdentityDialog.tsx new file mode 100644 index 00000000..a150a746 --- /dev/null +++ b/dashboard/src/routes/chat/VirtualIdentityDialog.tsx @@ -0,0 +1,206 @@ +import { Avatar, AvatarFallback } from '@/components/ui/avatar' +import { Button } from '@/components/ui/button' +import { + Dialog, + DialogContent, + DialogDescription, + DialogFooter, + DialogHeader, + DialogTitle, +} from "@/components/ui/dialog" +import { Input } from '@/components/ui/input' +import { Label } from "@/components/ui/label" +import { ScrollArea } from '@/components/ui/scroll-area' +import { + Select, + SelectContent, + SelectItem, + SelectTrigger, + SelectValue, +} from "@/components/ui/select" +import { cn } from '@/lib/utils' +import { Globe, Loader2, Search, UserCircle2, Users } from 'lucide-react' + +import type { PersonInfo, PlatformInfo, VirtualIdentityConfig } from './types' + +interface VirtualIdentityDialogProps { + open: boolean + onOpenChange: (open: boolean) => void + platforms: PlatformInfo[] + persons: PersonInfo[] + isLoadingPlatforms: boolean + isLoadingPersons: boolean + personSearchQuery: string + setPersonSearchQuery: (query: string) => void + tempVirtualConfig: VirtualIdentityConfig + setTempVirtualConfig: React.Dispatch> + onSelectPerson: (person: PersonInfo) => void + onCreateVirtualTab: () => void +} + +export function VirtualIdentityDialog({ + open, + onOpenChange, + platforms, + persons, + isLoadingPlatforms, + isLoadingPersons, + personSearchQuery, + setPersonSearchQuery, + tempVirtualConfig, + setTempVirtualConfig, + onSelectPerson, + onCreateVirtualTab, +}: VirtualIdentityDialogProps) { + return ( + + + + + + 新建虚拟身份对话 + + + 选择一个麦麦已认识的用户,以该用户的身份与麦麦对话。麦麦将使用她对该用户的记忆和认知来回应。 + + + +
+ {/* 平台选择 */} +
+ + +
+ + {/* 用户搜索和选择 */} + {tempVirtualConfig.platform && ( +
+ +
+ + setPersonSearchQuery(e.target.value)} + className="pl-9" + /> +
+ +
+ {isLoadingPersons ? ( +
+ +
+ ) : persons.length === 0 ? ( +
+ +

没有找到用户

+
+ ) : ( +
+ {persons.map((person) => ( + + ))} +
+ )} +
+
+
+ )} + + {/* 虚拟群名配置 */} + {tempVirtualConfig.personId && ( +
+ + setTempVirtualConfig(prev => ({ + ...prev, + groupName: e.target.value + }))} + /> +

+ 麦麦会认为这是一个名为此名称的群聊 +

+
+ )} +
+ + + + + +
+
+ ) +} diff --git a/dashboard/src/routes/chat.tsx b/dashboard/src/routes/chat/index.tsx similarity index 71% rename from dashboard/src/routes/chat.tsx rename to dashboard/src/routes/chat/index.tsx index 3ab26faa..a770ebc6 100644 --- a/dashboard/src/routes/chat.tsx +++ b/dashboard/src/routes/chat/index.tsx @@ -1,277 +1,19 @@ import { useState, useRef, useEffect, useCallback } from 'react' -import { fetchWithAuth } from '@/lib/fetch-with-auth' -import { ScrollArea } from '@/components/ui/scroll-area' + +import { Avatar, AvatarFallback } from '@/components/ui/avatar' import { Button } from '@/components/ui/button' import { Input } from '@/components/ui/input' -// Card 组件已移除,改用更简洁的全屏布局 -import { Avatar, AvatarFallback } from '@/components/ui/avatar' -import { Send, Bot, User, Loader2, WifiOff, Wifi, RefreshCw, Edit2, Users, Search, X, UserCircle2, Globe, Plus, MessageSquare } from 'lucide-react' -import { cn } from '@/lib/utils' +import { ScrollArea } from '@/components/ui/scroll-area' import { useToast } from '@/hooks/use-toast' -import { - Select, - SelectContent, - SelectItem, - SelectTrigger, - SelectValue, -} from "@/components/ui/select" -import { - Dialog, - DialogContent, - DialogDescription, - DialogHeader, - DialogTitle, - DialogFooter, -} from "@/components/ui/dialog" -import { Label } from "@/components/ui/label" +import { fetchWithAuth } from '@/lib/fetch-with-auth' +import { cn } from '@/lib/utils' +import { Bot, Edit2, Loader2, RefreshCw, User, Send, Wifi, WifiOff, UserCircle2 } from 'lucide-react' -// 生成唯一用户 ID -function generateUserId(): string { - return 'webui_' + Math.random().toString(36).substr(2, 9) + '_' + Date.now().toString(36) -} - -// 从 localStorage 获取或生成用户 ID -function getOrCreateUserId(): string { - const storageKey = 'maibot_webui_user_id' - let userId = localStorage.getItem(storageKey) - if (!userId) { - userId = generateUserId() - localStorage.setItem(storageKey, userId) - } - return userId -} - -// 从 localStorage 获取用户昵称 -function getStoredUserName(): string { - return localStorage.getItem('maibot_webui_user_name') || 'WebUI用户' -} - -// 保存用户昵称到 localStorage -function saveUserName(name: string): void { - localStorage.setItem('maibot_webui_user_name', name) -} - -// 虚拟标签页持久化存储 key -const VIRTUAL_TABS_STORAGE_KEY = 'maibot_webui_virtual_tabs' - -// 保存的虚拟标签页配置 -interface SavedVirtualTab { - id: string - label: string - virtualConfig: VirtualIdentityConfig - createdAt: number -} - -// 从 localStorage 获取保存的虚拟标签页 -function getSavedVirtualTabs(): SavedVirtualTab[] { - try { - const saved = localStorage.getItem(VIRTUAL_TABS_STORAGE_KEY) - if (saved) { - return JSON.parse(saved) - } - } catch (e) { - console.error('[Chat] 加载虚拟标签页失败:', e) - } - return [] -} - -// 保存虚拟标签页到 localStorage -function saveVirtualTabs(tabs: SavedVirtualTab[]): void { - try { - localStorage.setItem(VIRTUAL_TABS_STORAGE_KEY, JSON.stringify(tabs)) - } catch (e) { - console.error('[Chat] 保存虚拟标签页失败:', e) - } -} - -// 平台信息类型 -interface PlatformInfo { - platform: string - count: number -} - -// 用户信息类型(从后端获取的人物信息) -interface PersonInfo { - person_id: string - user_id: string - person_name: string - nickname: string | null - platform: string - is_known: boolean -} - -// 虚拟身份配置 -interface VirtualIdentityConfig { - platform: string - personId: string - userId: string - userName: string - groupName: string - groupId: string // 虚拟群 ID,用于持久化历史记录 -} - -// 聊天标签页 -interface ChatTab { - id: string - type: 'webui' | 'virtual' - label: string - virtualConfig?: VirtualIdentityConfig - messages: ChatMessage[] - isConnected: boolean - isTyping: boolean - sessionInfo: { - session_id?: string - user_id?: string - user_name?: string - bot_name?: string - } -} - -// 消息段类型 -interface MessageSegment { - type: 'text' | 'image' | 'emoji' | 'face' | 'voice' | 'video' | 'music' | 'file' | 'reply' | 'forward' | 'unknown' - data: string | number | object - original_type?: string -} - -// 消息类型 -interface ChatMessage { - id: string - type: 'user' | 'bot' | 'system' | 'error' | 'thinking' - content: string - timestamp: number - message_type?: 'text' | 'rich' // 消息格式类型 - segments?: MessageSegment[] // 富文本消息段 - sender?: { - name: string - user_id?: string - is_bot?: boolean - } -} - -// WebSocket 消息类型 -interface WsMessage { - type: string - content?: string - message_id?: string - timestamp?: number - is_typing?: boolean - session_id?: string - user_id?: string - user_name?: string - bot_name?: string - sender?: { - name: string - user_id?: string - is_bot?: boolean - } - // 历史消息列表(用于 type: 'history') - messages?: Array<{ - id?: string - content: string - timestamp: number - sender_name?: string - sender_id?: string - is_bot?: boolean - }> - group_id?: string - // 富文本消息 - message_type?: string - segments?: MessageSegment[] -} - -// 渲染单个消息段 -function RenderMessageSegment({ segment }: { segment: MessageSegment }) { - switch (segment.type) { - case 'text': - return {String(segment.data)} - - case 'image': - case 'emoji': - return ( - {segment.type { - // 图片加载失败时显示占位符 - const target = e.target as HTMLImageElement - target.style.display = 'none' - target.parentElement?.insertAdjacentHTML( - 'beforeend', - `[${segment.type === 'emoji' ? '表情包' : '图片'}加载失败]` - ) - }} - /> - ) - - case 'voice': - return ( -
- -
- ) - - case 'video': - return ( - - ) - - case 'face': - // QQ 原生表情,显示为文本 - return [表情:{String(segment.data)}] - - case 'music': - return [音乐分享] - - case 'file': - return [文件: {String(segment.data)}] - - case 'reply': - return [回复消息] - - case 'forward': - return [转发消息] - - case 'unknown': - default: - return [{segment.original_type || '未知消息'}] - } -} - -// 渲染消息内容(支持富文本) -// eslint-disable-next-line @typescript-eslint/no-unused-vars -function RenderMessageContent({ message, isBot: _isBot }: { message: ChatMessage; isBot: boolean }) { - // 如果是富文本消息,渲染消息段 - if (message.message_type === 'rich' && message.segments && message.segments.length > 0) { - return ( -
- {message.segments.map((segment, index) => ( - - ))} -
- ) - } - - // 普通文本消息 - return {message.content} -} +import { ChatTabBar } from './ChatTabBar' +import { RenderMessageContent } from './MessageRenderer' +import type { ChatTab, ChatMessage, PersonInfo, PlatformInfo, SavedVirtualTab, VirtualIdentityConfig, WsMessage } from './types' +import { getOrCreateUserId, getStoredUserName, getSavedVirtualTabs, saveUserName, saveVirtualTabs } from './utils' +import { VirtualIdentityDialog } from './VirtualIdentityDialog' export function ChatPage() { // 默认 WebUI 标签页 @@ -685,7 +427,7 @@ export function ChatPage() { type: 'bot', content: data.content || '', message_type: (data.message_type === 'rich' ? 'rich' : 'text') as 'text' | 'rich', - segments: data.segments as MessageSegment[] | undefined, + segments: data.segments, timestamp: data.timestamp || Date.now() / 1000, sender: data.sender, } @@ -1129,214 +871,29 @@ export function ChatPage() { return (
{/* 虚拟身份配置对话框 */} - - - - - - 新建虚拟身份对话 - - - 选择一个麦麦已认识的用户,以该用户的身份与麦麦对话。麦麦将使用她对该用户的记忆和认知来回应。 - - - -
- {/* 平台选择 */} -
- - -
- - {/* 用户搜索和选择 */} - {tempVirtualConfig.platform && ( -
- -
- - setPersonSearchQuery(e.target.value)} - className="pl-9" - /> -
- -
- {isLoadingPersons ? ( -
- -
- ) : persons.length === 0 ? ( -
- -

没有找到用户

-
- ) : ( -
- {persons.map((person) => ( - - ))} -
- )} -
-
-
- )} - - {/* 虚拟群名配置 */} - {tempVirtualConfig.personId && ( -
- - setTempVirtualConfig(prev => ({ - ...prev, - groupName: e.target.value - }))} - /> -

- 麦麦会认为这是一个名为此名称的群聊 -

-
- )} -
- - - - - -
-
+ {/* 标签页栏 */} -
-
-
- {tabs.map((tab) => ( -
switchTab(tab.id)} - > - {tab.type === 'webui' ? ( - - ) : ( - - )} - {tab.label} - {/* 连接状态指示器 */} - - {/* 关闭按钮(非默认标签页) */} - {tab.id !== 'webui-default' && ( - closeTab(tab.id, e)} - className="ml-0.5 p-0.5 rounded hover:bg-muted-foreground/20 cursor-pointer" - role="button" - tabIndex={0} - onKeyDown={(e) => { - if (e.key === 'Enter' || e.key === ' ') { - e.preventDefault() - closeTab(tab.id, e as any) - } - }} - > - - - )} -
- ))} - {/* 新建虚拟身份标签页按钮 */} - -
-
-
+ {/* 头部信息栏 */}
diff --git a/dashboard/src/routes/chat/types.ts b/dashboard/src/routes/chat/types.ts new file mode 100644 index 00000000..a8778ba6 --- /dev/null +++ b/dashboard/src/routes/chat/types.ts @@ -0,0 +1,106 @@ +// 虚拟标签页持久化存储 key +export const VIRTUAL_TABS_STORAGE_KEY = 'maibot_webui_virtual_tabs' + +// 保存的虚拟标签页配置 +export interface SavedVirtualTab { + id: string + label: string + virtualConfig: VirtualIdentityConfig + createdAt: number +} + +// 平台信息类型 +export interface PlatformInfo { + platform: string + count: number +} + +// 用户信息类型(从后端获取的人物信息) +export interface PersonInfo { + person_id: string + user_id: string + person_name: string + nickname: string | null + platform: string + is_known: boolean +} + +// 虚拟身份配置 +export interface VirtualIdentityConfig { + platform: string + personId: string + userId: string + userName: string + groupName: string + groupId: string // 虚拟群 ID,用于持久化历史记录 +} + +// 聊天标签页 +export interface ChatTab { + id: string + type: 'webui' | 'virtual' + label: string + virtualConfig?: VirtualIdentityConfig + messages: ChatMessage[] + isConnected: boolean + isTyping: boolean + sessionInfo: { + session_id?: string + user_id?: string + user_name?: string + bot_name?: string + } +} + +// 消息段类型 +export interface MessageSegment { + type: 'text' | 'image' | 'emoji' | 'face' | 'voice' | 'video' | 'music' | 'file' | 'reply' | 'forward' | 'unknown' + data: string | number | object + original_type?: string +} + +// 消息类型 +export interface ChatMessage { + id: string + type: 'user' | 'bot' | 'system' | 'error' | 'thinking' + content: string + timestamp: number + message_type?: 'text' | 'rich' // 消息格式类型 + segments?: MessageSegment[] // 富文本消息段 + sender?: { + name: string + user_id?: string + is_bot?: boolean + } +} + +// WebSocket 消息类型 +export interface WsMessage { + type: string + content?: string + message_id?: string + timestamp?: number + is_typing?: boolean + session_id?: string + user_id?: string + user_name?: string + bot_name?: string + sender?: { + name: string + user_id?: string + is_bot?: boolean + } + // 历史消息列表(用于 type: 'history') + messages?: Array<{ + id?: string + content: string + timestamp: number + sender_name?: string + sender_id?: string + is_bot?: boolean + }> + group_id?: string + // 富文本消息 + message_type?: string + segments?: MessageSegment[] +} diff --git a/dashboard/src/routes/chat/utils.ts b/dashboard/src/routes/chat/utils.ts new file mode 100644 index 00000000..46bfb05d --- /dev/null +++ b/dashboard/src/routes/chat/utils.ts @@ -0,0 +1,50 @@ +import { VIRTUAL_TABS_STORAGE_KEY } from './types' +import type { SavedVirtualTab } from './types' + +// 生成唯一用户 ID +export function generateUserId(): string { + return 'webui_' + Math.random().toString(36).substr(2, 9) + '_' + Date.now().toString(36) +} + +// 从 localStorage 获取或生成用户 ID +export function getOrCreateUserId(): string { + const storageKey = 'maibot_webui_user_id' + let userId = localStorage.getItem(storageKey) + if (!userId) { + userId = generateUserId() + localStorage.setItem(storageKey, userId) + } + return userId +} + +// 从 localStorage 获取用户昵称 +export function getStoredUserName(): string { + return localStorage.getItem('maibot_webui_user_name') || 'WebUI用户' +} + +// 保存用户昵称到 localStorage +export function saveUserName(name: string): void { + localStorage.setItem('maibot_webui_user_name', name) +} + +// 从 localStorage 获取保存的虚拟标签页 +export function getSavedVirtualTabs(): SavedVirtualTab[] { + try { + const saved = localStorage.getItem(VIRTUAL_TABS_STORAGE_KEY) + if (saved) { + return JSON.parse(saved) + } + } catch (e) { + console.error('[Chat] 加载虚拟标签页失败:', e) + } + return [] +} + +// 保存虚拟标签页到 localStorage +export function saveVirtualTabs(tabs: SavedVirtualTab[]): void { + try { + localStorage.setItem(VIRTUAL_TABS_STORAGE_KEY, JSON.stringify(tabs)) + } catch (e) { + console.error('[Chat] 保存虚拟标签页失败:', e) + } +}