初始化模版工程

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,10 @@
// Nova 事件管理 Hook
export { useNovaEvents, ReadyState } from './useNovaEvents'
export { useBuildConversationConnect } from './useBuildConversationConnect'
export type { PlatformConfig } from './useNovaEvents'
// 元素尺寸监听 Hook
export { useSize } from './useSize'
// Re-export API types for convenience
export type { ApiEvent } from '..'

View File

@@ -0,0 +1,117 @@
import { useMemo } from 'react'
import type { ApiEvent } from '../types'
import type { TaskArtifact, TaskStatus } from '../types'
import { TaskStatus as Status } from '../types'
/**
* 从事件中提取 artifacts 和 taskStatus
*/
export function useArtifactsExtractor(rawEvents: ApiEvent[]) {
return useMemo(() => {
const extractedArtifacts: TaskArtifact[] = []
const artifactKeys = new Set<string>() // 用于去重
let status: TaskStatus = Status.PENDING
for (const event of rawEvents) {
const content = event.content as Record<string, unknown> | undefined
const isUserInput = event.event_type === 'user_input' || event.role === 'user'
if (!content) continue
// 1. 提取 attachments
if (content.attachments) {
const attachments = Array.isArray(content.attachments)
? content.attachments
: [content.attachments]
for (const att of attachments) {
if (att?.file_name) {
const key = att.file_url || att.file_id || att.path || att.file_name
if (!artifactKeys.has(key)) {
artifactKeys.add(key)
extractedArtifacts.push({
path: att.path || att.file_url || att.file_id || '',
file_name: att.file_name,
file_type: att.file_type || att.file_name.split('.').pop() || '',
url: att.file_url,
from: isUserInput ? 'user' : 'assistant'
})
}
}
}
}
// 2. 提取 attachment_files
if (content.attachment_files && Array.isArray(content.attachment_files)) {
for (const file of content.attachment_files) {
if (file?.file_name) {
const key = file.url || file.path || file.file_name
if (!artifactKeys.has(key)) {
artifactKeys.add(key)
extractedArtifacts.push({
path: file.path || file.url || '',
file_name: file.file_name,
file_type: file.file_type || file.file_name.split('.').pop() || '',
url: file.url,
from: isUserInput ? 'user' : 'assistant'
})
}
}
}
}
// 3. 提取 generated_files (如 slide_create_in_batches 的输出)
const toolOutput = content.tool_output as Record<string, unknown> | undefined
if (toolOutput?.generated_files && Array.isArray(toolOutput.generated_files)) {
for (const file of toolOutput.generated_files) {
if (file?.index !== undefined && file?.content) {
const fileName = `slide_${file.index}.html`
const key = `generated_${event.event_id}_${file.index}`
if (!artifactKeys.has(key)) {
artifactKeys.add(key)
extractedArtifacts.push({
path: key,
file_name: fileName,
file_type: 'html',
event_type: 'generated_file',
tool_output: file.content,
from: isUserInput ? 'user' : 'assistant'
})
}
}
}
}
// 4. 提取 files (其他可能的文件字段)
if (content.files && Array.isArray(content.files)) {
for (const file of content.files) {
if (file?.name || file?.file_name) {
const fileName = file.name || file.file_name
const key = file.url || file.path || file.id || fileName
if (!artifactKeys.has(key)) {
artifactKeys.add(key)
extractedArtifacts.push({
path: file.path || file.url || file.id || '',
file_name: fileName,
file_type: file.type || file.file_type || fileName.split('.').pop() || '',
url: file.url,
from: isUserInput ? 'user' : 'assistant'
})
}
}
}
}
// 提取 taskStatus
const eventStatus = event.event_status as string | undefined
if (eventStatus === 'running' || eventStatus === 'in_progress') {
status = Status.IN_PROGRESS
} else if (eventStatus === 'success' || eventStatus === 'completed') {
status = Status.COMPLETED
} else if (eventStatus === 'failed' || eventStatus === 'error') {
status = Status.FAILED
}
}
return { artifacts: extractedArtifacts, taskStatus: status }
}, [rawEvents])
}

View File

@@ -0,0 +1,113 @@
import { useCallback } from 'react'
import type { Attachment, HandleImageAttachmentClick, TaskArtifact } from '../types'
import type { ApiEvent } from './useNovaEvents'
import { extractToolOutputArtifact } from '../task-panel/Preview/previewUtils'
/**
* 附件处理逻辑 Hook
*/
export function useAttachmentHandlers(onSelectAttachment: (artifact: TaskArtifact) => void) {
// 处理附件点击
const handleAttachmentClick = useCallback(
(attachment: Attachment) => {
const artifact: TaskArtifact = {
path: attachment.path || attachment.file_url || attachment.file_id || '',
file_name: attachment.file_name,
file_type: attachment.file_type,
url: attachment.file_url,
}
onSelectAttachment(artifact)
},
[onSelectAttachment]
)
// 处理图片附件点击
const handleImageAttachmentClick = useCallback<HandleImageAttachmentClick>(
(image, from = 'assistant') => {
const getFileExtension = (str?: string): string => {
if (!str) return 'jpg'
const parts = str.split('.')
const ext = parts.length > 1 ? parts[parts.length - 1].toLowerCase().replace(/\?.*$/, '') : ''
return ext || 'jpg'
}
const fileType =
getFileExtension(image.file_name) || getFileExtension(image.path) || getFileExtension(image.url) || 'jpg'
const artifact: TaskArtifact = {
path: image.path || image.url || '',
file_name: image.file_name || '图片',
file_type: fileType,
url: image.url,
from,
}
onSelectAttachment(artifact)
},
[onSelectAttachment]
)
// 处理工具调用点击
const handleToolCallClick = useCallback(
(event: ApiEvent) => {
const content = event.content as Record<string, unknown> | undefined
const actionType = (content?.action_type as string | undefined) || undefined
const toolName = content?.tool_name as string | undefined
const actionName = content?.action_name as string | undefined
const metaToolName = (content?.metadata as Record<string, unknown> | undefined)?.tool_name as string | undefined
const toLower = (v?: string) => v?.toLowerCase()
const isSkillLoader =
toLower(actionType) === 'skill_loader' ||
toLower(actionName) === 'skill_loader' ||
toLower(toolName) === 'skill_loader' ||
toLower(metaToolName) === 'skill_loader'
const base: TaskArtifact = {
path: event.event_id,
file_name: toolName || '工具调用',
file_type: 'tool_call',
event_type: 'tool_call',
action_type: isSkillLoader
? 'skill_loader'
: actionType || actionName || toolName,
tool_name: toolName,
event_arguments: content?.arguments,
tool_input: content?.tool_input,
tool_output: content?.tool_output,
}
// Skill Loader点击时按 Markdown 文档渲染
if (isSkillLoader) {
const output = content?.tool_output
const mdContent =
typeof output === 'string'
? output
: output != null
? JSON.stringify(output, null, 2)
: ''
onSelectAttachment({
...base,
file_type: 'md',
content: mdContent,
})
return
}
const outputArtifact = extractToolOutputArtifact(base)
if (outputArtifact) {
onSelectAttachment(outputArtifact)
return
}
onSelectAttachment(base)
},
[onSelectAttachment]
)
return {
handleAttachmentClick,
handleImageAttachmentClick,
handleToolCallClick,
}
}

