Refactor personality and emoji configuration forms; add chat components

- Updated PersonalityForm to handle multiple reply styles and probabilities.
- Removed unused fields from PersonalityConfig and adjusted default values.
- Refactored loadPersonalityConfig and loadEmojiConfig to align with new structure.
- Introduced ChatComposer, ChatHeaderBar, ChatWorkspaceSidebar, and MessageList components for improved chat interface.
- Enhanced user experience with dynamic message rendering and connection status indicators.
- Cleaned up API calls for saving configurations, focusing on essential fields.

Co-authored-by: Copilot <copilot@github.com>
This commit is contained in:
DrSmoothl
2026-04-25 00:02:14 +08:00
parent 201efe66a1
commit 5dfd6a60c5
21 changed files with 2076 additions and 1047 deletions

View File

@@ -0,0 +1,72 @@
import { Send } from 'lucide-react'
import { useTranslation } from 'react-i18next'
import { Button } from '@/components/ui/button'
import { Textarea } from '@/components/ui/textarea'
import { cn } from '@/lib/utils'
interface ChatComposerProps {
value: string
onChange: (value: string) => void
onSend: () => void
disabled: boolean
isConnected: boolean
}
/**
* 聊天输入区:自适应高度的输入框 + 浮动发送按钮,带快捷键提示。
*/
export function ChatComposer({ value, onChange, onSend, disabled, isConnected }: ChatComposerProps) {
const { t } = useTranslation()
const handleKeyDown = (e: React.KeyboardEvent<HTMLTextAreaElement>) => {
if (e.key === 'Enter' && !e.shiftKey && !e.nativeEvent.isComposing) {
e.preventDefault()
if (!disabled) onSend()
}
}
const canSend = !disabled && value.trim().length > 0
return (
<div className="bg-card/85 supports-backdrop-filter:bg-card/65 shrink-0 border-t backdrop-blur">
<div className="mx-auto max-w-4xl px-3 py-3 sm:px-6 sm:py-4">
<div
className={cn(
'group bg-background/80 focus-within:border-primary/60 focus-within:ring-primary/20 relative flex items-end gap-2 rounded-2xl border px-3 py-2 shadow-sm transition focus-within:ring-2',
!isConnected && 'opacity-70'
)}
>
<Textarea
aria-label={t('chat.input.placeholder')}
autoResize
className="max-h-40 min-h-9 flex-1 resize-none border-0 bg-transparent px-1 py-1.5 text-sm shadow-none focus-visible:ring-0"
disabled={!isConnected}
maxHeight={160}
minHeight={36}
placeholder={isConnected ? t('chat.input.placeholder') : t('chat.input.waiting')}
value={value}
onChange={(e) => onChange(e.target.value)}
onKeyDown={handleKeyDown}
/>
<Button
aria-label={t('chat.actions.send')}
className={cn(
'h-9 w-9 shrink-0 rounded-full transition',
canSend ? 'shadow-md' : 'opacity-60'
)}
disabled={!canSend}
size="icon"
title={t('chat.actions.send')}
onClick={onSend}
>
<Send className="h-4 w-4" />
</Button>
</div>
<p className="text-muted-foreground mt-1.5 hidden px-2 text-[11px] sm:block">
{t('chat.composer.hint')}
</p>
</div>
</div>
)
}

View File

