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:
72
dashboard/src/routes/chat/ChatComposer.tsx
Normal file
72
dashboard/src/routes/chat/ChatComposer.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
124
dashboard/src/routes/chat/ChatHeaderBar.tsx
Normal file
124
dashboard/src/routes/chat/ChatHeaderBar.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
|
||||
265
dashboard/src/routes/chat/ChatWorkspaceSidebar.tsx
Normal file
265
dashboard/src/routes/chat/ChatWorkspaceSidebar.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
227
dashboard/src/routes/chat/MessageList.tsx
Normal file
227
dashboard/src/routes/chat/MessageList.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
@@ -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>
|
||||
}
|
||||
|
||||
@@ -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
@@ -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>
|
||||
|
||||
@@ -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配置
|
||||
|
||||
@@ -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,
|
||||
})
|
||||
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user