View File

@@ -0,0 +1,93 @@
'use client'
import { request } from '@/http/request'
import { useCallback, useEffect, useState } from 'react';
export enum NovaState {
Pending,
Connected,
Failed,
}
type UrlOverrides = {
conversationId?: string
agentId?: string
}
type ConversationIdentifiers = {
agentId?: string
conversationId?: string
}
const parseUrlOverrides = (search: string): UrlOverrides => {
const params = new URLSearchParams(search)
return {
conversationId: params.get('conversationId') || undefined,
agentId: params.get('agentId') || undefined,
}
}
const resolveConversationIdentifiers = (
res: { agent_id: string; conversation_id: string },
overrides: UrlOverrides,
): ConversationIdentifiers => {
const { conversationId: urlConversationId, agentId: urlAgentId } = overrides
return {
agentId: urlAgentId ?? res?.agent_id,
conversationId: urlConversationId ?? res?.conversation_id,
}
}
export const useBuildConversationConnect = () => {
const [chatEnabled, setChatEnabled] = useState<NovaState>(NovaState.Pending)
const [agentId, setAgentId] = useState<string>()
const [conversationId, setConversationId] = useState<string>('')
const [platformConfig, setPlatformConfig] = useState({
wssUrl: '',
apiBaseUrl: '',
token: '',
tenantId: '',
agentId: '',
agentName: '',
})
const connect = useCallback(async () => {
try {
const res = await request.get('/info')
const search =
typeof window === 'undefined' ? '' : window.location.search
const urlOverrides = parseUrlOverrides(search)
const { agentId: finalAgentId, conversationId: finalConversationId } =
resolveConversationIdentifiers(res, urlOverrides)
setAgentId(finalAgentId)
setConversationId(finalConversationId ?? '')
setPlatformConfig({
wssUrl: res?.wssUrl,
apiBaseUrl: res?.apiBaseUrl,
token: res?.token,
tenantId: res?.tenantId,
agentId: finalAgentId ?? '',
agentName: res?.agent_name ?? '',
})
setChatEnabled(NovaState.Connected)
} catch {
setChatEnabled(NovaState.Failed)
}
}, [])
useEffect(() => {
connect()
}, [connect])
return {
chatEnabled,
agentId,
conversationId,
platformConfig,
connect,
}
}

View File