@@ -0,0 +1,124 @@
import { Bot, Loader2, RefreshCw, UserCircle2, Users, Wifi, WifiOff } from 'lucide-react'
import { useTranslation } from 'react-i18next'
import { Avatar, AvatarFallback } from '@/components/ui/avatar'
import { Button } from '@/components/ui/button'
import { Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui/tooltip'
import { cn } from '@/lib/utils'
import type { ChatTab } from './types'
interface ChatHeaderBarProps {
activeTab: ChatTab | undefined
botDisplayName: string
isConnecting: boolean
isLoadingHistory: boolean
onReconnect: () => void
}
/**
* 聊天主面板顶部信息栏:展示当前会话头像、标题、连接状态以及操作按钮。
*/
export function ChatHeaderBar({
activeTab,
botDisplayName,
isConnecting,
isLoadingHistory,
onReconnect,
}: ChatHeaderBarProps) {
const { t } = useTranslation()
const isVirtual = activeTab?.type === 'virtual'
const virtualConfig = activeTab?.virtualConfig
const connected = activeTab?.isConnected ?? false
return (
<header className="bg-card/85 supports-backdrop-filter:bg-card/65 relative z-1 shrink-0 border-b backdrop-blur">
<div className="flex items-center justify-between gap-3 px-4 py-3 sm:px-6 sm:py-4">
<div className="flex min-w-0 items-center gap-3">
{/* 头像 + 在线状态指示点 */}
<div className="relative shrink-0">
<Avatar className="h-10 w-10 ring-1 ring-border/60 sm:h-11 sm:w-11">
<AvatarFallback className="bg-primary-gradient text-primary-foreground">
<Bot className="h-5 w-5" />
</AvatarFallback>
</Avatar>
<span
aria-hidden="true"
className={cn(
'absolute right-0 bottom-0 h-3 w-3 rounded-full border-2 border-card transition-colors',
connected ? 'bg-emerald-500' : isConnecting ? 'bg-amber-500' : 'bg-muted-foreground/60'
)}
/>
</div>
{/* 标题与副标题 */}
<div className="min-w-0">
<h1 className="truncate text-sm font-semibold leading-tight sm:text-base">
{botDisplayName}
</h1>
<div className="text-muted-foreground mt-0.5 flex items-center gap-1.5 text-xs leading-tight">
{connected ? (
<>
<Wifi className="h-3 w-3 text-emerald-500" />
<span>{t('chat.status.connected')}</span>
</>
) : isConnecting ? (
<>
<Loader2 className="h-3 w-3 animate-spin" />
<span>{t('chat.status.connecting')}</span>
</>
) : (
<>
<WifiOff className="h-3 w-3 text-rose-500" />
<span>{t('chat.status.disconnected')}</span>
</>
)}
{isVirtual && virtualConfig && (
<>
<span aria-hidden className="text-muted-foreground/40">·</span>
<span className="inline-flex items-center gap-1">
<UserCircle2 className="h-3 w-3" />
<span className="max-w-40 truncate">{virtualConfig.userName}</span>
</span>
<span className="bg-muted text-muted-foreground rounded-full px-1.5 py-0.5 text-[10px] font-medium">
{virtualConfig.platform}
</span>
{virtualConfig.groupName && (
<span className="hidden items-center gap-1 sm:inline-flex">
<Users className="h-3 w-3" />
<span className="max-w-40 truncate">{virtualConfig.groupName}</span>
</span>
)}
</>
)}
</div>
</div>
</div>
{/* 右侧操作 */}
<div className="flex shrink-0 items-center gap-1">
{isLoadingHistory && (
<Loader2 className="text-muted-foreground h-4 w-4 animate-spin" aria-hidden="true" />
)}
<Tooltip>
<TooltipTrigger asChild>
<Button
aria-label={t('chat.actions.reconnect')}
className="h-9 w-9 rounded-full"
disabled={isConnecting}
size="icon"
variant="ghost"
onClick={onReconnect}
>
<RefreshCw className={cn('h-4 w-4', isConnecting && 'animate-spin')} />
</Button>
</TooltipTrigger>
<TooltipContent side="bottom">{t('chat.actions.reconnect')}</TooltipContent>
</Tooltip>
</div>
</div>
</header>
)
}

View File

@@ -1,5 +1,7 @@
import { Bot, Plus, UserCircle2, X } from 'lucide-react'
import { useTranslation } from 'react-i18next'
import { cn } from '@/lib/utils'
import { MessageSquare, Plus, UserCircle2, X } from 'lucide-react'
import type { ChatTab } from './types'
@@ -11,69 +13,74 @@ interface ChatTabBarProps {
onAddVirtual: () => void
}
export function ChatTabBar({
tabs,
activeTabId,
onSwitch,
onClose,
onAddVirtual,
}: ChatTabBarProps) {
/**
* 移动端横向会话切换条:在窄屏隐藏侧边栏时使用,保持与桌面端一致的视觉语言。
*/
export function ChatTabBar({ tabs, activeTabId, onSwitch, onClose, onAddVirtual }: ChatTabBarProps) {
const { t } = useTranslation()
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) => (
<button
<div className="bg-card/85 supports-backdrop-filter:bg-card/65 shrink-0 border-b backdrop-blur">
<div className="scrollbar-thin flex items-center gap-1 overflow-x-auto px-3 py-2">
{tabs.map((tab) => {
const active = activeTabId === tab.id
const Icon = tab.type === 'virtual' ? UserCircle2 : Bot
return (
<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"
'group flex shrink-0 items-center rounded-full border text-xs transition',
active
? 'bg-primary text-primary-foreground border-transparent shadow-sm'
: 'bg-background/60 text-muted-foreground hover:text-foreground hover:bg-background border-transparent'
)}
type="button"
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' && (
<button
type="button"
className="flex items-center gap-1.5 rounded-full px-3 py-1.5"
onClick={() => onSwitch(tab.id)}
>
<Icon className="h-3.5 w-3.5" />
<span className="max-w-32 truncate font-medium">{tab.label}</span>
<span
aria-hidden
className={cn(
'h-1.5 w-1.5 rounded-full transition-colors',
active
? tab.isConnected
? 'bg-primary-foreground'
: 'bg-primary-foreground/50'
: tab.isConnected
? 'bg-emerald-500'
: 'bg-muted-foreground/40'
)}
/>
</button>
{tab.id !== 'webui-default' && (
<button
type="button"
aria-label={t('chat.sidebar.closeConversation', { label: tab.label })}
className={cn(
'mr-1 rounded-full p-0.5 transition',
active ? 'hover:bg-primary-foreground/20' : 'hover:bg-muted'
)}
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)
}
}}
>
<X className="h-3 w-3" />
</span>
</button>
)}
</button>
))}
{/* 新建虚拟身份标签页按钮 */}
<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>
)
})}
<button
type="button"
aria-label={t('chat.sidebar.newVirtual')}
title={t('chat.sidebar.newVirtual')}
className="text-muted-foreground hover:bg-muted hover:text-foreground flex h-7 w-7 shrink-0 items-center justify-center rounded-full border border-dashed transition"
onClick={onAddVirtual}
>
<Plus className="h-3.5 w-3.5" />
</button>
</div>
</div>
)

View File

