From f334d9882da8e33bf53bd3e991db31a8ffae6dee Mon Sep 17 00:00:00 2001
From: DrSmoothl <1787882683@qq.com>
Date: Sun, 1 Mar 2026 19:19:22 +0800
Subject: [PATCH] refactor(dashboard): split chat.tsx into modular chat/
directory
- Extract types, utils, and components into separate files
- types.ts: All interfaces and type definitions (107 lines)
- utils.ts: Pure utility functions (50 lines)
- MessageRenderer.tsx: Message rendering components (96 lines)
- VirtualIdentityDialog.tsx: Virtual identity dialog (206 lines)
- ChatTabBar.tsx: Tab bar component (79 lines)
- index.tsx: Main ChatPage component (1147 lines)
- Update router import path to chat/index
- Fix TypeScript type imports per verbatimModuleSyntax
- Build passes with zero errors
---
dashboard/src/router.tsx | 2 +-
dashboard/src/routes/chat/ChatTabBar.tsx | 79 +++
dashboard/src/routes/chat/MessageRenderer.tsx | 96 ++++
.../src/routes/chat/VirtualIdentityDialog.tsx | 206 +++++++
.../src/routes/{chat.tsx => chat/index.tsx} | 509 ++----------------
dashboard/src/routes/chat/types.ts | 106 ++++
dashboard/src/routes/chat/utils.ts | 50 ++
7 files changed, 571 insertions(+), 477 deletions(-)
create mode 100644 dashboard/src/routes/chat/ChatTabBar.tsx
create mode 100644 dashboard/src/routes/chat/MessageRenderer.tsx
create mode 100644 dashboard/src/routes/chat/VirtualIdentityDialog.tsx
rename dashboard/src/routes/{chat.tsx => chat/index.tsx} (71%)
create mode 100644 dashboard/src/routes/chat/types.ts
create mode 100644 dashboard/src/routes/chat/utils.ts
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 (
+
{
+ // 图片加载失败时显示占位符
+ 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 (
+
+ )
+}
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 (
-
{
- // 图片加载失败时显示占位符
- 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 (
{/* 虚拟身份配置对话框 */}
-
+
{/* 标签页栏 */}
-
-
-
- {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)
+ }
+}