初始化模版工程

This commit is contained in:
Cloud Bot
2026-03-20 07:33:46 +00:00
commit 23717e0ecd
386 changed files with 51675 additions and 0 deletions

View File

@@ -0,0 +1,33 @@
import { describe, it, expect } from 'vitest'
import { render, screen } from '@testing-library/react'
import { ChatHeader } from './ChatHeader'
describe('ChatHeader', () => {
it('returns null when no header provided', () => {
const { container } = render(<ChatHeader />)
expect(container.firstChild).toBeNull()
})
it('renders string header as CardTitle', () => {
render(<ChatHeader header="Test Title" />)
expect(screen.getByText('Test Title')).toBeInTheDocument()
})
it('renders custom React node header', () => {
render(<ChatHeader header={<span data-testid="custom">Custom Header</span>} />)
expect(screen.getByTestId('custom')).toBeInTheDocument()
expect(screen.getByText('Custom Header')).toBeInTheDocument()
})
it('renders string header inside px-6 container', () => {
render(<ChatHeader header="Title" />)
const title = screen.getByText('Title')
expect(title.closest('.px-6')).not.toBeNull()
})
it('renders custom header inside px-4 container', () => {
render(<ChatHeader header={<div>Custom</div>} />)
const custom = screen.getByText('Custom')
expect(custom.closest('.px-4')).not.toBeNull()
})
})

View File

@@ -0,0 +1,27 @@
import React from 'react'
import { Card, CardHeader, CardTitle } from '@/components/ui/card'
export interface ChatHeaderProps {
/** 头部内容:可以是字符串标题或自定义 React 节点 */
header?: React.ReactNode
}
export function ChatHeader({ header }: ChatHeaderProps) {
if (!header) return null
return (
<Card className="rounded-none border-x-0 border-t-0">
<CardHeader className="h-[50px] p-0 justify-center">
{typeof header === 'string' ? (
<div className="px-6 flex items-center h-full">
<CardTitle className="text-base">{header}</CardTitle>
</div>
) : (
<div className="px-4 py-3 flex items-center h-full">
<h1 className="text-lg font-semibold">{header}</h1>
</div>
)}
</CardHeader>
</Card>
)
}

View File

@@ -0,0 +1,41 @@
import { MessageInput } from '../message-input'
import type { SendMessagePayload, TaskStatus } from '../types'
export interface ChatInputAreaProps {
/** 输入框占位符 */
placeholder?: string
/** 是否禁用 */
disabled?: boolean
/** 任务状态 */
taskStatus?: TaskStatus
/** 发送消息回调 */
onSend: (payload: SendMessagePayload) => void
/** 是否有工件(决定是否显示面板切换按钮) */
hasArtifacts: boolean
/** 面板是否可见 */
panelVisible: boolean
/** 面板切换回调 */
onPanelToggle: () => void
}
export function ChatInputArea({
placeholder,
disabled,
taskStatus,
onSend,
}: ChatInputAreaProps) {
return (
<div className="mt-2 shrink-0 px-6 pb-5">
<div className="mx-auto flex items-end gap-3">
<div className="flex-1">
<MessageInput
placeholder={placeholder}
disabled={disabled}
taskStatus={taskStatus}
onSend={onSend}
/>
</div>
</div>
</div>
)
}

View File

@@ -0,0 +1,250 @@
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