@@ -0,0 +1,265 @@
import { Bot, Check, Edit2, Plus, UserCircle2, X } from 'lucide-react'
import { useState } from 'react'
import { useTranslation } from 'react-i18next'
import { Avatar, AvatarFallback } from '@/components/ui/avatar'
import { Button } from '@/components/ui/button'
import { Input } from '@/components/ui/input'
import { ScrollArea } from '@/components/ui/scroll-area'
import { Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui/tooltip'
import { cn } from '@/lib/utils'
import type { ChatMessage, ChatTab } from './types'
interface ChatWorkspaceSidebarProps {
className?: string
tabs: ChatTab[]
activeTabId: string
userName: string
onSwitch: (tabId: string) => void
onClose: (tabId: string, e?: React.MouseEvent | React.KeyboardEvent) => void
onAddVirtual: () => void
onUpdateUserName: (name: string) => void
}
function getMessagePreview(message: ChatMessage | undefined, fallback: string, thinking: 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
}
function ConversationItem({
tab,
active,
onSwitch,
onClose,
}: {
tab: ChatTab
active: boolean
onSwitch: (id: string) => void
onClose: (id: string, e?: React.MouseEvent | React.KeyboardEvent) => void
}) {
const { t } = useTranslation()
const isVirtual = tab.type === 'virtual'
const lastMessage = tab.messages[tab.messages.length - 1]
const preview = getMessagePreview(
lastMessage,
t('chat.sidebar.emptyPreview'),
t('chat.message.thinking')
)
const Icon = isVirtual ? UserCircle2 : Bot
return (
<div
className={cn(
'group relative flex w-full min-w-0 items-center gap-1 rounded-xl pr-1 transition-colors',
active
? 'bg-primary/12 text-foreground shadow-inner'
: 'hover:bg-muted/70 text-foreground/90'
)}
>
{active && (
<span aria-hidden className="bg-primary absolute top-2 bottom-2 left-0 w-1 rounded-full" />
)}
<button
type="button"
className="flex w-full min-w-0 flex-1 items-center gap-3 overflow-hidden rounded-xl px-2.5 py-2 text-left"
onClick={() => onSwitch(tab.id)}
>
<div className="relative shrink-0">
<Avatar className="h-11 w-11 ring-1 ring-border/60">
<AvatarFallback
className={cn(
'text-xs',
isVirtual
? 'bg-secondary text-secondary-foreground'
: 'bg-primary-gradient text-primary-foreground'
)}
>
<Icon className="h-5 w-5" />
</AvatarFallback>
</Avatar>
<span
aria-hidden
className={cn(
'border-card absolute right-0 bottom-0 h-3 w-3 rounded-full border-2 transition-colors',
tab.isConnected ? 'bg-emerald-500' : 'bg-muted-foreground/40'
)}
/>
</div>
<div className="min-w-0 flex-1">
<div className="flex min-w-0 items-center justify-between gap-2">
<span className="min-w-0 flex-1 truncate text-sm font-medium">{tab.label}</span>
<span
className={cn(
'shrink-0 rounded-full px-1.5 py-0.5 text-[10px] font-medium tracking-wide',
isVirtual
? 'bg-secondary text-secondary-foreground'
: 'bg-primary/15 text-primary'
)}
>
{isVirtual ? t('chat.sidebar.virtualBadge') : t('chat.sidebar.webuiBadge')}
</span>
</div>
<p className="text-muted-foreground mt-0.5 truncate text-xs">{preview}</p>
</div>
</button>
{tab.id !== 'webui-default' && (
<button
type="button"
aria-label={t('chat.sidebar.closeConversation', { label: tab.label })}
className="text-muted-foreground hover:bg-background hover:text-foreground rounded-md p-1 opacity-0 transition group-hover:opacity-100 focus-visible:opacity-100"
onClick={(e) => onClose(tab.id, e)}
>
<X className="h-3.5 w-3.5" />
</button>
)}
</div>
)
}
export function ChatWorkspaceSidebar({
className,
tabs,
activeTabId,
userName,
onSwitch,
onClose,
onAddVirtual,
onUpdateUserName,
}: ChatWorkspaceSidebarProps) {
const { t } = useTranslation()
const [editing, setEditing] = useState(false)
const [draftName, setDraftName] = useState(userName)
const startEditing = () => {
setDraftName(userName)
setEditing(true)
}
const commit = () => {
const next = draftName.trim() || t('chat.userNameFallback')
onUpdateUserName(next)
setEditing(false)
}
return (
<aside
className={cn(
'bg-card/90 supports-backdrop-filter:bg-card/70 flex h-full w-72 shrink-0 flex-col border-r backdrop-blur xl:w-80',
className
)}
>
{/* 头部:标题 + 新建按钮 */}
<div className="border-b px-4 pt-5 pb-4">
<div className="flex items-end justify-between gap-3">
<div className="min-w-0">
<h2 className="truncate text-lg font-semibold tracking-tight">
{t('chat.sidebar.title')}
</h2>
<p className="text-muted-foreground mt-0.5 truncate text-xs">
{t('chat.sidebar.subtitle', { count: tabs.length })}
</p>
</div>
<Tooltip>
<TooltipTrigger asChild>
<Button
aria-label={t('chat.sidebar.newVirtual')}
className="h-9 w-9 shrink-0 rounded-full shadow-sm"
size="icon"
onClick={onAddVirtual}
>
<Plus className="h-4 w-4" />
</Button>
</TooltipTrigger>
<TooltipContent side="bottom">{t('chat.sidebar.newVirtual')}</TooltipContent>
</Tooltip>
</div>
</div>
{/* 会话列表 */}
<ScrollArea
className="min-h-0 flex-1"
contentClassName="!block w-full min-w-0"
scrollbars="vertical"
viewportClassName="[&>div]:!block [&>div]:!min-w-0 [&>div]:w-full"
>
<nav aria-label={t('chat.sidebar.conversations')} className="space-y-0.5 p-2">
{tabs.map((tab) => (
<ConversationItem
key={tab.id}
active={activeTabId === tab.id}
tab={tab}
onSwitch={onSwitch}
onClose={onClose}
/>
))}
</nav>
</ScrollArea>
{/* 底部:本地用户身份 */}
<div className="border-t p-3">
<div className="bg-background/70 hover:bg-background flex items-center gap-3 rounded-xl border p-2.5 transition-colors">
<Avatar className="h-10 w-10 shrink-0 ring-1 ring-border/60">
<AvatarFallback className="bg-secondary text-secondary-foreground">
<UserCircle2 className="h-5 w-5" />
</AvatarFallback>
</Avatar>
<div className="min-w-0 flex-1">
<p className="text-muted-foreground text-[11px] uppercase tracking-wide">
{t('chat.sidebar.profileTitle')}
</p>
{editing ? (
<div className="mt-0.5 flex items-center gap-1">
<Input
autoFocus
className="h-7 text-sm"
placeholder={t('chat.identity.namePlaceholder')}
value={draftName}
onChange={(e) => setDraftName(e.target.value)}
onKeyDown={(e) => {
if (e.key === 'Enter' && !e.nativeEvent.isComposing) {
e.preventDefault()
commit()
} else if (e.key === 'Escape') {
setEditing(false)
}
}}
/>
<Button
aria-label={t('chat.sidebar.saveName')}
className="h-7 w-7 shrink-0"
size="icon"
variant="ghost"
onClick={commit}
>
<Check className="h-3.5 w-3.5" />
</Button>
</div>
) : (
<div className="flex min-w-0 items-center gap-1">
<p className="min-w-0 flex-1 truncate text-sm font-medium">{userName}</p>
<Tooltip>
<TooltipTrigger asChild>
<Button
aria-label={t('chat.sidebar.editName')}
className="h-6 w-6 shrink-0 opacity-60 hover:opacity-100"
size="icon"
variant="ghost"
onClick={startEditing}
>
<Edit2 className="h-3 w-3" />
</Button>
</TooltipTrigger>
<TooltipContent side="top">{t('chat.sidebar.editName')}</TooltipContent>
</Tooltip>
</div>
)}
</div>
</div>
</div>
</aside>
)
}

View File

@@ -0,0 +1,227 @@
import { Bot, Sparkles, User } from 'lucide-react'
import { useEffect, useRef } from 'react'
import { useTranslation } from 'react-i18next'
import { Avatar, AvatarFallback } from '@/components/ui/avatar'
import { ScrollArea } from '@/components/ui/scroll-area'
import { cn } from '@/lib/utils'
import { RenderMessageContent } from './MessageRenderer'
import type { ChatMessage } from './types'
interface MessageListProps {
messages: ChatMessage[]
isLoadingHistory: boolean
botDisplayName: string
userName: string
language: string
}
interface BubbleAvatarProps {
type: 'user' | 'bot' | 'thinking'
visible: boolean
}
function BubbleAvatar({ type, visible }: BubbleAvatarProps) {
return (
<div className="h-8 w-8 shrink-0 sm:h-9 sm:w-9">
{visible && (
<Avatar className="h-full w-full ring-1 ring-border/60">
<AvatarFallback
className={cn(
'text-xs',
type === 'user'
? 'bg-secondary text-secondary-foreground'
: 'bg-primary-gradient text-primary-foreground'
)}
>
{type === 'user' ? (
<User className="h-4 w-4" />
) : (
<Bot className="h-4 w-4" />
)}
</AvatarFallback>
</Avatar>
)}
</div>
)
}
function ThinkingBubble() {
const { t } = useTranslation()
return (
<div className="bg-muted/80 text-muted-foreground inline-flex items-center gap-2 rounded-2xl rounded-bl-sm px-3.5 py-2.5">
<span className="flex gap-1">
<span className="bg-primary/60 h-1.5 w-1.5 animate-bounce rounded-full [animation-delay:0ms]" />
<span className="bg-primary/60 h-1.5 w-1.5 animate-bounce rounded-full [animation-delay:150ms]" />
<span className="bg-primary/60 h-1.5 w-1.5 animate-bounce rounded-full [animation-delay:300ms]" />
</span>
<span className="text-xs">{t('chat.message.thinking')}</span>
</div>
)
}
function EmptyState({ botName }: { botName: string }) {
const { t } = useTranslation()
return (
<div className="flex h-full flex-col items-center justify-center gap-3 px-6 py-16 text-center">
<div className="bg-primary-gradient text-primary-foreground relative flex h-16 w-16 items-center justify-center rounded-2xl shadow-lg">
<Sparkles className="h-7 w-7" />
<span className="bg-primary/30 absolute inset-0 -z-10 animate-pulse rounded-2xl blur-xl" />
</div>
<div className="space-y-1">
<h2 className="text-base font-semibold sm:text-lg">
{t('chat.message.empty', { bot: botName })}
</h2>
<p className="text-muted-foreground text-xs sm:text-sm">{t('chat.message.emptyHint')}</p>
</div>
</div>
)
}
/**
* 聊天消息列表:支持连续同发送者消息分组、思考占位、富文本与系统/错误信息样式。
*/
export function MessageList({
messages,
isLoadingHistory,
botDisplayName,
userName,
language,
}: MessageListProps) {
const { t } = useTranslation()
const endRef = useRef<HTMLDivElement>(null)
useEffect(() => {
endRef.current?.scrollIntoView({ behavior: 'smooth' })
}, [messages])
const formatTime = (timestamp: number) => {
const date = new Date(timestamp * 1000)
return date.toLocaleTimeString(language || 'zh-CN', {
hour: '2-digit',
minute: '2-digit',
})
}
if (messages.length === 0 && !isLoadingHistory) {
return (
<div className="min-w-0 min-h-0 flex-1 overflow-hidden">
<ScrollArea
className="h-full w-full"
contentClassName="!block w-full min-w-0"
scrollbars="vertical"
viewportClassName="[&>div]:!block [&>div]:!min-w-0 [&>div]:w-full"
>
<EmptyState botName={botDisplayName} />
</ScrollArea>
</div>
)
}
return (
<div className="min-w-0 min-h-0 flex-1 overflow-hidden">
<ScrollArea
className="h-full w-full"
contentClassName="!block w-full min-w-0"
scrollbars="vertical"
viewportClassName="[&>div]:!block [&>div]:!min-w-0 [&>div]:w-full"
>
<div className="mx-auto flex w-full max-w-4xl min-w-0 flex-col gap-1 px-3 py-5 sm:px-6 sm:py-6">
{messages.map((message, index) => {
// 系统消息:作为分隔条
if (message.type === 'system') {
return (
<div key={message.id} className="my-2 flex items-center gap-3">
<div className="bg-border/60 h-px flex-1" />
<span className="text-muted-foreground bg-card/70 rounded-full border px-3 py-0.5 text-[11px]">
{message.content}
</span>
<div className="bg-border/60 h-px flex-1" />
</div>
)
}
// 错误消息
if (message.type === 'error') {
return (
<div key={message.id} className="my-2 flex justify-center">
<div className="bg-destructive/10 text-destructive border-destructive/30 rounded-full border px-3 py-1 text-xs">
{message.content}
</div>
</div>
)
}
const isUser = message.type === 'user'
const isThinking = message.type === 'thinking'
const bubbleType: 'user' | 'bot' | 'thinking' = isUser ? 'user' : isThinking ? 'thinking' : 'bot'
// 是否与上一条消息属于同一发送者(用于分组:仅首条显示头像 + 名字)
const previous = messages[index - 1]
const sameGroup =
previous &&
previous.type === message.type &&
(previous.sender?.user_id ?? previous.sender?.name) ===
(message.sender?.user_id ?? message.sender?.name)
const senderName =
message.sender?.name || (isUser ? userName : botDisplayName)
return (
<div
key={message.id}
className={cn(
'flex w-full min-w-0 items-end gap-2 sm:gap-3',
isUser ? 'flex-row-reverse' : 'flex-row',
sameGroup ? 'mt-0.5' : 'mt-3 first:mt-0'
)}
>
<BubbleAvatar type={bubbleType === 'thinking' ? 'bot' : bubbleType} visible={!sameGroup} />
<div
className={cn(
'flex min-w-0 max-w-[80%] flex-col sm:max-w-[70%]',
isUser ? 'items-end' : 'items-start'
)}
>
{!sameGroup && (
<div
className={cn(
'text-muted-foreground mb-1 flex items-center gap-2 px-1 text-[11px]',
isUser && 'flex-row-reverse'
)}
>
<span className="hidden font-medium sm:inline">{senderName}</span>
<span>{formatTime(message.timestamp)}</span>
</div>
)}
{isThinking ? (
<ThinkingBubble />
) : (
<div
className={cn(
'shadow-sm/30 wrap-break-word min-w-0 max-w-full overflow-hidden px-3.5 py-2 text-sm leading-relaxed',
isUser
? 'bg-primary text-primary-foreground rounded-2xl rounded-br-md'
: 'bg-muted text-foreground rounded-2xl rounded-bl-md'
)}
>
<RenderMessageContent message={message} isBot={!isUser} />
</div>
)}
</div>
</div>
)
})}
<div ref={endRef} />
{/* 用于读屏 / 避免悬空 */}
<span className="sr-only" aria-live="polite">
{messages.length > 0 ? t('chat.sidebar.subtitle', { count: messages.length }) : ''}
</span>
</div>
</ScrollArea>
</div>
)
}

View File

@@ -1,87 +1,106 @@
import { useTranslation } from 'react-i18next'
import { cn } from '@/lib/utils'
import type { ChatMessage, MessageSegment } from './types'
// 渲染单个消息段
export function RenderMessageSegment({ segment }: { segment: MessageSegment }) {
const { t } = useTranslation()
switch (segment.type) {
case 'text':
return <span className="whitespace-pre-wrap">{String(segment.data)}</span>
case 'image':
case 'emoji':
case 'emoji': {
const mediaLabel = segment.type === 'emoji' ? t('chat.media.emoji') : t('chat.media.image')
return (
<img
src={String(segment.data)}
alt={segment.type === 'emoji' ? '表情包' : '图片'}
<img
src={String(segment.data)}
alt={mediaLabel}
className={cn(
"rounded-lg max-w-full",
segment.type === 'emoji' ? "max-h-32" : "max-h-64"
'max-w-full rounded-lg',
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>`
)
const fallback = document.createElement('span')
fallback.className = 'text-muted-foreground text-xs'
fallback.textContent = t('chat.media.loadFailed', { type: mediaLabel })
target.parentElement?.appendChild(fallback)
}}
/>
)
}
case 'voice':
return (
<div className="flex items-center gap-2">
<audio
controls
src={String(segment.data)}
className="max-w-[200px] h-8"
>
<track kind="captions" src="" label="无字幕" default />
<audio controls src={String(segment.data)} className="h-8 max-w-[200px]">
<track kind="captions" src="" label={t('chat.media.noCaptions')} default />
{t('chat.media.audioUnsupported')}
</audio>
</div>
)
case 'video':
return (
<video
controls
src={String(segment.data)}
className="rounded-lg max-w-full max-h-64"
>
<track kind="captions" src="" label="无字幕" default />
<video controls src={String(segment.data)} className="max-h-64 max-w-full rounded-lg">
<track kind="captions" src="" label={t('chat.media.noCaptions')} default />
{t('chat.media.videoUnsupported')}
</video>
)
case 'face':
// QQ 原生表情,显示为文本
return <span className="text-muted-foreground">[:{String(segment.data)}]</span>
return (
<span className="text-muted-foreground">
{t('chat.media.face', { data: String(segment.data) })}
</span>
)
case 'music':
return <span className="text-muted-foreground">[]</span>
return <span className="text-muted-foreground">{t('chat.media.music')}</span>
case 'file':
return <span className="text-muted-foreground">[: {String(segment.data)}]</span>
return (
<span className="text-muted-foreground">
{t('chat.media.file', { data: String(segment.data) })}
</span>
)
case 'reply':
return <span className="text-muted-foreground text-xs">[]</span>
return <span className="text-muted-foreground text-xs">{t('chat.media.reply')}</span>
case 'forward':
return <span className="text-muted-foreground">[]</span>
return <span className="text-muted-foreground">{t('chat.media.forward')}</span>
case 'unknown':
default:
return <span className="text-muted-foreground">[{segment.original_type || '未知消息'}]</span>
return (
<span className="text-muted-foreground">
{t('chat.media.unknown', {
type: segment.original_type || t('chat.media.unknownMessage'),
})}
</span>
)
}
}
// 渲染消息内容(支持富文本)
// eslint-disable-next-line @typescript-eslint/no-unused-vars
export function RenderMessageContent({ message, isBot: _isBot }: { message: ChatMessage; isBot: boolean }) {
export function RenderMessageContent({
message,
isBot: _isBot,
}: {
message: ChatMessage
isBot: boolean
}) {
// 如果是富文本消息,渲染消息段
if (message.message_type === 'rich' && message.segments && message.segments.length > 0) {
return (
@@ -92,7 +111,7 @@ export function RenderMessageContent({ message, isBot: _isBot }: { message: Chat
</div>
)
}
// 普通文本消息
return <span className="whitespace-pre-wrap">{message.content}</span>
}

View File

@@ -8,9 +8,9 @@ import {
DialogFooter,
DialogHeader,
DialogTitle,
} from "@/components/ui/dialog"
} from '@/components/ui/dialog'
import { Input } from '@/components/ui/input'
import { Label } from "@/components/ui/label"
import { Label } from '@/components/ui/label'
import { ScrollArea } from '@/components/ui/scroll-area'
import {
Select,
@@ -18,9 +18,10 @@ import {
SelectItem,
SelectTrigger,
SelectValue,
} from "@/components/ui/select"
} from '@/components/ui/select'
import { cn } from '@/lib/utils'
import { Globe, Loader2, Search, UserCircle2, Users } from 'lucide-react'
import { useTranslation } from 'react-i18next'
import type { PersonInfo, PlatformInfo, VirtualIdentityConfig } from './types'
@@ -53,30 +54,33 @@ export function VirtualIdentityDialog({
onSelectPerson,
onCreateVirtualTab,
}: VirtualIdentityDialogProps) {
const { t } = useTranslation()
return (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent className="sm:max-w-125 max-h-[85vh] overflow-hidden flex flex-col" confirmOnEnter>
<DialogContent
className="flex max-h-[85vh] flex-col overflow-hidden sm:max-w-125"
confirmOnEnter
>
<DialogHeader>
<DialogTitle className="flex items-center gap-2">
<UserCircle2 className="h-5 w-5" />
{t('chat.dialog.title')}
</DialogTitle>
<DialogDescription>
,使
</DialogDescription>
<DialogDescription>{t('chat.dialog.description')}</DialogDescription>
</DialogHeader>
<DialogBody className="space-y-4 flex-1" viewportClassName="pr-0">
<DialogBody className="flex-1 space-y-4" viewportClassName="pr-0">
{/* 平台选择 */}
<div className="space-y-2">
<Label className="flex items-center gap-2">
<Globe className="h-4 w-4" />
{t('chat.dialog.platform')}
</Label>
<Select
value={tempVirtualConfig.platform}
onValueChange={(value) => {
setTempVirtualConfig(prev => ({
setTempVirtualConfig((prev) => ({
...prev,
platform: value,
personId: '',
@@ -86,12 +90,18 @@ export function VirtualIdentityDialog({
}}
>
<SelectTrigger disabled={isLoadingPlatforms}>
<SelectValue placeholder={isLoadingPlatforms ? "加载中..." : "选择平台"} />
<SelectValue
placeholder={
isLoadingPlatforms
? t('chat.dialog.loading')
: t('chat.dialog.platformPlaceholder')
}
/>
</SelectTrigger>
<SelectContent>
{platforms.map((p) => (
<SelectItem key={p.platform} value={p.platform}>
{p.platform} ({p.count} )
{p.platform} {t('chat.dialog.personCount', { count: p.count })}
</SelectItem>
))}
</SelectContent>
@@ -100,70 +110,76 @@ export function VirtualIdentityDialog({
{/* 用户搜索和选择 */}
{tempVirtualConfig.platform && (
<div className="space-y-2 flex-1 overflow-hidden flex flex-col">
<div className="flex flex-1 flex-col space-y-2 overflow-hidden">
<Label className="flex items-center gap-2">
<Users className="h-4 w-4" />
{t('chat.dialog.user')}
</Label>
<div className="relative">
<Search className="absolute left-3 top-1/2 -translate-y-1/2 h-4 w-4 text-muted-foreground" />
<Search className="text-muted-foreground absolute top-1/2 left-3 h-4 w-4 -translate-y-1/2" />
<Input
placeholder="搜索用户名..."
placeholder={t('chat.dialog.searchUser')}
value={personSearchQuery}
onChange={(e) => setPersonSearchQuery(e.target.value)}
className="pl-9"
/>
</div>
<ScrollArea className="h-62.5 border rounded-md">
<div className="p-2">
<ScrollArea className="bg-background/40 h-62.5 rounded-lg border">
<div className="p-1.5">
{isLoadingPersons ? (
<div className="flex items-center justify-center py-8">
<Loader2 className="h-6 w-6 animate-spin text-muted-foreground" />
<div className="flex items-center justify-center py-10">
<Loader2 className="text-muted-foreground h-6 w-6 animate-spin" />
</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 className="text-muted-foreground flex flex-col items-center justify-center py-10">
<Users className="mb-2 h-8 w-8 opacity-50" />
<p className="text-sm">{t('chat.dialog.noUsers')}</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 className="space-y-0.5">
{persons.map((person) => {
const selected = tempVirtualConfig.personId === person.person_id
const display = person.nickname || person.person_name
return (
<button
key={person.person_id}
type="button"
onClick={() => onSelectPerson(person)}
className={cn(
'flex w-full items-center gap-3 rounded-md p-2 text-left transition-colors',
selected
? 'bg-primary/12 text-foreground'
: 'hover:bg-muted/70'
)}
>
<Avatar className="h-9 w-9 shrink-0 ring-1 ring-border/60">
<AvatarFallback
className={cn(
'text-xs font-semibold',
selected
? 'bg-primary-gradient text-primary-foreground'
: 'bg-muted text-foreground'
)}
>
{(display || '?').charAt(0).toUpperCase()}
</AvatarFallback>
</Avatar>
<div className="min-w-0 flex-1">
<div className="flex items-center gap-1.5">
<span className="truncate text-sm font-medium">{display}</span>
{person.is_known && (
<span className="bg-emerald-500/15 text-emerald-600 dark:text-emerald-400 rounded-full px-1.5 py-0.5 text-[10px] font-medium">
{t('chat.dialog.knownUserSuffix').replace(/^\s*·\s*/, '')}
</span>
)}
</div>
<div className="text-muted-foreground truncate font-mono text-[11px]">
{person.user_id}
</div>
</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>
))}
</button>
)
})}
</div>
)}
</div>
@@ -174,32 +190,32 @@ export function VirtualIdentityDialog({
{/* 虚拟群名配置 */}
{tempVirtualConfig.personId && (
<div className="space-y-2">
<Label></Label>
<Label>{t('chat.dialog.groupName')}</Label>
<Input
placeholder="WebUI虚拟群聊"
placeholder={t('chat.virtualGroupFallback')}
value={tempVirtualConfig.groupName}
onChange={(e) => setTempVirtualConfig(prev => ({
...prev,
groupName: e.target.value
}))}
onChange={(e) =>
setTempVirtualConfig((prev) => ({
...prev,
groupName: e.target.value,
}))
}
/>
<p className="text-xs text-muted-foreground">
</p>
<p className="text-muted-foreground text-xs">{t('chat.dialog.groupNameHint')}</p>
</div>
)}
</DialogBody>
<DialogFooter className="gap-2 sm:gap-0">
<Button variant="outline" onClick={() => onOpenChange(false)}>
{t('chat.actions.cancel')}
</Button>
<Button
data-dialog-action="confirm"
onClick={onCreateVirtualTab}
disabled={!tempVirtualConfig.platform || !tempVirtualConfig.personId}
>
{t('chat.dialog.create')}
</Button>
</DialogFooter>
</DialogContent>

File diff suppressed because it is too large Load Diff

View File

@@ -313,6 +313,7 @@ interface PersonalityFormProps {
export function PersonalityForm({ config, onChange }: PersonalityFormProps) {
const { t } = useTranslation()
const multipleReplyStyleText = config.multiple_reply_style.join('\n')
return (
<div className="space-y-6">
@@ -345,48 +346,49 @@ export function PersonalityForm({ config, onChange }: PersonalityFormProps) {
</div>
<div className="space-y-3">
<Label htmlFor="interest">{t('setupPage.forms.personality.interest.label')}</Label>
<Textarea
id="interest"
placeholder={t('setupPage.forms.personality.interest.placeholder')}
value={config.interest}
onChange={(e) => onChange({ ...config, interest: e.target.value })}
rows={2}
/>
<p className="text-muted-foreground text-xs">
{t('setupPage.forms.personality.interest.description')}
</p>
</div>
<Separator />
<div className="space-y-3">
<Label htmlFor="plan_style">{t('setupPage.forms.personality.planStyle.label')}</Label>
<Textarea
id="plan_style"
placeholder={t('setupPage.forms.personality.planStyle.placeholder')}
value={config.plan_style}
onChange={(e) => onChange({ ...config, plan_style: e.target.value })}
rows={4}
/>
<p className="text-muted-foreground text-xs">
{t('setupPage.forms.personality.planStyle.description')}
</p>
</div>
<div className="space-y-3">
<Label htmlFor="private_plan_style">
{t('setupPage.forms.personality.privatePlanStyle.label')}
<Label htmlFor="multiple_reply_style">
{t('setupPage.forms.personality.multipleReplyStyle.label')}
</Label>
<Textarea
id="private_plan_style"
placeholder={t('setupPage.forms.personality.privatePlanStyle.placeholder')}
value={config.private_plan_style}
onChange={(e) => onChange({ ...config, private_plan_style: e.target.value })}
rows={3}
id="multiple_reply_style"
placeholder={t('setupPage.forms.personality.multipleReplyStyle.placeholder')}
value={multipleReplyStyleText}
onChange={(e) =>
onChange({
...config,
multiple_reply_style: e.target.value
.split('\n')
.map((style) => style.trim())
.filter(Boolean),
})
}
rows={5}
/>
<p className="text-muted-foreground text-xs">
{t('setupPage.forms.personality.privatePlanStyle.description')}
{t('setupPage.forms.personality.multipleReplyStyle.description')}
</p>
</div>
<div className="space-y-3">
<div className="flex items-center justify-between">
<Label htmlFor="multiple_probability">
{t('setupPage.forms.personality.multipleProbability.label')}
</Label>
<span className="text-muted-foreground text-sm">
{(config.multiple_probability * 100).toFixed(0)}%
</span>
</div>
<Input
id="multiple_probability"
type="range"
min="0"
max="1"
step="0.1"
value={config.multiple_probability}
onChange={(e) => onChange({ ...config, multiple_probability: Number(e.target.value) })}
/>
<p className="text-muted-foreground text-xs">
{t('setupPage.forms.personality.multipleProbability.description')}
</p>
</div>
</div>
@@ -405,23 +407,17 @@ export function EmojiForm({ config, onChange }: EmojiFormProps) {
return (
<div className="space-y-6">
<div className="space-y-3">
<div className="flex items-center justify-between">
<Label htmlFor="emoji_chance">{t('setupPage.forms.emoji.emojiChance.label')}</Label>
<span className="text-muted-foreground text-sm">
{(config.emoji_chance * 100).toFixed(0)}%
</span>
</div>
<Label htmlFor="emoji_send_num">{t('setupPage.forms.emoji.emojiSendNum.label')}</Label>
<Input
id="emoji_chance"
type="range"
min="0"
max="1"
step="0.1"
value={config.emoji_chance}
onChange={(e) => onChange({ ...config, emoji_chance: Number(e.target.value) })}
id="emoji_send_num"
type="number"
min="1"
max="64"
value={config.emoji_send_num}
onChange={(e) => onChange({ ...config, emoji_send_num: Number(e.target.value) })}
/>
<p className="text-muted-foreground text-xs">
{t('setupPage.forms.emoji.emojiChance.description')}
{t('setupPage.forms.emoji.emojiSendNum.description')}
</p>
</div>
@@ -532,22 +528,6 @@ export function OtherBasicForm({ config, onChange }: OtherBasicFormProps) {
return (
<div className="space-y-6">
<div className="flex items-center justify-between">
<div className="space-y-1">
<Label htmlFor="enable_tool">{t('setupPage.forms.other.enableTool.label')}</Label>
<p className="text-muted-foreground text-xs">
{t('setupPage.forms.other.enableTool.description')}
</p>
</div>
<Switch
id="enable_tool"
checked={config.enable_tool}
onCheckedChange={(checked) => onChange({ ...config, enable_tool: checked })}
/>
</div>
<Separator />
<div className="flex items-center justify-between">
<div className="space-y-1">
<Label htmlFor="all_global">{t('setupPage.forms.other.allGlobal.label')}</Label>

View File

@@ -51,9 +51,8 @@ export async function loadPersonalityConfig(): Promise<PersonalityConfig> {
return {
personality: personalityConfig.personality || '',
reply_style: personalityConfig.reply_style || '',
interest: personalityConfig.interest || '',
plan_style: personalityConfig.plan_style || '',
private_plan_style: personalityConfig.private_plan_style || '',
multiple_reply_style: personalityConfig.multiple_reply_style || [],
multiple_probability: personalityConfig.multiple_probability ?? 0.2,
}
}
@@ -71,8 +70,8 @@ export async function loadEmojiConfig(): Promise<EmojiConfig> {
const emojiConfig = (data.config.emoji || {}) as Partial<EmojiConfig>
return {
emoji_chance: emojiConfig.emoji_chance ?? 0.4,
max_reg_num: emojiConfig.max_reg_num ?? 40,
emoji_send_num: emojiConfig.emoji_send_num ?? 25,
max_reg_num: emojiConfig.max_reg_num ?? 64,
do_replace: emojiConfig.do_replace ?? true,
check_interval: emojiConfig.check_interval ?? 10,
steal_emoji: emojiConfig.steal_emoji ?? true,
@@ -90,18 +89,15 @@ export async function loadOtherBasicConfig(): Promise<OtherBasicConfig> {
const result = await parseResponse<{
config: {
tool?: { enable_tool?: boolean }
expression?: { all_global_jargon?: boolean }
}
}>(response)
const data = throwIfError(result)
const config = data.config
const toolConfig = config.tool || {}
const expressionConfig = config.expression || {}
return {
enable_tool: toolConfig.enable_tool ?? true,
all_global: expressionConfig.all_global_jargon ?? true,
}
}
@@ -169,38 +165,16 @@ export async function saveEmojiConfig(config: EmojiConfig) {
return throwIfError(result)
}
// 保存其他基础配置(工具、情绪、黑话)
// 保存其他基础配置(黑话)
export async function saveOtherBasicConfig(config: OtherBasicConfig) {
// 需要分别保存到不同的section
const promises = []
const response = await fetchWithAuth('/api/webui/config/bot/section/expression', {
method: 'POST',
headers: getAuthHeaders(),
body: JSON.stringify({ all_global_jargon: config.all_global }),
})
// 保存tool配置
promises.push(
fetchWithAuth('/api/webui/config/bot/section/tool', {
method: 'POST',
headers: getAuthHeaders(),
body: JSON.stringify({ enable_tool: config.enable_tool }),
})
)
// 保存expression配置中的all_global_jargon
promises.push(
fetchWithAuth('/api/webui/config/bot/section/expression', {
method: 'POST',
headers: getAuthHeaders(),
body: JSON.stringify({ all_global_jargon: config.all_global }),
})
)
const results = await Promise.all(promises)
// 检查所有请求是否成功
for (const response of results) {
const result = await parseResponse(response)
throwIfError(result)
}
return { success: true }
const result = await parseResponse(response)
return throwIfError(result)
}
// 保存硅基流动API配置

View File

@@ -95,13 +95,17 @@ function SetupPageContent() {
const createDefaultPersonalityConfig = (): PersonalityConfig => ({
personality: t('setupPage.defaults.personality.personality'),
reply_style: t('setupPage.defaults.personality.replyStyle'),
interest: t('setupPage.defaults.personality.interest'),
plan_style: t('setupPage.defaults.personality.planStyle'),
private_plan_style: t('setupPage.defaults.personality.privatePlanStyle'),
multiple_reply_style: [
t('setupPage.defaults.personality.multipleReplyStyles.plain'),
t('setupPage.defaults.personality.multipleReplyStyles.shortText'),
t('setupPage.defaults.personality.multipleReplyStyles.shortSymbol'),
t('setupPage.defaults.personality.multipleReplyStyles.translation'),
],
multiple_probability: 0.2,
})
const createDefaultEmojiConfig = (): EmojiConfig => ({
emoji_chance: 0.4,
max_reg_num: 40,
emoji_send_num: 25,
max_reg_num: 64,
do_replace: true,
check_interval: 10,
steal_emoji: true,
@@ -132,7 +136,6 @@ function SetupPageContent() {
// 步骤4其他基础配置
const [otherBasic, setOtherBasic] = useState<OtherBasicConfig>({
enable_tool: true,
all_global: true,
})

View File

@@ -20,14 +20,13 @@ export interface BotBasicConfig {
export interface PersonalityConfig {
personality: string
reply_style: string
interest: string
plan_style: string
private_plan_style: string
multiple_reply_style: string[]
multiple_probability: number
}
// 步骤3表情包配置
export interface EmojiConfig {
emoji_chance: number
emoji_send_num: number
max_reg_num: number
do_replace: boolean
check_interval: number
@@ -38,7 +37,6 @@ export interface EmojiConfig {
// 步骤4其他基础配置
export interface OtherBasicConfig {
enable_tool: boolean
all_global: boolean // 全局黑话模式expression.all_global_jargon
}