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
This commit is contained in:
DrSmoothl
2026-03-01 19:19:22 +08:00
parent 34b05e4e16
commit f334d9882d
7 changed files with 571 additions and 477 deletions

View File

@@ -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'

View File

@@ -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 (
<div className="shrink-0 border-b bg-muted/30">
<div className="max-w-4xl mx-auto px-2 sm:px-4">
<div className="flex items-center gap-1 overflow-x-auto py-1.5 scrollbar-thin">
{tabs.map((tab) => (
<div
key={tab.id}
className={cn(
"flex items-center gap-1.5 px-3 py-1.5 rounded-md text-sm whitespace-nowrap transition-colors cursor-pointer",
"hover:bg-muted",
activeTabId === tab.id
? "bg-background shadow-sm border"
: "text-muted-foreground"
)}
onClick={() => onSwitch(tab.id)}
>
{tab.type === 'webui' ? (
<MessageSquare className="h-3.5 w-3.5" />
) : (
<UserCircle2 className="h-3.5 w-3.5" />
)}
<span className="max-w-[100px] truncate">{tab.label}</span>
{/* 连接状态指示器 */}
<span className={cn(
"w-1.5 h-1.5 rounded-full",
tab.isConnected ? "bg-green-500" : "bg-muted-foreground/50"
)} />
{/* 关闭按钮(非默认标签页) */}
{tab.id !== 'webui-default' && (
<span
onClick={(e) => 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)
}
}}
>
<X className="h-3 w-3" />
</span>
)}
</div>
))}
{/* 新建虚拟身份标签页按钮 */}
<button
onClick={onAddVirtual}
className="flex items-center gap-1 px-2 py-1.5 rounded-md text-sm text-muted-foreground hover:bg-muted hover:text-foreground transition-colors"
title="新建虚拟身份对话"
>
<Plus className="h-3.5 w-3.5" />
</button>
</div>
</div>
</div>
)
}

View File

@@ -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 <span className="whitespace-pre-wrap">{String(segment.data)}</span>
case 'image':
case 'emoji':
return (
<img
src={String(segment.data)}
alt={segment.type === 'emoji' ? '表情包' : '图片'}
className={cn(
"rounded-lg max-w-full",
segment.type === 'emoji' ? "max-h-32" : "max-h-64"
)}
loading="lazy"
onError={(e) => {
// 图片加载失败时显示占位符
const target = e.target as HTMLImageElement
target.style.display = 'none'
target.parentElement?.insertAdjacentHTML(
'beforeend',
`<span class="text-muted-foreground text-xs">[${segment.type === 'emoji' ? '表情包' : '图片'}加载失败]</span>`
)
}}
/>
)
case 'voice':
return (
<div className="flex items-center gap-2">
<audio
controls
src={String(segment.data)}
className="max-w-[200px] h-8"
>
</audio>
</div>
)
case 'video':
return (
<video
controls
src={String(segment.data)}
className="rounded-lg max-w-full max-h-64"
>
</video>
)
case 'face':
// QQ 原生表情,显示为文本
return <span className="text-muted-foreground">[:{String(segment.data)}]</span>
case 'music':
return <span className="text-muted-foreground">[]</span>
case 'file':
return <span className="text-muted-foreground">[: {String(segment.data)}]</span>
case 'reply':
return <span className="text-muted-foreground text-xs">[]</span>
case 'forward':
return <span className="text-muted-foreground">[]</span>
case 'unknown':
default:
return <span className="text-muted-foreground">[{segment.original_type || '未知消息'}]</span>
}
}
// 渲染消息内容(支持富文本)
// 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 (
<div className="flex flex-col gap-2">
{message.segments.map((segment, index) => (
<RenderMessageSegment key={index} segment={segment} />
))}
</div>
)
}
// 普通文本消息
return <span className="whitespace-pre-wrap">{message.content}</span>
}

View File