@@ -0,0 +1,323 @@
import { useMemo, useCallback } from 'react'
import type { ApiEvent, ExtendedEvent } from '../types'
import { processSlideEvents, attachSlideMetadata } from '../utils/slideEventHelpers'
import { getEventRenderProps } from '../utils/event-render-props'
type TodoListItem = {
id?: string
title?: string
status?: string
[key: string]: unknown
}
function getEventTaskId(event: ApiEvent | ExtendedEvent): string | undefined {
const directKeys = ['task_id', 'plan_id', 'conversation_id'] as const
for (const key of directKeys) {
if (key in event) {
const value = event[key]
if (value !== undefined && value !== null) {
return String(value)
}
}
}
const metadataTaskId =
event.metadata &&
typeof event.metadata === 'object' &&
(('task_id' in event.metadata && event.metadata.task_id) ||
('plan_id' in event.metadata && event.metadata.plan_id) ||
('conversation_id' in event.metadata && event.metadata.conversation_id))
? ((event.metadata.task_id ??
event.metadata.plan_id ??
event.metadata.conversation_id) as string | number | undefined | null)
: undefined
return metadataTaskId !== undefined && metadataTaskId !== null
? String(metadataTaskId)
: undefined
}
function isTodoListEvent(event: ExtendedEvent): boolean {
return (
event.event_type === 'task_update' &&
!!event.renderProps?.taskTodoConfig?.list?.length
)
}
function getTodoItemKey(item: TodoListItem, index: number): string {
if (item.id !== undefined && item.id !== null) {
return `id:${String(item.id)}`
}
if (typeof item.title === 'string' && item.title.trim()) {
return `title:${item.title.trim()}`
}
return `index:${index}`
}
function mergeTodoListItems(group: ExtendedEvent[]): TodoListItem[] {
const itemsByKey = new Map<string, TodoListItem>()
const firstSeenKeys: string[] = []
for (const event of group) {
const items = (event.renderProps?.taskTodoConfig?.list || []) as TodoListItem[]
items.forEach((item, index) => {
const key = getTodoItemKey(item, index)
if (!itemsByKey.has(key)) {
firstSeenKeys.push(key)
}
itemsByKey.set(key, {
...(itemsByKey.get(key) || {}),
...item,
})
})
}
const latestItems = (group[group.length - 1]?.renderProps?.taskTodoConfig?.list ||
[]) as TodoListItem[]
const orderedKeys = Array.from(
new Set([
...latestItems.map((item, index) => getTodoItemKey(item, index)),
...firstSeenKeys,
]),
)
return orderedKeys
.map(key => itemsByKey.get(key))
.filter((item): item is TodoListItem => !!item)
}
function mergeTodoListGroup(group: ExtendedEvent[]): ExtendedEvent {
const lastEvent = group[group.length - 1]
return {
...lastEvent,
renderProps: lastEvent.renderProps
? {
...lastEvent.renderProps,
taskTodoConfig: {
list: mergeTodoListItems(group),
},
}
: lastEvent.renderProps,
}
}
/**
* 将 ApiEvent 转换为 ExtendedEvent并填充 renderProps
*/
export function useEventConverter() {
return useCallback((event: ApiEvent): ExtendedEvent => {
const isUserInput = event.event_type === 'user_input' || event.role === 'user'
const extEvent: ExtendedEvent = {
event_id: event.event_id,
event_type: event.event_type || 'message',
timestamp: event.created_at
? new Date(event.created_at).getTime()
: typeof event.timestamp === 'number'
? event.timestamp
: event.timestamp
? new Date(event.timestamp).getTime()
: Date.now(),
content: {
text: (event.content?.text || event.content?.content || '') as string,
...event.content,
},
metadata: {
...event.metadata,
isUserInput,
task_id: getEventTaskId(event),
role: event.role,
},
event_status: event.event_status,
stream: event.stream,
plan_step_state: event.plan_step_state,
// 填充 renderProps核心将原始事件结构化为渲染所需属性
renderProps: getEventRenderProps(event),
}
return extEvent
}, [])
}
/**
* 处理原始事件数据,去重、合并流式事件,并生成 renderProps
*/
export function useEventProcessor(
rawEvents: ApiEvent[],
) {
const convertEvent = useEventConverter()
return useMemo(() => {
if (!rawEvents || rawEvents.length === 0) {
return [[] as ExtendedEvent[]]
}
// 按 event_id 去重,后来的替换先来的(流式事件也遵循此规则)
const eventsMap = new Map<string, ApiEvent>()
for (const event of rawEvents) {
eventsMap.set(event.event_id, event)
}
// 处理 slide 流式数据
const slideData = processSlideEvents(rawEvents)
// 转换、过滤、附加 slide 元数据
const displayEvents = Array.from(eventsMap.values())
.filter(event => event.is_display !== false)
// 对应 Remix task-event.ts notEmpty过滤 text 类型但内容为空的流式中间状态
.filter(event => {
if (event.event_type !== 'text') return true
const content = event.content?.content
return !(!content || (typeof content === 'string' && !content.trim()))
})
.map(event => {
const extEvent = convertEvent(event)
const slideMetadata = attachSlideMetadata(event, slideData)
if (slideMetadata) {
extEvent.metadata = {
...extEvent.metadata,
slide: slideMetadata,
}
}
return extEvent
})
// 按时间戳升序排列
displayEvents.sort(
(a: ExtendedEvent, b: ExtendedEvent) =>
(a.timestamp || 0) - (b.timestamp || 0),
)
// ─────────────────────────────────────────────
// browser_use 事件合并:
// - 按 tool_call_id 维度将同一次浏览器操作的多条事件合并为一条
// - 保留「最后一个事件」作为展示主体
// - 如果最后一个没有截图,则从整组中向前查找最近一个带截图的 tool_output 补上
// 这样从一开始就只渲染一条 BrowserUseAction不会出现多条拆散的浏览器卡片
// ─────────────────────────────────────────────
const browserGroups = new Map<string, ExtendedEvent[]>()
const nonBrowserEvents: ExtendedEvent[] = []
for (const event of displayEvents) {
const actionType = event.renderProps?.action?.action_type
if (actionType === 'browser_use') {
const toolCallId =
(event.content as any)?.tool_call_id ||
(event.renderProps?.action?.tool_input as any)?.tool_call_id ||
event.event_id
const group = browserGroups.get(toolCallId)
if (group) {
group.push(event)
} else {
browserGroups.set(toolCallId, [event])
}
} else {
nonBrowserEvents.push(event)
}
}
const mergedBrowserEvents: ExtendedEvent[] = []
for (const group of browserGroups.values()) {
if (group.length === 0) continue
const lastEvent = group[group.length - 1]
const lastAction = lastEvent.renderProps?.action
if (lastAction) {
const lastToolOutput = lastAction.tool_output as
| { result?: { clean_screenshot_path?: string; screenshot_path?: string } }
| undefined
const hasScreenshot =
lastToolOutput?.result?.clean_screenshot_path ||
lastToolOutput?.result?.screenshot_path
if (!hasScreenshot) {
// 从该组中找最近一个有截图的 tool_output
for (let j = group.length - 2; j >= 0; j--) {
const candidateOutput = group[j].renderProps?.action?.tool_output as
| { result?: { clean_screenshot_path?: string; screenshot_path?: string } }
| undefined
if (
candidateOutput?.result?.clean_screenshot_path ||
candidateOutput?.result?.screenshot_path
) {
lastEvent.renderProps!.action = {
...lastEvent.renderProps!.action!,
tool_output: candidateOutput,
}
break
}
}
}
}
mergedBrowserEvents.push(lastEvent)
}
const mergedEvents = [...nonBrowserEvents, ...mergedBrowserEvents]
// 合并后再次按时间排序,保证整体对话时间线正确
mergedEvents.sort(
(a: ExtendedEvent, b: ExtendedEvent) =>
(a.timestamp || 0) - (b.timestamp || 0),
)
// ─────────────────────────────────────────────
// TodoList(task_update) 事件合并:
// - 有 task_id 时,按 task_id 聚合同一条任务线的多次 task_update
// - 没有 task_id 时,回退到当前用户轮次,避免无 task_id 的卡片被全局误并
// - 保留最后一个事件作为展示主体
// - Todo 列表按 id/title 增量合并,兼容后续事件只返回局部步骤的情况
// 这样消息列表中只展示一张最新的 Todo 卡片,不会被中间更新刷出多条
// ─────────────────────────────────────────────
const todoGroups = new Map<string, ExtendedEvent[]>()
const nonTodoEvents: ExtendedEvent[] = []
let currentTurnKey = 'conversation-start'
for (const event of mergedEvents) {
if (event.metadata?.isUserInput) {
currentTurnKey = event.event_id
}
if (isTodoListEvent(event)) {
const taskKey = getEventTaskId(event)
const groupKey = taskKey ? `task:${taskKey}` : `${currentTurnKey}:no-task`
const group = todoGroups.get(groupKey)
if (group) {
group.push(event)
} else {
todoGroups.set(groupKey, [event])
}
} else {
nonTodoEvents.push(event)
}
}
const mergedTodoEvents = Array.from(todoGroups.entries()).map(
([, group]) => mergeTodoListGroup(group),
)
const timelineEvents = [...nonTodoEvents, ...mergedTodoEvents]
timelineEvents.sort(
(a: ExtendedEvent, b: ExtendedEvent) =>
(a.timestamp || 0) - (b.timestamp || 0),
)
return [timelineEvents]
}, [rawEvents, convertEvent])
}

