251 lines
8.2 KiB
TypeScript
251 lines
8.2 KiB
TypeScript
import React, { useRef, useCallback, useMemo, useState } from 'react'
|
|
import { FolderOpen, Share2, Loader2 } from 'lucide-react'
|
|
import { Button } from '@/components/ui/button'
|
|
import { cn } from '@/utils/cn'
|
|
import { MessageList, type MessageListRef } from '../message-list'
|
|
import { TaskPanel } from '../task-panel'
|
|
import { NovaKitProvider } from '../context/NovaKitProvider'
|
|
import { useNovaChatLogic } from '../hooks/useNovaChatLogic'
|
|
import { ChatInputArea } from './ChatInputArea'
|
|
import type { PlatformConfig, ApiEvent } from '../hooks/useNovaEvents'
|
|
import { toast } from 'sonner'
|
|
import { request } from '@/http/request'
|
|
|
|
// 导出类型供外部使用
|
|
export type { ApiEvent, PlatformConfig }
|
|
|
|
export interface NovaChatProps {
|
|
/** 模式 */
|
|
mode?: 'chat' | 'share'
|
|
/** 是否显示文件面板 */
|
|
panelMode?: 'sidebar' | 'dialog'
|
|
/** 会话 ID */
|
|
conversationId?: string
|
|
/** 代理 ID */
|
|
agentId?: string
|
|
/** 侧边面板宽度 */
|
|
panelWidth?: number | string
|
|
/** 是否正在加载 */
|
|
loading?: boolean
|
|
/** 输入框占位符 */
|
|
placeholder?: string
|
|
/** 是否禁用输入 */
|
|
disabled?: boolean
|
|
/** 空状态渲染 */
|
|
emptyRender?: React.ReactNode
|
|
/** 自定义类名 */
|
|
className?: string
|
|
|
|
// useNovaEvents 相关配置
|
|
/** 平台配置(如果提供,则自动建立 WebSocket 连接和获取历史记录) */
|
|
platformConfig?: PlatformConfig
|
|
/** WebSocket 重连次数限制,默认 3 */
|
|
reconnectLimit?: number
|
|
/** WebSocket 重连间隔(毫秒),默认 3000 */
|
|
reconnectInterval?: number
|
|
/** 获取认证 Token */
|
|
getToken?: () => string | undefined
|
|
/** 获取租户 ID */
|
|
getTenantId?: () => string | undefined
|
|
/** 新事件回调 */
|
|
onEvent?: (event: ApiEvent) => void
|
|
/** 连接状态变化回调 */
|
|
onConnectionChange?: (connected: boolean) => void
|
|
/** 错误回调 */
|
|
onError?: (error: Error) => void
|
|
}
|
|
|
|
/**
|
|
* Nova 聊天组件 - 整合消息列表、输入框和文件面板
|
|
*/
|
|
function InnerNovaChat({
|
|
mode = 'chat',
|
|
panelMode = 'sidebar',
|
|
conversationId,
|
|
agentId,
|
|
panelWidth = '50%',
|
|
placeholder,
|
|
disabled = false,
|
|
emptyRender,
|
|
className,
|
|
platformConfig,
|
|
reconnectLimit = 3,
|
|
reconnectInterval = 3000,
|
|
getToken,
|
|
getTenantId,
|
|
onEvent,
|
|
onConnectionChange,
|
|
onError,
|
|
}: NovaChatProps) {
|
|
const messageListRef = useRef<MessageListRef>(null)
|
|
|
|
// 使用主业务逻辑 Hook
|
|
const {
|
|
messages,
|
|
loading,
|
|
taskStatus,
|
|
artifacts,
|
|
hasArtifacts,
|
|
panelVisible,
|
|
selectedAttachment,
|
|
handleSend,
|
|
handlePanelToggle,
|
|
handlePanelClose,
|
|
handleAttachmentClick,
|
|
handleImageAttachmentClick,
|
|
handleToolCallClick,
|
|
setLoading,
|
|
api,
|
|
} = useNovaChatLogic({
|
|
conversationId,
|
|
agentId,
|
|
platformConfig,
|
|
reconnectLimit,
|
|
reconnectInterval,
|
|
getToken,
|
|
getTenantId,
|
|
onEvent,
|
|
onConnectionChange,
|
|
onError,
|
|
mode
|
|
})
|
|
|
|
const [shareLoading, setShareLoading] = useState(false)
|
|
|
|
// 包装 handleSend 以添加滚动逻辑
|
|
const handleSendWithScroll = useCallback(
|
|
(payload: Parameters<typeof handleSend>[0]) => {
|
|
handleSend(payload)
|
|
|
|
// 延迟滚动到底部,确保消息已添加到列表中
|
|
setTimeout(() => {
|
|
messageListRef.current?.scrollToBottom('smooth')
|
|
}, 100)
|
|
},
|
|
[handleSend]
|
|
)
|
|
|
|
const providerAgentId = agentId ?? platformConfig?.agentId ?? ''
|
|
|
|
const shareUrl = useMemo(() => {
|
|
if (!conversationId || !providerAgentId) return ''
|
|
return `${window.location.origin}/share?conversationId=${encodeURIComponent(conversationId)}&agentId=${encodeURIComponent(providerAgentId)}`
|
|
}, [conversationId, providerAgentId])
|
|
|
|
return (
|
|
<NovaKitProvider panelMode={panelMode} conversationId={conversationId} api={api} agentName={platformConfig?.agentName || 'Autonomous Agent'} agentId={providerAgentId} setLoading={setLoading} loading={loading} mode={mode}>
|
|
<div className={cn('flex h-full w-full overflow-hidden text-foreground bg-white', className)}>
|
|
{/* 主聊天区域 */}
|
|
<div className="relative flex flex-col h-full min-w-0 w-full flex-1">
|
|
{/* 头部通栏:白色磨砂,图标黑色 */}
|
|
<div className="shrink-0 z-20 flex items-center justify-end px-4 py-2 bg-white">
|
|
{hasArtifacts && (
|
|
<Button
|
|
type="button"
|
|
onClick={handlePanelToggle}
|
|
title={panelVisible ? '关闭文件面板' : '打开文件面板'}
|
|
variant="outline"
|
|
size="icon"
|
|
className={cn(
|
|
'w-8 h-8 rounded-xl',
|
|
'border-none bg-transparent',
|
|
'shadow-none flex items-center justify-center',
|
|
'text-gray-800 hover:text-black hover:scale-105 transition-all duration-200 hover:bg-gray-100'
|
|
)}
|
|
>
|
|
<FolderOpen className="w-4 h-4" />
|
|
</Button>
|
|
)}
|
|
|
|
{mode === 'chat' && (
|
|
<Button
|
|
type="button"
|
|
variant="outline"
|
|
size="icon"
|
|
disabled={!shareUrl || shareLoading}
|
|
title={shareUrl ? '复制分享链接' : '缺少会话或 Agent 信息,无法分享'}
|
|
className={cn(
|
|
'w-8 h-8 rounded-xl',
|
|
'border-none bg-transparent',
|
|
'shadow-none flex items-center justify-center',
|
|
'text-gray-800 hover:text-black hover:scale-105 transition-all duration-200 hover:bg-gray-100'
|
|
)}
|
|
onClick={async () => {
|
|
if (!shareUrl || shareLoading) return
|
|
|
|
setShareLoading(true)
|
|
try {
|
|
await request.post('/v1/super_agent/chat/update_public', {
|
|
conversation_id: conversationId,
|
|
is_public: true,
|
|
})
|
|
|
|
try {
|
|
await navigator.clipboard?.writeText?.(shareUrl)
|
|
toast.success('分享链接已复制到剪贴板')
|
|
} catch {
|
|
window.open(shareUrl, '_blank', 'noopener,noreferrer')
|
|
}
|
|
} catch {
|
|
toast.error('分享链接更新失败')
|
|
} finally {
|
|
setShareLoading(false)
|
|
}
|
|
}}
|
|
>
|
|
{shareLoading ? (
|
|
<Loader2 className="w-4 h-4 animate-spin" />
|
|
) : (
|
|
<Share2 className="w-6 h-6" />
|
|
)}
|
|
</Button>
|
|
)}
|
|
</div>
|
|
|
|
{/* 消息列表 - 铺满全部高度,消息可滚动到输入框下方 */}
|
|
<MessageList
|
|
ref={messageListRef}
|
|
messages={messages}
|
|
taskStatus={taskStatus}
|
|
loading={loading}
|
|
emptyRender={emptyRender}
|
|
onAttachmentClick={handleAttachmentClick}
|
|
onImageAttachmentClick={handleImageAttachmentClick}
|
|
onToolCallClick={handleToolCallClick}
|
|
onSendMessage={content => handleSendWithScroll({ content })}
|
|
/>
|
|
|
|
{/* 输入区域 - absolute 固定在底部,磨砂透明,消息可穿过 */}
|
|
{mode === 'chat' && (
|
|
<div className="absolute bottom-0 left-0 right-0 z-10">
|
|
<ChatInputArea
|
|
placeholder={placeholder}
|
|
disabled={disabled}
|
|
taskStatus={taskStatus}
|
|
onSend={handleSendWithScroll}
|
|
hasArtifacts={hasArtifacts}
|
|
panelVisible={panelVisible}
|
|
onPanelToggle={handlePanelToggle}
|
|
/>
|
|
</div>
|
|
)}
|
|
</div>
|
|
|
|
{/* 文件面板:支持 sidebar 与 dialog 两种模式 */}
|
|
{(hasArtifacts || selectedAttachment) && panelVisible && (
|
|
<TaskPanel
|
|
artifacts={selectedAttachment ? [selectedAttachment] : artifacts}
|
|
visible={panelVisible}
|
|
width={panelWidth}
|
|
onClose={handlePanelClose}
|
|
initialSelected={selectedAttachment}
|
|
/>
|
|
)}
|
|
</div>
|
|
</NovaKitProvider>
|
|
)
|
|
}
|
|
|
|
export const NovaChat = React.memo(InnerNovaChat)
|
|
export default NovaChat
|