@@ -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<React.SetStateAction<VirtualIdentityConfig>>
onSelectPerson: (person: PersonInfo) => void
onCreateVirtualTab: () => void
}
export function VirtualIdentityDialog({
open,
onOpenChange,
platforms,
persons,
isLoadingPlatforms,
isLoadingPersons,
personSearchQuery,
setPersonSearchQuery,
tempVirtualConfig,
setTempVirtualConfig,
onSelectPerson,
onCreateVirtualTab,
}: VirtualIdentityDialogProps) {
return (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent className="sm:max-w-[500px] max-h-[85vh] overflow-hidden flex flex-col">
<DialogHeader>
<DialogTitle className="flex items-center gap-2">
<UserCircle2 className="h-5 w-5" />
</DialogTitle>
<DialogDescription>
,使
</DialogDescription>
</DialogHeader>
<div className="space-y-4 flex-1 overflow-hidden flex flex-col">
{/* 平台选择 */}
<div className="space-y-2">
<Label className="flex items-center gap-2">
<Globe className="h-4 w-4" />
</Label>
<Select
value={tempVirtualConfig.platform}
onValueChange={(value) => {
setTempVirtualConfig(prev => ({
...prev,
platform: value,
personId: '',
userId: '',
userName: '',
}))
}}
>
<SelectTrigger disabled={isLoadingPlatforms}>
<SelectValue placeholder={isLoadingPlatforms ? "加载中..." : "选择平台"} />
</SelectTrigger>
<SelectContent>
{platforms.map((p) => (
<SelectItem key={p.platform} value={p.platform}>
{p.platform} ({p.count} )
</SelectItem>
))}
</SelectContent>
</Select>
</div>
{/* 用户搜索和选择 */}
{tempVirtualConfig.platform && (
<div className="space-y-2 flex-1 overflow-hidden flex flex-col">
<Label className="flex items-center gap-2">
<Users className="h-4 w-4" />
</Label>
<div className="relative">
<Search className="absolute left-3 top-1/2 -translate-y-1/2 h-4 w-4 text-muted-foreground" />
<Input
placeholder="搜索用户名..."
value={personSearchQuery}
onChange={(e) => setPersonSearchQuery(e.target.value)}
className="pl-9"
/>
</div>
<ScrollArea className="h-[250px] border rounded-md">
<div className="p-2">
{isLoadingPersons ? (
<div className="flex items-center justify-center py-8">
<Loader2 className="h-6 w-6 animate-spin text-muted-foreground" />
</div>
) : persons.length === 0 ? (
<div className="flex flex-col items-center justify-center py-8 text-muted-foreground">
<Users className="h-8 w-8 mb-2 opacity-50" />
<p className="text-sm"></p>
</div>
) : (
<div className="space-y-1">
{persons.map((person) => (
<button
key={person.person_id}
onClick={() => onSelectPerson(person)}
className={cn(
"w-full flex items-center gap-3 p-2 rounded-md text-left transition-colors",
tempVirtualConfig.personId === person.person_id
? "bg-primary text-primary-foreground"
: "hover:bg-muted"
)}
>
<Avatar className="h-8 w-8 shrink-0">
<AvatarFallback className={cn(
"text-xs",
tempVirtualConfig.personId === person.person_id
? "bg-primary-foreground/20"
: "bg-muted"
)}>
{(person.nickname || person.person_name || '?').charAt(0)}
</AvatarFallback>
</Avatar>
<div className="min-w-0 flex-1">
<div className="font-medium truncate">
{person.nickname || person.person_name}
</div>
<div className={cn(
"text-xs truncate",
tempVirtualConfig.personId === person.person_id
? "text-primary-foreground/70"
: "text-muted-foreground"
)}>
ID: {person.user_id}
{person.is_known && " · 已认识"}
</div>
</div>
</button>
))}
</div>
)}
</div>
</ScrollArea>
</div>
)}
{/* 虚拟群名配置 */}
{tempVirtualConfig.personId && (
<div className="space-y-2">
<Label></Label>
<Input
placeholder="WebUI虚拟群聊"
value={tempVirtualConfig.groupName}
onChange={(e) => setTempVirtualConfig(prev => ({
...prev,
groupName: e.target.value
}))}
/>
<p className="text-xs text-muted-foreground">
</p>
</div>
)}
</div>
<DialogFooter className="gap-2 sm:gap-0">
<Button variant="outline" onClick={() => onOpenChange(false)}>
</Button>
<Button
onClick={onCreateVirtualTab}
disabled={!tempVirtualConfig.platform || !tempVirtualConfig.personId}
>
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
)
}

View File