View File

@@ -0,0 +1,166 @@
import { useCallback, useRef, useMemo } from 'react'
import type { UploadFile } from '../types'
import { request } from '@/http/request'
import { createCustomOSSUploader } from '@bty/uploader'
import { getSTSToken, getOssSignatureUrl } from '@apis/oss'
// Simple helper to mimic MIME type detection
export const ACCEPT_FILE_TYPE_LIST = [
'.pdf', '.doc', '.docx', '.xls', '.xlsx', '.ppt', '.pptx',
'.txt', '.json', '.csv', '.md',
'.png', '.jpg', '.jpeg', '.gif', '.bmp', '.webp', '.svg', '.ico',
'.html', '.py', '.jsonld', '.xml', '.zip',
'.mp3', '.mp4', '.mov', '.m4a',
'.pdb', '.mermaid',
]
export function getMimeByAcceptList(filename: string): string | undefined {
const ext = `.${(filename.split('.').pop() || '').toLowerCase()}`
const map: Record<string, string> = {
'.pdf': 'application/pdf',
'.doc': 'application/msword',
'.docx': 'application/vnd.openxmlformats-officedocument.wordprocessingml.document',
'.xls': 'application/vnd.ms-excel',
'.xlsx': 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet',
'.ppt': 'application/vnd.ms-powerpoint',
'.pptx': 'application/vnd.openxmlformats-officedocument.presentationml.presentation',
'.txt': 'text/plain',
'.json': 'application/json',
'.csv': 'text/csv',
'.md': 'text/markdown',
'.png': 'image/png',
'.jpg': 'image/jpeg',
'.jpeg': 'image/jpeg',
'.gif': 'image/gif',
'.bmp': 'image/bmp',
'.webp': 'image/webp',
'.svg': 'image/svg+xml',
'.ico': 'image/x-icon',
'.html': 'text/html',
'.jsonld': 'application/ld+json',
'.pdb': 'application/vnd.microsoft.portable-executable',
'.mermaid': 'text/mermaid',
}
return map[ext] || undefined
}
export interface UseFileUploaderProps {
onUploadStart?: (file: UploadFile) => void
onUploadEnd?: (file: UploadFile) => void
onUploadError?: (error: Error, file?: UploadFile) => void
onFileUpdate?: (file: UploadFile) => void
}
export function useFileUploader({
onUploadStart,
onUploadEnd,
onUploadError,
onFileUpdate,
}: UseFileUploaderProps = {}) {
const uploadUUId = useRef<string>('')
// Initialize OSS Uploader with STS token provider from our local package
const ossUploader = useMemo(() => {
return createCustomOSSUploader(getSTSToken)
}, [])
const uploadFile = useCallback(async (file: File) => {
// 1. Validation
const isValidSize = file.size <= 100 * 1024 * 1024 // 100MB
if (!isValidSize) {
console.warn('File size exceeds 100MB limit')
return
}
// 2. Init file object and State
const uid = crypto.randomUUID()
const mimeType = getMimeByAcceptList(file.name) || file.type || 'application/octet-stream'
const tempFile: UploadFile = {
uid,
name: file.name,
type: mimeType,
byte_size: file.size,
uploadStatus: 'pending',
progress: 0,
url: URL.createObjectURL(file),
}
onUploadStart?.(tempFile)
try {
// 3. Construct File Path (Business Logic)
const timestamp = new Date().valueOf()
const uuid = crypto.randomUUID()
uploadUUId.current = uuid
const filePath = `super_agent/user_upload_file/${uuid}/_${timestamp}_${file.name}`
// 4. Upload to OSS using the shared package
await ossUploader.multipartUpload({
filePath,
file,
options: {
headers: {
'Content-Type': mimeType,
'Content-Disposition': 'inline',
},
progress: (progress: number) => {
// OSS SDK returns progress as 0-1
onFileUpdate?.({
...tempFile,
progress: Math.floor(progress * 100),
uploadStatus: 'uploading'
})
}
},
})
// 5. Get Signature URL (Optional / if private)
const signatureUrl = await getOssSignatureUrl(filePath)
// 6. Create File Upload Record (Backend Sync)
const lastDotIndex = file.name.lastIndexOf('.')
const splitName = lastDotIndex !== -1
? [file.name.substring(0, lastDotIndex), file.name.substring(lastDotIndex + 1)]
: [file.name]
const safeName = `${splitName[0]}-${Math.random().toString(36).substring(2, 5)}${splitName.length > 1 ? `.${splitName[1]}` : ''}`
const res = await request.post<{ file_upload_record_id: string }>('/file/record', {
file_url: filePath,
file_type: file.type || 'application/octet-stream',
file_name: safeName,
file_byte_size: file.size || 0,
conversation_id: uuid,
})
// 7. Finalize
const finalFile: UploadFile = {
...tempFile,
name: safeName,
url: signatureUrl,
upload_file_id: res.file_upload_record_id,
progress: 100,
uploadStatus: 'success',
}
onFileUpdate?.(finalFile)
onUploadEnd?.(finalFile)
} catch (error) {
console.error('Upload failed:', error)
const errorFile: UploadFile = {
...tempFile,
uploadStatus: 'error'
}
onFileUpdate?.(errorFile)
onUploadError?.(error as Error, errorFile)
}
}, [ossUploader, onUploadStart, onUploadEnd, onUploadError, onFileUpdate])
return {
uploadFile,
accept: ACCEPT_FILE_TYPE_LIST.join(','),
}
}

