初始化模版工程
This commit is contained in:
10
components/nova-sdk/hooks/index.ts
Normal file
10
components/nova-sdk/hooks/index.ts
Normal 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 '..'
|
||||
117
components/nova-sdk/hooks/useArtifactsExtractor.ts
Normal file
117
components/nova-sdk/hooks/useArtifactsExtractor.ts
Normal 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])
|
||||
}
|
||||
113
components/nova-sdk/hooks/useAttachmentHandlers.ts
Normal file
113
components/nova-sdk/hooks/useAttachmentHandlers.ts
Normal 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,
|
||||
}
|
||||
}
|
||||
93
components/nova-sdk/hooks/useBuildConversationConnect.ts
Normal file
93
components/nova-sdk/hooks/useBuildConversationConnect.ts
Normal 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,
|
||||
}
|
||||
}
|
||||
323
components/nova-sdk/hooks/useEventProcessor.ts
Normal file
323
components/nova-sdk/hooks/useEventProcessor.ts
Normal 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])
|
||||
}
|
||||
166
components/nova-sdk/hooks/useFileUploader.ts
Normal file
166
components/nova-sdk/hooks/useFileUploader.ts
Normal 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(','),
|
||||
}
|
||||
}
|
||||
25
components/nova-sdk/hooks/useMessageScroll.ts
Normal file
25
components/nova-sdk/hooks/useMessageScroll.ts
Normal 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,
|
||||
}
|
||||
}
|
||||
51
components/nova-sdk/hooks/useMessageSender.ts
Normal file
51
components/nova-sdk/hooks/useMessageSender.ts
Normal 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
|
||||
}
|
||||
}
|
||||
232
components/nova-sdk/hooks/useNovaChatLogic.ts
Normal file
232
components/nova-sdk/hooks/useNovaChatLogic.ts
Normal 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,
|
||||
}
|
||||
}
|
||||
}
|
||||
370
components/nova-sdk/hooks/useNovaEvents.ts
Normal file
370
components/nova-sdk/hooks/useNovaEvents.ts
Normal 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
|
||||
159
components/nova-sdk/hooks/useNovaService.ts
Normal file
159
components/nova-sdk/hooks/useNovaService.ts
Normal 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,
|
||||
}
|
||||
}
|
||||
40
components/nova-sdk/hooks/usePanelState.ts
Normal file
40
components/nova-sdk/hooks/usePanelState.ts
Normal 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,
|
||||
}
|
||||
}
|
||||
32
components/nova-sdk/hooks/useSize.ts
Normal file
32
components/nova-sdk/hooks/useSize.ts
Normal 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
|
||||
}
|
||||
Reference in New Issue
Block a user