@@ -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 <span className="whitespace-pre-wrap">{String(segment.data)}</span>
case 'image':
case 'emoji':
return (
<img
src={String(segment.data)}
alt={segment.type === 'emoji' ? '表情包' : '图片'}
className={cn(
"rounded-lg max-w-full",
segment.type === 'emoji' ? "max-h-32" : "max-h-64"
)}
loading="lazy"
onError={(e) => {
// 图片加载失败时显示占位符
const target = e.target as HTMLImageElement
target.style.display = 'none'
target.parentElement?.insertAdjacentHTML(
'beforeend',
`<span class="text-muted-foreground text-xs">[${segment.type === 'emoji' ? '表情包' : '图片'}加载失败]</span>`
)
}}
/>
)
case 'voice':
return (
<div className="flex items-center gap-2">
<audio
controls
src={String(segment.data)}
className="max-w-[200px] h-8"
>
</audio>
</div>
)
case 'video':
return (
<video
controls
src={String(segment.data)}
className="rounded-lg max-w-full max-h-64"
>
</video>
)
case 'face':
// QQ 原生表情,显示为文本
return <span className="text-muted-foreground">[:{String(segment.data)}]</span>
case 'music':
return <span className="text-muted-foreground">[]</span>
case 'file':
return <span className="text-muted-foreground">[: {String(segment.data)}]</span>
case 'reply':
return <span className="text-muted-foreground text-xs">[]</span>
case 'forward':
return <span className="text-muted-foreground">[]</span>
case 'unknown':
default:
return <span className="text-muted-foreground">[{segment.original_type || '未知消息'}]</span>
}
}
// 渲染消息内容(支持富文本)
// 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 (
<div className="flex flex-col gap-2">
{message.segments.map((segment, index) => (
<RenderMessageSegment key={index} segment={segment} />
))}
</div>
)
}
// 普通文本消息
return <span className="whitespace-pre-wrap">{message.content}</span>
}
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 (
<div className="h-full flex flex-col">
{/* 虚拟身份配置对话框 */}
<Dialog open={showVirtualConfig} onOpenChange={setShowVirtualConfig}>
<DialogContent className="sm:max-w-[500px] max-h-[85vh] overflow-hidden flex flex-col">
<DialogHeader>
<DialogTitle className="flex items-center gap-2">
<UserCircle2 className="h-5 w-5" />
</DialogTitle>
<DialogDescription>
使
</DialogDescription>
</DialogHeader>
<div className="space-y-4 flex-1 overflow-hidden flex flex-col">
{/* 平台选择 */}
<div className="space-y-2">
<Label className="flex items-center gap-2">
<Globe className="h-4 w-4" />
</Label>
<Select
value={tempVirtualConfig.platform}
onValueChange={(value) => {
setTempVirtualConfig(prev => ({
...prev,
platform: value,
personId: '',
userId: '',
userName: '',
}))
setPersons([])
}}
>
<SelectTrigger disabled={isLoadingPlatforms}>
<SelectValue placeholder={isLoadingPlatforms ? "加载中..." : "选择平台"} />
</SelectTrigger>
<SelectContent>
{platforms.map((p) => (
<SelectItem key={p.platform} value={p.platform}>
{p.platform} ({p.count} )
</SelectItem>
))}
</SelectContent>
</Select>
</div>
{/* 用户搜索和选择 */}
{tempVirtualConfig.platform && (
<div className="space-y-2 flex-1 overflow-hidden flex flex-col">
<Label className="flex items-center gap-2">
<Users className="h-4 w-4" />
</Label>
<div className="relative">
<Search className="absolute left-3 top-1/2 -translate-y-1/2 h-4 w-4 text-muted-foreground" />
<Input
placeholder="搜索用户名..."
value={personSearchQuery}
onChange={(e) => setPersonSearchQuery(e.target.value)}
className="pl-9"
/>
</div>
<ScrollArea className="h-[250px] border rounded-md">
<div className="p-2">
{isLoadingPersons ? (
<div className="flex items-center justify-center py-8">
<Loader2 className="h-6 w-6 animate-spin text-muted-foreground" />
</div>
) : persons.length === 0 ? (
<div className="flex flex-col items-center justify-center py-8 text-muted-foreground">
<Users className="h-8 w-8 mb-2 opacity-50" />
<p className="text-sm"></p>
</div>
) : (
<div className="space-y-1">
{persons.map((person) => (
<button
key={person.person_id}
onClick={() => selectPerson(person)}
className={cn(
"w-full flex items-center gap-3 p-2 rounded-md text-left transition-colors",
tempVirtualConfig.personId === person.person_id
? "bg-primary text-primary-foreground"
: "hover:bg-muted"
)}
>
<Avatar className="h-8 w-8 shrink-0">
<AvatarFallback className={cn(
"text-xs",
tempVirtualConfig.personId === person.person_id
? "bg-primary-foreground/20"
: "bg-muted"
)}>
{(person.nickname || person.person_name || '?').charAt(0)}
</AvatarFallback>
</Avatar>
<div className="min-w-0 flex-1">
<div className="font-medium truncate">
{person.nickname || person.person_name}
</div>
<div className={cn(
"text-xs truncate",
tempVirtualConfig.personId === person.person_id
? "text-primary-foreground/70"
: "text-muted-foreground"
)}>
ID: {person.user_id}
{person.is_known && " · 已认识"}
</div>
</div>
</button>
))}
</div>
)}
</div>
</ScrollArea>
</div>
)}
{/* 虚拟群名配置 */}
{tempVirtualConfig.personId && (
<div className="space-y-2">
<Label></Label>
<Input
placeholder="WebUI虚拟群聊"
value={tempVirtualConfig.groupName}
onChange={(e) => setTempVirtualConfig(prev => ({
...prev,
groupName: e.target.value
}))}
/>
<p className="text-xs text-muted-foreground">
</p>
</div>
)}
</div>
<DialogFooter className="gap-2 sm:gap-0">
<Button variant="outline" onClick={() => setShowVirtualConfig(false)}>
</Button>
<Button
onClick={createVirtualTab}
disabled={!tempVirtualConfig.platform || !tempVirtualConfig.personId}
>
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
<VirtualIdentityDialog
open={showVirtualConfig}
onOpenChange={setShowVirtualConfig}
platforms={platforms}
persons={persons}
isLoadingPlatforms={isLoadingPlatforms}
isLoadingPersons={isLoadingPersons}
personSearchQuery={personSearchQuery}
setPersonSearchQuery={setPersonSearchQuery}
tempVirtualConfig={tempVirtualConfig}
setTempVirtualConfig={setTempVirtualConfig}
onSelectPerson={selectPerson}
onCreateVirtualTab={createVirtualTab}
/>
{/* 标签页栏 */}
<div className="shrink-0 border-b bg-muted/30">
<div className="max-w-4xl mx-auto px-2 sm:px-4">
<div className="flex items-center gap-1 overflow-x-auto py-1.5 scrollbar-thin">
{tabs.map((tab) => (
<div
key={tab.id}
className={cn(
"flex items-center gap-1.5 px-3 py-1.5 rounded-md text-sm whitespace-nowrap transition-colors cursor-pointer",
"hover:bg-muted",
activeTabId === tab.id
? "bg-background shadow-sm border"
: "text-muted-foreground"
)}
onClick={() => switchTab(tab.id)}
>
{tab.type === 'webui' ? (
<MessageSquare className="h-3.5 w-3.5" />
) : (
<UserCircle2 className="h-3.5 w-3.5" />
)}
<span className="max-w-[100px] truncate">{tab.label}</span>
{/* 连接状态指示器 */}
<span className={cn(
"w-1.5 h-1.5 rounded-full",
tab.isConnected ? "bg-green-500" : "bg-muted-foreground/50"
)} />
{/* 关闭按钮(非默认标签页) */}
{tab.id !== 'webui-default' && (
<span
onClick={(e) => 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)
}
}}
>
<X className="h-3 w-3" />
</span>
)}
</div>
))}
{/* 新建虚拟身份标签页按钮 */}
<button
onClick={openVirtualConfig}
className="flex items-center gap-1 px-2 py-1.5 rounded-md text-sm text-muted-foreground hover:bg-muted hover:text-foreground transition-colors"
title="新建虚拟身份对话"
>
<Plus className="h-3.5 w-3.5" />
</button>
</div>
</div>
</div>
<ChatTabBar
tabs={tabs}
activeTabId={activeTabId}
onSwitch={switchTab}
onClose={closeTab}
onAddVirtual={openVirtualConfig}
/>
{/* 头部信息栏 */}
<div className="shrink-0 border-b bg-background/95 backdrop-blur supports-[backdrop-filter]:bg-background/60">

View File

@@ -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[]
}

View File

@@ -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)
}
}