View File

@@ -0,0 +1,25 @@
import { useRef, useCallback } from 'react'
import type { MessageListRef } from '../message-list'
/**
* 消息滚动管理 Hook
*/
export function useMessageScroll() {
const messageListRef = useRef<MessageListRef>(null)
const scrollToBottom = useCallback((behavior: ScrollBehavior = 'smooth') => {
messageListRef.current?.scrollToBottom(behavior)
}, [])
const scrollToBottomDelayed = useCallback((delay = 100, behavior: ScrollBehavior = 'smooth') => {
setTimeout(() => {
messageListRef.current?.scrollToBottom(behavior)
}, delay)
}, [])
return {
messageListRef,
scrollToBottom,
scrollToBottomDelayed,
}
}

View File

@@ -0,0 +1,51 @@
import { useState, useCallback } from 'react'
import type { SendMessagePayload } from '../types'
import type { PlatformConfig } from './useNovaEvents'
interface UseMessageSenderProps {
conversationId?: string
platformConfig?: PlatformConfig
sendMessage?: (message: string) => void
agentId?: string
}
export function useMessageSender({
conversationId,
platformConfig,
sendMessage,
agentId,
}: UseMessageSenderProps) {
const [sendingMessage, setSendingMessage] = useState(false)
// Send Message Logic
const handleSend = useCallback(
(payload: SendMessagePayload) => {
// If platformConfig is provided, send via WebSocket
if (platformConfig && sendMessage) {
try {
setSendingMessage(true)
const message = {
message_type: 'chat',
conversation_id: conversationId,
agent_id: agentId,
agent_model: 'Nova Pro',
content: payload.content,
refer_content: payload.refer_content || '',
upload_file_ids: payload.upload_file_ids,
}
sendMessage(JSON.stringify(message))
} catch (error) {
console.error('Failed to send message via WebSocket:', error)
setSendingMessage(false)
}
}
},
[platformConfig, sendMessage, conversationId, agentId]
)
return {
sendingMessage,
setSendingMessage,
handleSend
}
}

View File

@@ -0,0 +1,232 @@
import { useEffect, useMemo, useState } from 'react'
import { useRequest } from 'ahooks'
import { useNovaEvents } from './useNovaEvents'
import { useEventStore } from '../store/useEventStore'
import { useArtifactsExtractor } from './useArtifactsExtractor'
import { useEventProcessor } from './useEventProcessor'
import { usePanelState } from './usePanelState'
import { useAttachmentHandlers } from './useAttachmentHandlers'
import { useMessageSender } from './useMessageSender'
import { useNovaService } from './useNovaService'
import type { ConversationInfo } from './useNovaService'
import { TERMINAL_TASK_STATUS, TaskStatus } from '../types'
import type { PlatformConfig, ApiEvent } from './useNovaEvents'
export interface UseNovaChatLogicProps {
mode: 'chat' | 'share'
conversationId?: string
platformConfig?: PlatformConfig
reconnectLimit?: number
reconnectInterval?: number
agentId?: string
getToken?: () => string | undefined
getTenantId?: () => string | undefined
onEvent?: (event: ApiEvent) => void
onConnectionChange?: (connected: boolean) => void
onError?: (error: Error) => void
}
/**
* Nova Chat 主业务逻辑 Hook
* 采用原子化模块组合模式,提高代码鲁棒性和可维护性
*/
export function useNovaChatLogic({
mode,
conversationId,
platformConfig,
reconnectLimit = 3,
reconnectInterval = 3000,
getToken,
getTenantId,
onEvent,
onConnectionChange,
onError,
agentId,
}: UseNovaChatLogicProps) {
// 1. 核心事件与连接管理
const novaEvents = useNovaEvents({
mode,
conversationId,
platformConfig: platformConfig || { wssUrl: '', apiBaseUrl: '', agentId: '', agentName: '' },
reconnectLimit,
reconnectInterval,
getToken,
getTenantId,
onEvent,
onConnectionChange,
onError,
})
const { rawEvents } = novaEvents
// 2. 数据处理与提取
const { artifacts, taskStatus: eventTaskStatus } = useArtifactsExtractor(rawEvents)
const [polledTaskStatus, setPolledTaskStatus] = useState<string | undefined>()
const [initialTaskStatusLoading, setInitialTaskStatusLoading] = useState(false)
const taskStatus = useMemo(
() => (polledTaskStatus as typeof eventTaskStatus | undefined) || eventTaskStatus,
[polledTaskStatus, eventTaskStatus],
)
const processedMessages = useEventProcessor(rawEvents)
// 3. 面板与附件状态管理
const {
panelVisible,
selectedAttachment,
togglePanel,
closePanel,
selectAttachment
} = usePanelState()
// 4. 附件操作处理器
const {
handleAttachmentClick,
handleImageAttachmentClick,
handleToolCallClick
} = useAttachmentHandlers(selectAttachment)
// 5. 消息发送逻辑
const { sendingMessage, handleSend, setSendingMessage } = useMessageSender({
conversationId,
platformConfig,
sendMessage: novaEvents.sendMessage,
agentId,
})
// 6. 工件服务URL 获取等)
const service = useNovaService({
platformConfig,
getToken,
getTenantId,
conversationId
})
const { getConversationInfoList } = service
// 7. Store 同步(副作用管理)
const setEvents = useEventStore((state) => state.setEvents)
const setArtifacts = useEventStore((state) => state.setArtifacts)
// 8. WS 阶段轮询会话信息ahooks useRequest遇到终止状态时关闭 loading
const terminalSet = new Set(TERMINAL_TASK_STATUS.map((s) => String(s).toLowerCase()))
const getConversationTaskStatus = (
list: ConversationInfo[] | undefined,
currentConversationId?: string,
) => {
if (!currentConversationId) return undefined
return list?.find(
item => item.conversation_id === currentConversationId,
)?.task_status
}
const isTerminalTaskStatus = (status?: string) =>
!!status && terminalSet.has(String(status).toLowerCase())
const shouldPollTaskStatus =
!!conversationId &&
(sendingMessage || taskStatus === TaskStatus.IN_PROGRESS)
const stopChat = async () => {
await service.stopChat()
setSendingMessage(false)
setPolledTaskStatus(TaskStatus.STOPPED)
cancelPoll()
}
const { run: runPoll, cancel: cancelPoll } = useRequest(
(cid: string) =>
getConversationInfoList([cid]).then((res) => res.data ?? []),
{
manual: true,
pollingInterval: shouldPollTaskStatus ? 5000 : undefined,
ready: shouldPollTaskStatus,
onSuccess: (list) => {
const nextTaskStatus = getConversationTaskStatus(list, conversationId)
if (nextTaskStatus) {
setPolledTaskStatus(nextTaskStatus)
}
if (isTerminalTaskStatus(nextTaskStatus)) {
setSendingMessage(false)
cancelPoll()
}
},
}
)
useEffect(() => {
if (shouldPollTaskStatus && conversationId) {
runPoll(conversationId)
} else {
cancelPoll()
}
}, [shouldPollTaskStatus, conversationId, runPoll, cancelPoll])
useEffect(() => {
let cancelled = false
setPolledTaskStatus(undefined)
if (!conversationId) {
setInitialTaskStatusLoading(false)
return
}
setInitialTaskStatusLoading(true)
void getConversationInfoList([conversationId])
.then((res) => {
if (cancelled) return
const nextTaskStatus = getConversationTaskStatus(res.data ?? [], conversationId)
if (nextTaskStatus) {
setPolledTaskStatus(nextTaskStatus)
}
})
.catch(() => {})
.finally(() => {
if (!cancelled) {
setInitialTaskStatusLoading(false)
}
})
return () => {
cancelled = true
}
}, [conversationId, getConversationInfoList])
useEffect(() => {
setEvents(rawEvents)
}, [rawEvents, setEvents])
useEffect(() => {
setArtifacts(artifacts)
}, [artifacts, setArtifacts])
// 9. 统一对外接口
return {
// 状态数据
messages: processedMessages,
loading: sendingMessage || initialTaskStatusLoading,
taskStatus,
artifacts,
hasArtifacts: artifacts.length > 0,
panelVisible,
selectedAttachment,
// 操作方法
handleSend,
handlePanelToggle: togglePanel,
handlePanelClose: closePanel,
handleAttachmentClick,
handleImageAttachmentClick,
handleToolCallClick,
setLoading: setSendingMessage,
agentId,
// 统一的 API 命名空间
api: {
...service,
stopChat,
}
}
}

View File

@@ -0,0 +1,370 @@
import { useState, useEffect, useRef, useCallback } from 'react'
import { createWebSocketClient, ReadyState, type Result as WebSocketResult } from '@/app/api/websocket'
// import { createRequestClient, type RequestClient } from '@/api/request'
import { request } from '@/http/request'
import type { ApiEvent } from '../types'
export type { ApiEvent }
export interface PlatformConfig {
wssUrl: string
apiBaseUrl: string
agentId: string
agentName: string
}
interface UseNovaEventsOptions {
mode: 'chat' | 'share'
conversationId?: string
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
}
interface UseNovaEventsResult {
/** 原始事件列表 */
rawEvents: ApiEvent[]
/** 是否正在加载历史记录 */
loading: boolean
/** WebSocket 是否已连接 */
connected: boolean
/** WebSocket 连接状态 */
readyState: ReadyState
/** 错误信息 */
error: Error | null
/** 手动刷新历史记录 */
refresh: () => Promise<void>
/** 手动重连 WebSocket */
reconnect: () => void
/** 清空事件列表 */
clear: () => void
/** 切换会话 */
switchConversation: (conversationId: string) => void
/** 发送消息 */
sendMessage: WebSocket['send']
}
function isApiEventLike(value: unknown): value is ApiEvent {
return !!value && typeof value === 'object' && 'event_id' in value
}
function normalizeIncomingEvent(payload: unknown): ApiEvent | null {
if (isApiEventLike(payload)) {
return payload
}
if (
payload &&
typeof payload === 'object' &&
'data' in payload &&
isApiEventLike((payload as { data?: unknown }).data)
) {
return (payload as { data: ApiEvent }).data
}
return null
}
/**
* Nova 事件管理 Hook
*
* 负责:
* 1. 建立 WebSocket 连接接收实时事件
* 2. 请求 event_list 接口获取历史记录
* 3. 合并和管理事件列表
*/
export function useNovaEvents({
mode,
conversationId,
platformConfig,
reconnectLimit = 3,
reconnectInterval = 3000,
getToken,
getTenantId,
onEvent,
onConnectionChange,
onError,
}: UseNovaEventsOptions): UseNovaEventsResult {
// 状态
const [rawEvents, setRawEvents] = useState<ApiEvent[]>([])
const [loading, setLoading] = useState(false)
const [connected, setConnected] = useState(false)
const [readyState, setReadyState] = useState<ReadyState>(ReadyState.Closed)
const [error, setError] = useState<Error | null>(null)
// Refs
const wsClientRef = useRef<WebSocketResult | null>(null)
const isUnmountedRef = useRef(false)
const conversationIdRef = useRef(conversationId)
const mountCountRef = useRef(0)
// 保持 conversationId ref 同步
conversationIdRef.current = conversationId
// 回调 refs避免重新创建 WebSocket
const onEventRef = useRef(onEvent)
const onConnectionChangeRef = useRef(onConnectionChange)
const onErrorRef = useRef(onError)
onEventRef.current = onEvent
onConnectionChangeRef.current = onConnectionChange
onErrorRef.current = onError
// 更新连接状态
const updateConnected = useCallback((value: boolean) => {
setConnected(value)
onConnectionChangeRef.current?.(value)
}, [])
// 更新错误状态
const updateError = useCallback((err: Error | null) => {
setError(err)
if (err) {
onErrorRef.current?.(err)
}
}, [])
// 合并事件到列表(按 event_id 去重,新事件替换旧事件)
const mergeEvent = useCallback((newEvent: ApiEvent) => {
setRawEvents(prev => {
const index = prev.findIndex(e => e.event_id === newEvent.event_id)
if (index === -1) {
// 新事件,追加到末尾
return [...prev, newEvent]
}
// 已存在,替换
const updated = [...prev]
updated[index] = newEvent
return updated
})
}, [])
// 批量合并事件(用于历史记录)
const mergeEvents = useCallback((newEvents: ApiEvent[]) => {
setRawEvents((prev) => {
const eventsMap = new Map<string, ApiEvent>()
// 先放入现有事件
for (const event of prev) {
eventsMap.set(event.event_id, event)
}
// 合并新事件(会覆盖同 id 的旧事件)
for (const event of newEvents) {
eventsMap.set(event.event_id, event)
}
return Array.from(eventsMap.values())
})
}, [])
// 获取历史记录
const fetchEventList = useCallback(async () => {
if (!conversationIdRef.current) return
setLoading(true)
updateError(null)
try {
const payload = {
conversation_id: conversationIdRef.current,
page_no: 1,
page_size: 3000,
}
const response = mode === 'share'
? await request.post<{
data: {
chat_event_list: ApiEvent[]
}
}>('/v1/super_agent/chat/event_list_share', payload)
: await request.get<{
data: {
chat_event_list: ApiEvent[]
}
}>('/chat/event', payload)
// 提取 data.data.chat_event_list 字段
const events = response?.data?.chat_event_list || []
if (!isUnmountedRef.current) {
// 使用 mergeEvents 而不是直接 setRawEvents以保留实时推送的新事件
mergeEvents(events)
}
} catch (err) {
if (!isUnmountedRef.current) {
const error = err instanceof Error ? err : new Error('Failed to fetch event list')
updateError(error)
}
} finally {
if (!isUnmountedRef.current) {
setLoading(false)
}
}
}, [mergeEvents, updateError, mode])
// 创建 WebSocket 连接
const createWebSocket = useCallback(() => {
if (!conversationIdRef.current) return
// 已有连接,不重复创建
if (wsClientRef.current) return
// 构建 WebSocket URL
const wsUrl = new URL(platformConfig.wssUrl)
wsUrl.searchParams.set('conversation_id', conversationIdRef.current)
const wsClient = createWebSocketClient(wsUrl.toString(), {
reconnectLimit,
reconnectInterval,
manual: true, // 手动连接,避免 React Strict Mode 下的问题
getToken,
getTenantId,
onOpen: () => {
if (isUnmountedRef.current) return
setReadyState(ReadyState.Open)
updateConnected(true)
updateError(null)
// 连接成功后切换到当前会话
if (conversationIdRef.current && wsClientRef.current) {
wsClientRef.current.switchConversation(conversationIdRef.current)
}
},
onClose: () => {
if (isUnmountedRef.current) return
setReadyState(ReadyState.Closed)
updateConnected(false)
},
onError: () => {
if (isUnmountedRef.current) return
updateError(new Error('WebSocket connection error'))
},
onMessage: (message: ApiEvent) => {
if (isUnmountedRef.current) return
const normalizedEvent = normalizeIncomingEvent(message)
if (!normalizedEvent) return
mergeEvent(normalizedEvent)
onEventRef.current?.(normalizedEvent)
},
})
wsClientRef.current = wsClient
// 手动触发连接
wsClient.connect()
}, [
platformConfig.wssUrl,
reconnectLimit,
reconnectInterval,
getToken,
getTenantId,
mergeEvent,
updateConnected,
updateError,
])
// 手动重连
const reconnect = useCallback(() => {
if (wsClientRef.current) {
wsClientRef.current.connect()
} else {
createWebSocket()
}
}, [createWebSocket])
// 清空事件列表
const clear = useCallback(() => {
setRawEvents([])
}, [])
// 切换会话
const switchConversation = useCallback((newConversationId: string) => {
if (wsClientRef.current) {
wsClientRef.current.switchConversation(newConversationId)
}
}, [])
// 发送消息
const sendMessage = useCallback<WebSocket['send']>((data) => {
if (wsClientRef.current) {
wsClientRef.current.sendMessage(data)
} else {
throw new Error('WebSocket not connected')
}
}, [])
// 主 effect当 conversationId 或 platformConfig 变化时处理
useEffect(() => {
isUnmountedRef.current = false
console.log('conversationId', conversationId)
// 无会话 ID 时清空
if (!conversationId) {
setRawEvents([])
setConnected(false)
setReadyState(ReadyState.Closed)
return
}
// conversationId 存在,获取历史记录
fetchEventList()
// 如果已有连接且已打开,直接切换会话;否则创建新连接
if (wsClientRef.current && wsClientRef.current.readyState === ReadyState.Open) {
wsClientRef.current.switchConversation(conversationId)
} else {
createWebSocket()
}
// 清理函数
return () => {
isUnmountedRef.current = true
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [conversationId, platformConfig.wssUrl, platformConfig.apiBaseUrl])
// 组件卸载时清理
useEffect(() => {
mountCountRef.current += 1
const currentMount = mountCountRef.current
return () => {
// 延迟执行,如果 mountCount 变了说明重新 mount 了Strict Mode不清理
setTimeout(() => {
if (currentMount === mountCountRef.current && wsClientRef.current) {
wsClientRef.current.cleanup()
wsClientRef.current = null
}
}, 0)
}
}, [])
return {
rawEvents,
loading,
connected,
readyState,
error,
refresh: fetchEventList,
reconnect,
clear,
switchConversation,
sendMessage,
}
}
export { ReadyState }
export default useNovaEvents

View File

@@ -0,0 +1,159 @@
import { useMemo, useCallback } from 'react'
import { request } from '@/http/request'
import type { TaskArtifact } from '../types'
import type { PlatformConfig } from './useNovaEvents'
export interface ConversationInfo {
conversation_id: string
title?: string
task_status?: string
is_read?: boolean
is_favourite?: boolean
}
interface UseNovaServiceProps {
platformConfig?: PlatformConfig
getToken?: () => string | undefined
getTenantId?: () => string | undefined
conversationId?: string
}
interface DirFileItem {
desc?: string
file_name?: string
file_type?: string
last_modified?: number
path?: string
}
const toAbsoluteHttpUrl = (value: unknown): string | null => {
if (typeof value !== 'string' || !value) return null
if (value.startsWith('http://') || value.startsWith('https://')) return value
return null
}
const extractDirFileList = (payload: unknown): DirFileItem[] => {
if (Array.isArray(payload)) return payload
if (!payload || typeof payload !== 'object') return []
const obj = payload as Record<string, unknown>
if (Array.isArray(obj.data)) return obj.data as DirFileItem[]
return []
}
const normalizeFileName = (name: string): string => {
const raw = name.trim()
if (!raw) return ''
const withoutPrefix = raw.startsWith('/upload/') ? raw.slice('/upload/'.length) : raw
return withoutPrefix
}
const resolvePathByName = (files: DirFileItem[], fileName?: string): string | null => {
if (!fileName) return null
const normalizedName = normalizeFileName(fileName)
if (!normalizedName) return null
const expectedSegment = `upload/${normalizedName}`
const matched = files.find(item => {
if (typeof item.file_name !== 'string' || !item.file_name) return false
return item.file_name.includes(expectedSegment)
})
return matched?.path || null
}
export function useNovaService({
platformConfig,
conversationId,
}: UseNovaServiceProps) {
// API Client Singleton
const apiClient = useMemo(() => {
if (!platformConfig?.apiBaseUrl) {
return null
}
return request
}, [platformConfig])
// Get Artifact URL Method
const getArtifactUrl = useCallback(
async (
artifact: TaskArtifact,
params?: Record<string, string>
): Promise<{ data: string }> => {
try {
if (!apiClient) {
throw new Error('API client is not initialized')
}
const taskId = artifact.task_id || conversationId
let resolvedPath = artifact.path
if (taskId) {
const dirFilesResponse = await apiClient.post<unknown>(
'/v1/super_agent/chat/get_dir_file',
{ task_id: taskId }
)
const dirFiles = extractDirFileList(dirFilesResponse)
const matchedPath = resolvePathByName(dirFiles, artifact.file_name)
if (matchedPath) {
resolvedPath = matchedPath
}
}
// Call OSS URL interface
const response = await apiClient.post<string>('/chat/oss_url', {
file_path: resolvedPath,
task_id: taskId,
params
})
const signedUrl = toAbsoluteHttpUrl(response) || ''
const fallback = toAbsoluteHttpUrl(artifact.path) || ''
return { data: signedUrl || fallback }
} catch (error) {
console.error('Failed to fetch artifact URL:', error)
return { data: toAbsoluteHttpUrl(artifact.path) || '' }
}
},
[conversationId, apiClient]
)
const stopChat = useCallback(async () => {
try {
if (!apiClient) {
throw new Error('API client is not initialized')
}
if (!conversationId) {
throw new Error('Conversation ID is required')
}
await apiClient.get('/chat/stop', { conversation_id: conversationId })
} catch (error) {
console.error('Failed to stop chat:', error)
throw error
}
}, [conversationId, apiClient])
/** 获取会话信息列表(用于轮询 task_status使用 novaRequest */
const getConversationInfoList = useCallback(
async (conversationIds: string[]) => {
if (conversationIds.length === 0) {
return { data: [] as ConversationInfo[] }
}
const res = await request.post<{ data?: ConversationInfo[] } | ConversationInfo[]>(
'/conversation/info',
{ conversation_ids: conversationIds }
)
const list = Array.isArray(res) ? res : (res?.data ?? [])
return { data: list }
},
[]
)
return {
apiClient,
getArtifactUrl,
stopChat,
getConversationInfoList,
}
}

View File

@@ -0,0 +1,40 @@
import { useState, useCallback } from 'react'
import type { TaskArtifact } from '../types'
/**
* 面板状态管理 Hook
*/
export function usePanelState() {
const [panelVisible, setPanelVisible] = useState(false)
const [selectedAttachment, setSelectedAttachment] = useState<TaskArtifact | null>(null)
const togglePanel = useCallback(() => {
setPanelVisible((prev) => !prev)
}, [])
const openPanel = useCallback((attachment?: TaskArtifact) => {
setPanelVisible(true)
if (attachment) {
setSelectedAttachment(attachment)
}
}, [])
const closePanel = useCallback(() => {
setPanelVisible(false)
setSelectedAttachment(null)
}, [])
const selectAttachment = useCallback((attachment: TaskArtifact) => {
setSelectedAttachment(attachment)
setPanelVisible(true)
}, [])
return {
panelVisible,
selectedAttachment,
togglePanel,
openPanel,
closePanel,
selectAttachment,
}
}

View File

@@ -0,0 +1,32 @@
import { useState, useLayoutEffect } from 'react'
/**
* 监听元素尺寸变化的 Hook
*/
export function useSize(target: React.RefObject<HTMLElement | null>) {
const [size, setSize] = useState<{ width: number; height: number } | null>(null)
useLayoutEffect(() => {
const element = target.current
if (!element) {
console.log('useSize: element is null')
return
}
const resizeObserver = new ResizeObserver((entries) => {
for (const entry of entries) {
const { width, height } = entry.contentRect
console.log('useSize: resize', width, height)
setSize({ width, height })
}
})
resizeObserver.observe(element)
return () => {
resizeObserver.disconnect()
}
}, [target])
return size
}