feat: 拆分左侧栏、中间聊天区、右侧栏组件, Hooks 提取
This commit is contained in:
34
src/views/AgentChat/hooks/index.ts
Normal file
34
src/views/AgentChat/hooks/index.ts
Normal file
@@ -0,0 +1,34 @@
|
|||||||
|
// src/views/AgentChat/hooks/index.ts
|
||||||
|
// 自定义 Hooks 统一导出
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 自定义 Hooks 统一入口
|
||||||
|
*
|
||||||
|
* 使用示例:
|
||||||
|
* ```typescript
|
||||||
|
* // 方式 1: 从统一入口导入(推荐)
|
||||||
|
* import { useAgentChat, useAgentSessions, useFileUpload, useAutoScroll } from './hooks';
|
||||||
|
*
|
||||||
|
* // 方式 2: 从单个文件导入
|
||||||
|
* import { useAgentChat } from './hooks/useAgentChat';
|
||||||
|
* ```
|
||||||
|
*/
|
||||||
|
|
||||||
|
export { useAutoScroll } from './useAutoScroll';
|
||||||
|
export { useFileUpload } from './useFileUpload';
|
||||||
|
export type { UploadedFile, UseFileUploadReturn } from './useFileUpload';
|
||||||
|
|
||||||
|
export { useAgentSessions } from './useAgentSessions';
|
||||||
|
export type {
|
||||||
|
Session,
|
||||||
|
User,
|
||||||
|
UseAgentSessionsParams,
|
||||||
|
UseAgentSessionsReturn,
|
||||||
|
} from './useAgentSessions';
|
||||||
|
|
||||||
|
export { useAgentChat } from './useAgentChat';
|
||||||
|
export type {
|
||||||
|
ToastFunction,
|
||||||
|
UseAgentChatParams,
|
||||||
|
UseAgentChatReturn,
|
||||||
|
} from './useAgentChat';
|
||||||
289
src/views/AgentChat/hooks/useAgentChat.ts
Normal file
289
src/views/AgentChat/hooks/useAgentChat.ts
Normal file
@@ -0,0 +1,289 @@
|
|||||||
|
// src/views/AgentChat/hooks/useAgentChat.ts
|
||||||
|
// 消息处理 Hook - 发送消息、处理响应、错误处理
|
||||||
|
|
||||||
|
import { useState, useCallback } from 'react';
|
||||||
|
import type { Dispatch, SetStateAction, KeyboardEvent } from 'react';
|
||||||
|
import axios from 'axios';
|
||||||
|
import { logger } from '@utils/logger';
|
||||||
|
import { MessageTypes, type Message } from '../constants/messageTypes';
|
||||||
|
import type { UploadedFile } from './useFileUpload';
|
||||||
|
import type { User } from './useAgentSessions';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Toast 通知函数类型(来自 Chakra UI)
|
||||||
|
*/
|
||||||
|
export interface ToastFunction {
|
||||||
|
(options: {
|
||||||
|
title: string;
|
||||||
|
description?: string;
|
||||||
|
status: 'success' | 'error' | 'warning' | 'info';
|
||||||
|
duration?: number;
|
||||||
|
isClosable?: boolean;
|
||||||
|
}): void;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* useAgentChat Hook 参数
|
||||||
|
*/
|
||||||
|
export interface UseAgentChatParams {
|
||||||
|
/** 当前用户信息 */
|
||||||
|
user: User | null;
|
||||||
|
/** 当前会话 ID */
|
||||||
|
currentSessionId: string | null;
|
||||||
|
/** 设置当前会话 ID */
|
||||||
|
setCurrentSessionId: Dispatch<SetStateAction<string | null>>;
|
||||||
|
/** 选中的 AI 模型 */
|
||||||
|
selectedModel: string;
|
||||||
|
/** 选中的工具列表 */
|
||||||
|
selectedTools: string[];
|
||||||
|
/** 已上传文件列表 */
|
||||||
|
uploadedFiles: UploadedFile[];
|
||||||
|
/** 清空已上传文件 */
|
||||||
|
clearFiles: () => void;
|
||||||
|
/** Toast 通知函数 */
|
||||||
|
toast: ToastFunction;
|
||||||
|
/** 重新加载会话列表(发送消息成功后调用) */
|
||||||
|
loadSessions: () => Promise<void>;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* useAgentChat Hook 返回值
|
||||||
|
*/
|
||||||
|
export interface UseAgentChatReturn {
|
||||||
|
/** 消息列表 */
|
||||||
|
messages: Message[];
|
||||||
|
/** 设置消息列表 */
|
||||||
|
setMessages: Dispatch<SetStateAction<Message[]>>;
|
||||||
|
/** 输入框内容 */
|
||||||
|
inputValue: string;
|
||||||
|
/** 设置输入框内容 */
|
||||||
|
setInputValue: Dispatch<SetStateAction<string>>;
|
||||||
|
/** 是否正在处理消息 */
|
||||||
|
isProcessing: boolean;
|
||||||
|
/** 发送消息 */
|
||||||
|
handleSendMessage: () => Promise<void>;
|
||||||
|
/** 键盘事件处理(Enter 发送) */
|
||||||
|
handleKeyPress: (e: KeyboardEvent<HTMLInputElement>) => void;
|
||||||
|
/** 添加消息到列表 */
|
||||||
|
addMessage: (message: Partial<Message>) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* useAgentChat Hook
|
||||||
|
*
|
||||||
|
* 处理消息发送、AI 响应、错误处理逻辑
|
||||||
|
*
|
||||||
|
* @param params - UseAgentChatParams
|
||||||
|
* @returns UseAgentChatReturn
|
||||||
|
*
|
||||||
|
* @example
|
||||||
|
* ```tsx
|
||||||
|
* const {
|
||||||
|
* messages,
|
||||||
|
* inputValue,
|
||||||
|
* setInputValue,
|
||||||
|
* isProcessing,
|
||||||
|
* handleSendMessage,
|
||||||
|
* handleKeyPress,
|
||||||
|
* } = useAgentChat({
|
||||||
|
* user,
|
||||||
|
* currentSessionId,
|
||||||
|
* setCurrentSessionId,
|
||||||
|
* selectedModel,
|
||||||
|
* selectedTools,
|
||||||
|
* uploadedFiles,
|
||||||
|
* clearFiles,
|
||||||
|
* toast,
|
||||||
|
* loadSessions,
|
||||||
|
* });
|
||||||
|
* ```
|
||||||
|
*/
|
||||||
|
export const useAgentChat = ({
|
||||||
|
user,
|
||||||
|
currentSessionId,
|
||||||
|
setCurrentSessionId,
|
||||||
|
selectedModel,
|
||||||
|
selectedTools,
|
||||||
|
uploadedFiles,
|
||||||
|
clearFiles,
|
||||||
|
toast,
|
||||||
|
loadSessions,
|
||||||
|
}: UseAgentChatParams): UseAgentChatReturn => {
|
||||||
|
const [messages, setMessages] = useState<Message[]>([]);
|
||||||
|
const [inputValue, setInputValue] = useState('');
|
||||||
|
const [isProcessing, setIsProcessing] = useState(false);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 添加消息到列表
|
||||||
|
*/
|
||||||
|
const addMessage = useCallback((message: Partial<Message>) => {
|
||||||
|
setMessages((prev) => [
|
||||||
|
...prev,
|
||||||
|
{
|
||||||
|
id: Date.now() + Math.random(),
|
||||||
|
timestamp: new Date().toISOString(),
|
||||||
|
...message,
|
||||||
|
} as Message,
|
||||||
|
]);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 发送消息到后端 API
|
||||||
|
*/
|
||||||
|
const handleSendMessage = useCallback(async () => {
|
||||||
|
if (!inputValue.trim() || isProcessing) return;
|
||||||
|
|
||||||
|
// 创建用户消息
|
||||||
|
const userMessage: Partial<Message> = {
|
||||||
|
type: MessageTypes.USER,
|
||||||
|
content: inputValue,
|
||||||
|
timestamp: new Date().toISOString(),
|
||||||
|
files: uploadedFiles.length > 0 ? uploadedFiles : undefined,
|
||||||
|
};
|
||||||
|
|
||||||
|
addMessage(userMessage);
|
||||||
|
const userInput = inputValue;
|
||||||
|
|
||||||
|
// 清空输入框和文件
|
||||||
|
setInputValue('');
|
||||||
|
clearFiles();
|
||||||
|
setIsProcessing(true);
|
||||||
|
|
||||||
|
try {
|
||||||
|
// 显示 "思考中" 状态
|
||||||
|
addMessage({
|
||||||
|
type: MessageTypes.AGENT_THINKING,
|
||||||
|
content: '正在分析你的问题...',
|
||||||
|
});
|
||||||
|
|
||||||
|
// 调用后端 API
|
||||||
|
const response = await axios.post('/mcp/agent/chat', {
|
||||||
|
message: userInput,
|
||||||
|
conversation_history: messages
|
||||||
|
.filter((m) => m.type === MessageTypes.USER || m.type === MessageTypes.AGENT_RESPONSE)
|
||||||
|
.map((m) => ({
|
||||||
|
isUser: m.type === MessageTypes.USER,
|
||||||
|
content: m.content,
|
||||||
|
})),
|
||||||
|
user_id: user?.id || 'anonymous',
|
||||||
|
user_nickname: user?.nickname || '匿名用户',
|
||||||
|
user_avatar: user?.avatar || '',
|
||||||
|
subscription_type: user?.subscription_type || 'free',
|
||||||
|
session_id: currentSessionId,
|
||||||
|
model: selectedModel,
|
||||||
|
tools: selectedTools,
|
||||||
|
files: uploadedFiles.length > 0 ? uploadedFiles : undefined,
|
||||||
|
});
|
||||||
|
|
||||||
|
// 移除 "思考中" 消息
|
||||||
|
setMessages((prev) => prev.filter((m) => m.type !== MessageTypes.AGENT_THINKING));
|
||||||
|
|
||||||
|
if (response.data.success) {
|
||||||
|
const data = response.data;
|
||||||
|
|
||||||
|
// 更新会话 ID(如果是新会话)
|
||||||
|
if (data.session_id && !currentSessionId) {
|
||||||
|
setCurrentSessionId(data.session_id);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 显示执行计划(如果有)
|
||||||
|
if (data.plan) {
|
||||||
|
addMessage({
|
||||||
|
type: MessageTypes.AGENT_PLAN,
|
||||||
|
content: '已制定执行计划',
|
||||||
|
plan: data.plan,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// 显示执行步骤(如果有)
|
||||||
|
if (data.steps && data.steps.length > 0) {
|
||||||
|
addMessage({
|
||||||
|
type: MessageTypes.AGENT_EXECUTING,
|
||||||
|
content: '正在执行步骤...',
|
||||||
|
plan: data.plan,
|
||||||
|
stepResults: data.steps,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// 移除 "执行中" 消息
|
||||||
|
setMessages((prev) => prev.filter((m) => m.type !== MessageTypes.AGENT_EXECUTING));
|
||||||
|
|
||||||
|
// 显示最终回复
|
||||||
|
addMessage({
|
||||||
|
type: MessageTypes.AGENT_RESPONSE,
|
||||||
|
content: data.final_answer || data.message || '处理完成',
|
||||||
|
plan: data.plan,
|
||||||
|
stepResults: data.steps,
|
||||||
|
metadata: data.metadata,
|
||||||
|
});
|
||||||
|
|
||||||
|
// 重新加载会话列表
|
||||||
|
loadSessions();
|
||||||
|
}
|
||||||
|
} catch (error: any) {
|
||||||
|
logger.error('Agent chat error', error);
|
||||||
|
|
||||||
|
// 移除 "思考中" 和 "执行中" 消息
|
||||||
|
setMessages((prev) =>
|
||||||
|
prev.filter(
|
||||||
|
(m) => m.type !== MessageTypes.AGENT_THINKING && m.type !== MessageTypes.AGENT_EXECUTING
|
||||||
|
)
|
||||||
|
);
|
||||||
|
|
||||||
|
// 显示错误消息
|
||||||
|
const errorMessage = error.response?.data?.error || error.message || '处理失败';
|
||||||
|
addMessage({
|
||||||
|
type: MessageTypes.ERROR,
|
||||||
|
content: `处理失败:${errorMessage}`,
|
||||||
|
});
|
||||||
|
|
||||||
|
// 显示 Toast 通知
|
||||||
|
toast({
|
||||||
|
title: '处理失败',
|
||||||
|
description: errorMessage,
|
||||||
|
status: 'error',
|
||||||
|
duration: 5000,
|
||||||
|
});
|
||||||
|
} finally {
|
||||||
|
setIsProcessing(false);
|
||||||
|
}
|
||||||
|
}, [
|
||||||
|
inputValue,
|
||||||
|
isProcessing,
|
||||||
|
uploadedFiles,
|
||||||
|
messages,
|
||||||
|
user,
|
||||||
|
currentSessionId,
|
||||||
|
selectedModel,
|
||||||
|
selectedTools,
|
||||||
|
addMessage,
|
||||||
|
clearFiles,
|
||||||
|
setCurrentSessionId,
|
||||||
|
loadSessions,
|
||||||
|
toast,
|
||||||
|
]);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 键盘事件处理(Enter 发送,Shift+Enter 换行)
|
||||||
|
*/
|
||||||
|
const handleKeyPress = useCallback(
|
||||||
|
(e: KeyboardEvent<HTMLInputElement>) => {
|
||||||
|
if (e.key === 'Enter' && !e.shiftKey) {
|
||||||
|
e.preventDefault();
|
||||||
|
handleSendMessage();
|
||||||
|
}
|
||||||
|
},
|
||||||
|
[handleSendMessage]
|
||||||
|
);
|
||||||
|
|
||||||
|
return {
|
||||||
|
messages,
|
||||||
|
setMessages,
|
||||||
|
inputValue,
|
||||||
|
setInputValue,
|
||||||
|
isProcessing,
|
||||||
|
handleSendMessage,
|
||||||
|
handleKeyPress,
|
||||||
|
addMessage,
|
||||||
|
};
|
||||||
|
};
|
||||||
189
src/views/AgentChat/hooks/useAgentSessions.ts
Normal file
189
src/views/AgentChat/hooks/useAgentSessions.ts
Normal file
@@ -0,0 +1,189 @@
|
|||||||
|
// src/views/AgentChat/hooks/useAgentSessions.ts
|
||||||
|
// 会话管理 Hook - 加载、切换、创建会话
|
||||||
|
|
||||||
|
import { useState, useEffect, useCallback } from 'react';
|
||||||
|
import type { Dispatch, SetStateAction } from 'react';
|
||||||
|
import axios from 'axios';
|
||||||
|
import { logger } from '@utils/logger';
|
||||||
|
import { MessageTypes, type Message } from '../constants/messageTypes';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 会话数据结构
|
||||||
|
*/
|
||||||
|
export interface Session {
|
||||||
|
session_id: string;
|
||||||
|
title?: string;
|
||||||
|
created_at?: string;
|
||||||
|
timestamp?: string;
|
||||||
|
message_count?: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 用户信息(从 AuthContext 传入)
|
||||||
|
*/
|
||||||
|
export interface User {
|
||||||
|
id: string;
|
||||||
|
nickname?: string;
|
||||||
|
avatar?: string;
|
||||||
|
subscription_type?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* useAgentSessions Hook 参数
|
||||||
|
*/
|
||||||
|
export interface UseAgentSessionsParams {
|
||||||
|
/** 当前用户信息 */
|
||||||
|
user: User | null;
|
||||||
|
/** 消息列表 setter(用于创建新会话时设置欢迎消息) */
|
||||||
|
setMessages: Dispatch<SetStateAction<Message[]>>;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* useAgentSessions Hook 返回值
|
||||||
|
*/
|
||||||
|
export interface UseAgentSessionsReturn {
|
||||||
|
/** 会话列表 */
|
||||||
|
sessions: Session[];
|
||||||
|
/** 当前选中的会话 ID */
|
||||||
|
currentSessionId: string | null;
|
||||||
|
/** 设置当前会话 ID */
|
||||||
|
setCurrentSessionId: Dispatch<SetStateAction<string | null>>;
|
||||||
|
/** 是否正在加载会话列表 */
|
||||||
|
isLoadingSessions: boolean;
|
||||||
|
/** 加载会话列表 */
|
||||||
|
loadSessions: () => Promise<void>;
|
||||||
|
/** 切换到指定会话 */
|
||||||
|
switchSession: (sessionId: string) => void;
|
||||||
|
/** 创建新会话(显示欢迎消息) */
|
||||||
|
createNewSession: () => void;
|
||||||
|
/** 加载指定会话的历史消息 */
|
||||||
|
loadSessionHistory: (sessionId: string) => Promise<void>;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* useAgentSessions Hook
|
||||||
|
*
|
||||||
|
* 管理会话列表、会话切换、新建会话逻辑
|
||||||
|
*
|
||||||
|
* @param params - UseAgentSessionsParams
|
||||||
|
* @returns UseAgentSessionsReturn
|
||||||
|
*
|
||||||
|
* @example
|
||||||
|
* ```tsx
|
||||||
|
* const {
|
||||||
|
* sessions,
|
||||||
|
* currentSessionId,
|
||||||
|
* isLoadingSessions,
|
||||||
|
* switchSession,
|
||||||
|
* createNewSession,
|
||||||
|
* } = useAgentSessions({ user, setMessages });
|
||||||
|
* ```
|
||||||
|
*/
|
||||||
|
export const useAgentSessions = ({
|
||||||
|
user,
|
||||||
|
setMessages,
|
||||||
|
}: UseAgentSessionsParams): UseAgentSessionsReturn => {
|
||||||
|
const [sessions, setSessions] = useState<Session[]>([]);
|
||||||
|
const [currentSessionId, setCurrentSessionId] = useState<string | null>(null);
|
||||||
|
const [isLoadingSessions, setIsLoadingSessions] = useState(false);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 加载用户的会话列表
|
||||||
|
*/
|
||||||
|
const loadSessions = useCallback(async () => {
|
||||||
|
if (!user?.id) return;
|
||||||
|
|
||||||
|
setIsLoadingSessions(true);
|
||||||
|
try {
|
||||||
|
const response = await axios.get('/mcp/agent/sessions', {
|
||||||
|
params: { user_id: user.id, limit: 50 },
|
||||||
|
});
|
||||||
|
|
||||||
|
if (response.data.success) {
|
||||||
|
setSessions(response.data.data);
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
logger.error('加载会话列表失败', error);
|
||||||
|
} finally {
|
||||||
|
setIsLoadingSessions(false);
|
||||||
|
}
|
||||||
|
}, [user?.id]);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 加载指定会话的历史消息
|
||||||
|
*/
|
||||||
|
const loadSessionHistory = useCallback(
|
||||||
|
async (sessionId: string) => {
|
||||||
|
if (!sessionId) return;
|
||||||
|
|
||||||
|
try {
|
||||||
|
const response = await axios.get(`/mcp/agent/history/${sessionId}`, {
|
||||||
|
params: { limit: 100 },
|
||||||
|
});
|
||||||
|
|
||||||
|
if (response.data.success) {
|
||||||
|
const history = response.data.data;
|
||||||
|
const formattedMessages: Message[] = history.map((msg: any, idx: number) => ({
|
||||||
|
id: `${sessionId}-${idx}`,
|
||||||
|
type: msg.message_type === 'user' ? MessageTypes.USER : MessageTypes.AGENT_RESPONSE,
|
||||||
|
content: msg.message,
|
||||||
|
plan: msg.plan ? JSON.parse(msg.plan) : null,
|
||||||
|
stepResults: msg.steps ? JSON.parse(msg.steps) : null,
|
||||||
|
timestamp: msg.timestamp,
|
||||||
|
}));
|
||||||
|
|
||||||
|
setMessages(formattedMessages);
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
logger.error('加载会话历史失败', error);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
[setMessages]
|
||||||
|
);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 切换到指定会话
|
||||||
|
*/
|
||||||
|
const switchSession = useCallback(
|
||||||
|
(sessionId: string) => {
|
||||||
|
setCurrentSessionId(sessionId);
|
||||||
|
loadSessionHistory(sessionId);
|
||||||
|
},
|
||||||
|
[loadSessionHistory]
|
||||||
|
);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 创建新会话(清空消息,显示欢迎消息)
|
||||||
|
*/
|
||||||
|
const createNewSession = useCallback(() => {
|
||||||
|
setCurrentSessionId(null);
|
||||||
|
setMessages([
|
||||||
|
{
|
||||||
|
id: Date.now(),
|
||||||
|
type: MessageTypes.AGENT_RESPONSE,
|
||||||
|
content: `你好${user?.nickname || ''}!👋\n\n我是**价小前**,你的 AI 投研助手。\n\n**我能做什么?**\n• 📊 全面分析股票基本面和技术面\n• 🔥 追踪市场热点和涨停板块\n• 📈 研究行业趋势和投资机会\n• 📰 汇总最新财经新闻和研报\n\n直接输入你的问题开始探索!`,
|
||||||
|
timestamp: new Date().toISOString(),
|
||||||
|
},
|
||||||
|
]);
|
||||||
|
}, [user?.nickname, setMessages]);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 组件挂载时加载会话列表并创建新会话
|
||||||
|
*/
|
||||||
|
useEffect(() => {
|
||||||
|
loadSessions();
|
||||||
|
createNewSession();
|
||||||
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||||
|
}, [user]);
|
||||||
|
|
||||||
|
return {
|
||||||
|
sessions,
|
||||||
|
currentSessionId,
|
||||||
|
setCurrentSessionId,
|
||||||
|
isLoadingSessions,
|
||||||
|
loadSessions,
|
||||||
|
switchSession,
|
||||||
|
createNewSession,
|
||||||
|
loadSessionHistory,
|
||||||
|
};
|
||||||
|
};
|
||||||
38
src/views/AgentChat/hooks/useAutoScroll.ts
Normal file
38
src/views/AgentChat/hooks/useAutoScroll.ts
Normal file
@@ -0,0 +1,38 @@
|
|||||||
|
// src/views/AgentChat/hooks/useAutoScroll.ts
|
||||||
|
// 自动滚动 Hook - 消息列表自动滚动到底部
|
||||||
|
|
||||||
|
import { useEffect, useRef } from 'react';
|
||||||
|
import type { Message } from '../constants/messageTypes';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* useAutoScroll Hook
|
||||||
|
*
|
||||||
|
* 监听消息列表变化,自动滚动到底部
|
||||||
|
*
|
||||||
|
* @param messages - 消息列表
|
||||||
|
* @returns messagesEndRef - 消息列表底部引用(需要绑定到消息列表末尾的 div)
|
||||||
|
*
|
||||||
|
* @example
|
||||||
|
* ```tsx
|
||||||
|
* const { messagesEndRef } = useAutoScroll(messages);
|
||||||
|
*
|
||||||
|
* return (
|
||||||
|
* <VStack>
|
||||||
|
* {messages.map(msg => <MessageCard key={msg.id} message={msg} />)}
|
||||||
|
* <div ref={messagesEndRef} />
|
||||||
|
* </VStack>
|
||||||
|
* );
|
||||||
|
* ```
|
||||||
|
*/
|
||||||
|
export const useAutoScroll = (messages: Message[]) => {
|
||||||
|
const messagesEndRef = useRef<HTMLDivElement>(null);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
// 平滑滚动到底部
|
||||||
|
messagesEndRef.current?.scrollIntoView({ behavior: 'smooth' });
|
||||||
|
}, [messages]);
|
||||||
|
|
||||||
|
return {
|
||||||
|
messagesEndRef,
|
||||||
|
};
|
||||||
|
};
|
||||||
131
src/views/AgentChat/hooks/useFileUpload.ts
Normal file
131
src/views/AgentChat/hooks/useFileUpload.ts
Normal file
@@ -0,0 +1,131 @@
|
|||||||
|
// src/views/AgentChat/hooks/useFileUpload.ts
|
||||||
|
// 文件上传 Hook - 处理文件选择、预览、删除
|
||||||
|
|
||||||
|
import { useState, useRef } from 'react';
|
||||||
|
import type { ChangeEvent, RefObject } from 'react';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 上传文件数据结构
|
||||||
|
*/
|
||||||
|
export interface UploadedFile {
|
||||||
|
/** 文件名 */
|
||||||
|
name: string;
|
||||||
|
/** 文件大小(字节) */
|
||||||
|
size: number;
|
||||||
|
/** 文件 MIME 类型 */
|
||||||
|
type: string;
|
||||||
|
/** 文件预览 URL(使用 URL.createObjectURL 创建) */
|
||||||
|
url: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* useFileUpload Hook 返回值
|
||||||
|
*/
|
||||||
|
export interface UseFileUploadReturn {
|
||||||
|
/** 已上传文件列表 */
|
||||||
|
uploadedFiles: UploadedFile[];
|
||||||
|
/** 文件输入框引用(用于触发文件选择) */
|
||||||
|
fileInputRef: RefObject<HTMLInputElement>;
|
||||||
|
/** 处理文件选择事件 */
|
||||||
|
handleFileSelect: (event: ChangeEvent<HTMLInputElement>) => void;
|
||||||
|
/** 删除指定文件 */
|
||||||
|
removeFile: (index: number) => void;
|
||||||
|
/** 清空所有文件 */
|
||||||
|
clearFiles: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* useFileUpload Hook
|
||||||
|
*
|
||||||
|
* 处理文件上传相关逻辑(选择、预览、删除)
|
||||||
|
*
|
||||||
|
* @returns UseFileUploadReturn
|
||||||
|
*
|
||||||
|
* @example
|
||||||
|
* ```tsx
|
||||||
|
* const { uploadedFiles, fileInputRef, handleFileSelect, removeFile } = useFileUpload();
|
||||||
|
*
|
||||||
|
* return (
|
||||||
|
* <>
|
||||||
|
* <input
|
||||||
|
* ref={fileInputRef}
|
||||||
|
* type="file"
|
||||||
|
* multiple
|
||||||
|
* accept="image/*,.pdf,.doc,.docx,.txt"
|
||||||
|
* onChange={handleFileSelect}
|
||||||
|
* style={{ display: 'none' }}
|
||||||
|
* />
|
||||||
|
* <Button onClick={() => fileInputRef.current?.click()}>上传文件</Button>
|
||||||
|
* {uploadedFiles.map((file, idx) => (
|
||||||
|
* <Tag key={idx}>
|
||||||
|
* {file.name}
|
||||||
|
* <TagCloseButton onClick={() => removeFile(idx)} />
|
||||||
|
* </Tag>
|
||||||
|
* ))}
|
||||||
|
* </>
|
||||||
|
* );
|
||||||
|
* ```
|
||||||
|
*/
|
||||||
|
export const useFileUpload = (): UseFileUploadReturn => {
|
||||||
|
const [uploadedFiles, setUploadedFiles] = useState<UploadedFile[]>([]);
|
||||||
|
const fileInputRef = useRef<HTMLInputElement>(null);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 处理文件选择事件
|
||||||
|
*/
|
||||||
|
const handleFileSelect = (event: ChangeEvent<HTMLInputElement>) => {
|
||||||
|
const files = Array.from(event.target.files || []);
|
||||||
|
|
||||||
|
const fileData: UploadedFile[] = files.map((file) => ({
|
||||||
|
name: file.name,
|
||||||
|
size: file.size,
|
||||||
|
type: file.type,
|
||||||
|
// 创建本地预览 URL(实际上传时需要转换为 base64 或上传到服务器)
|
||||||
|
url: URL.createObjectURL(file),
|
||||||
|
}));
|
||||||
|
|
||||||
|
setUploadedFiles((prev) => [...prev, ...fileData]);
|
||||||
|
|
||||||
|
// 清空 input value,允许重复选择同一文件
|
||||||
|
if (event.target) {
|
||||||
|
event.target.value = '';
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 删除指定索引的文件
|
||||||
|
*/
|
||||||
|
const removeFile = (index: number) => {
|
||||||
|
setUploadedFiles((prev) => {
|
||||||
|
// 释放 URL.createObjectURL 创建的内存
|
||||||
|
const file = prev[index];
|
||||||
|
if (file?.url) {
|
||||||
|
URL.revokeObjectURL(file.url);
|
||||||
|
}
|
||||||
|
|
||||||
|
return prev.filter((_, i) => i !== index);
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 清空所有文件
|
||||||
|
*/
|
||||||
|
const clearFiles = () => {
|
||||||
|
// 释放所有 URL 内存
|
||||||
|
uploadedFiles.forEach((file) => {
|
||||||
|
if (file.url) {
|
||||||
|
URL.revokeObjectURL(file.url);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
setUploadedFiles([]);
|
||||||
|
};
|
||||||
|
|
||||||
|
return {
|
||||||
|
uploadedFiles,
|
||||||
|
fileInputRef,
|
||||||
|
handleFileSelect,
|
||||||
|
removeFile,
|
||||||
|
clearFiles,
|
||||||
|
};
|
||||||
|
};
|
||||||
@@ -2,16 +2,11 @@
|
|||||||
// 超炫酷的 AI 投研助手 - HeroUI v3 现代深色主题版本
|
// 超炫酷的 AI 投研助手 - HeroUI v3 现代深色主题版本
|
||||||
// 使用 Framer Motion 物理动画引擎 + 毛玻璃效果
|
// 使用 Framer Motion 物理动画引擎 + 毛玻璃效果
|
||||||
|
|
||||||
import React, { useState, useEffect, useRef } from 'react';
|
import React, { useState, useEffect } from 'react';
|
||||||
import { Box, Flex, useToast, useColorMode } from '@chakra-ui/react';
|
import { Box, Flex, useToast, useColorMode } from '@chakra-ui/react';
|
||||||
import { useAuth } from '@contexts/AuthContext';
|
import { useAuth } from '@contexts/AuthContext';
|
||||||
import { logger } from '@utils/logger';
|
|
||||||
import axios from 'axios';
|
|
||||||
|
|
||||||
// 注意:图标导入已移至子组件中,主组件不再需要导入
|
|
||||||
|
|
||||||
// 常量配置 - 从 TypeScript 模块导入
|
// 常量配置 - 从 TypeScript 模块导入
|
||||||
import { MessageTypes } from './constants/messageTypes';
|
|
||||||
import { DEFAULT_MODEL_ID } from './constants/models';
|
import { DEFAULT_MODEL_ID } from './constants/models';
|
||||||
import { DEFAULT_SELECTED_TOOLS } from './constants/tools';
|
import { DEFAULT_SELECTED_TOOLS } from './constants/tools';
|
||||||
|
|
||||||
@@ -21,44 +16,78 @@ import LeftSidebar from './components/LeftSidebar';
|
|||||||
import ChatArea from './components/ChatArea';
|
import ChatArea from './components/ChatArea';
|
||||||
import RightSidebar from './components/RightSidebar';
|
import RightSidebar from './components/RightSidebar';
|
||||||
|
|
||||||
|
// 自定义 Hooks
|
||||||
|
import { useAgentSessions, useAgentChat, useFileUpload, useAutoScroll } from './hooks';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Agent Chat - 主组件(HeroUI v3 深色主题)
|
* Agent Chat - 主组件(HeroUI v3 深色主题)
|
||||||
*
|
*
|
||||||
* 注意:所有常量配置已提取到 constants/ 目录:
|
* 架构说明:
|
||||||
* - animations: constants/animations.ts
|
* - Phase 1: 常量配置已提取到 constants/ 目录(TypeScript)
|
||||||
* - MessageTypes: constants/messageTypes.ts
|
* - Phase 2: UI 组件已拆分到 components/ 目录
|
||||||
* - AVAILABLE_MODELS: constants/models.ts
|
* - Phase 3: 业务逻辑已提取到 hooks/ 目录(TypeScript)
|
||||||
* - MCP_TOOLS, TOOL_CATEGORIES: constants/tools.ts
|
*
|
||||||
* - quickQuestions: constants/quickQuestions.ts
|
* 主组件职责:
|
||||||
|
* 1. 组合各个自定义 Hooks
|
||||||
|
* 2. 管理 UI 状态(侧边栏开关、模型选择、工具选择)
|
||||||
|
* 3. 组合渲染子组件
|
||||||
*/
|
*/
|
||||||
const AgentChat = () => {
|
const AgentChat = () => {
|
||||||
const { user } = useAuth();
|
const { user } = useAuth();
|
||||||
const toast = useToast();
|
const toast = useToast();
|
||||||
const { setColorMode } = useColorMode();
|
const { setColorMode } = useColorMode();
|
||||||
|
|
||||||
// 会话管理
|
// ==================== UI 状态(主组件管理)====================
|
||||||
const [sessions, setSessions] = useState([]);
|
|
||||||
const [currentSessionId, setCurrentSessionId] = useState(null);
|
|
||||||
const [isLoadingSessions, setIsLoadingSessions] = useState(false);
|
|
||||||
|
|
||||||
// 消息管理
|
|
||||||
const [messages, setMessages] = useState([]);
|
|
||||||
const [inputValue, setInputValue] = useState('');
|
|
||||||
const [isProcessing, setIsProcessing] = useState(false);
|
|
||||||
|
|
||||||
// UI 状态
|
|
||||||
const [selectedModel, setSelectedModel] = useState(DEFAULT_MODEL_ID);
|
const [selectedModel, setSelectedModel] = useState(DEFAULT_MODEL_ID);
|
||||||
const [selectedTools, setSelectedTools] = useState(DEFAULT_SELECTED_TOOLS);
|
const [selectedTools, setSelectedTools] = useState(DEFAULT_SELECTED_TOOLS);
|
||||||
const [isLeftSidebarOpen, setIsLeftSidebarOpen] = useState(true);
|
const [isLeftSidebarOpen, setIsLeftSidebarOpen] = useState(true);
|
||||||
const [isRightSidebarOpen, setIsRightSidebarOpen] = useState(true);
|
const [isRightSidebarOpen, setIsRightSidebarOpen] = useState(true);
|
||||||
|
|
||||||
// 文件上传
|
// ==================== 自定义 Hooks ====================
|
||||||
const [uploadedFiles, setUploadedFiles] = useState([]);
|
|
||||||
const fileInputRef = useRef(null);
|
|
||||||
|
|
||||||
// Refs
|
// 文件上传 Hook
|
||||||
const messagesEndRef = useRef(null);
|
const { uploadedFiles, fileInputRef, handleFileSelect, removeFile, clearFiles } = useFileUpload();
|
||||||
const inputRef = useRef(null);
|
|
||||||
|
// 会话管理 Hook(需要先创建 messages state)
|
||||||
|
const [messages, setMessages] = useState([]);
|
||||||
|
|
||||||
|
const {
|
||||||
|
sessions,
|
||||||
|
currentSessionId,
|
||||||
|
setCurrentSessionId,
|
||||||
|
isLoadingSessions,
|
||||||
|
loadSessions,
|
||||||
|
switchSession,
|
||||||
|
createNewSession,
|
||||||
|
} = useAgentSessions({
|
||||||
|
user,
|
||||||
|
setMessages,
|
||||||
|
});
|
||||||
|
|
||||||
|
// 消息处理 Hook
|
||||||
|
const {
|
||||||
|
inputValue,
|
||||||
|
setInputValue,
|
||||||
|
isProcessing,
|
||||||
|
handleSendMessage,
|
||||||
|
handleKeyPress,
|
||||||
|
} = useAgentChat({
|
||||||
|
user,
|
||||||
|
currentSessionId,
|
||||||
|
setCurrentSessionId,
|
||||||
|
selectedModel,
|
||||||
|
selectedTools,
|
||||||
|
uploadedFiles,
|
||||||
|
clearFiles,
|
||||||
|
toast,
|
||||||
|
loadSessions,
|
||||||
|
});
|
||||||
|
|
||||||
|
// 自动滚动 Hook
|
||||||
|
const { messagesEndRef } = useAutoScroll(messages);
|
||||||
|
|
||||||
|
// ==================== 输入框引用(保留在主组件)====================
|
||||||
|
const inputRef = React.useRef(null);
|
||||||
|
|
||||||
// ==================== 启用深色模式 ====================
|
// ==================== 启用深色模式 ====================
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@@ -72,215 +101,7 @@ const AgentChat = () => {
|
|||||||
};
|
};
|
||||||
}, [setColorMode]);
|
}, [setColorMode]);
|
||||||
|
|
||||||
// ==================== API 调用函数 ====================
|
// ==================== 渲染组件 ====================
|
||||||
|
|
||||||
const loadSessions = async () => {
|
|
||||||
if (!user?.id) return;
|
|
||||||
setIsLoadingSessions(true);
|
|
||||||
try {
|
|
||||||
const response = await axios.get('/mcp/agent/sessions', {
|
|
||||||
params: { user_id: user.id, limit: 50 },
|
|
||||||
});
|
|
||||||
if (response.data.success) {
|
|
||||||
setSessions(response.data.data);
|
|
||||||
}
|
|
||||||
} catch (error) {
|
|
||||||
logger.error('加载会话列表失败', error);
|
|
||||||
} finally {
|
|
||||||
setIsLoadingSessions(false);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const loadSessionHistory = async (sessionId) => {
|
|
||||||
if (!sessionId) return;
|
|
||||||
try {
|
|
||||||
const response = await axios.get(`/mcp/agent/history/${sessionId}`, {
|
|
||||||
params: { limit: 100 },
|
|
||||||
});
|
|
||||||
if (response.data.success) {
|
|
||||||
const history = response.data.data;
|
|
||||||
const formattedMessages = history.map((msg, idx) => ({
|
|
||||||
id: `${sessionId}-${idx}`,
|
|
||||||
type: msg.message_type === 'user' ? MessageTypes.USER : MessageTypes.AGENT_RESPONSE,
|
|
||||||
content: msg.message,
|
|
||||||
plan: msg.plan ? JSON.parse(msg.plan) : null,
|
|
||||||
stepResults: msg.steps ? JSON.parse(msg.steps) : null,
|
|
||||||
timestamp: msg.timestamp,
|
|
||||||
}));
|
|
||||||
setMessages(formattedMessages);
|
|
||||||
}
|
|
||||||
} catch (error) {
|
|
||||||
logger.error('加载会话历史失败', error);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const createNewSession = () => {
|
|
||||||
setCurrentSessionId(null);
|
|
||||||
setMessages([
|
|
||||||
{
|
|
||||||
id: Date.now(),
|
|
||||||
type: MessageTypes.AGENT_RESPONSE,
|
|
||||||
content: `你好${user?.nickname || ''}!👋\n\n我是**价小前**,你的 AI 投研助手。\n\n**我能做什么?**\n• 📊 全面分析股票基本面和技术面\n• 🔥 追踪市场热点和涨停板块\n• 📈 研究行业趋势和投资机会\n• 📰 汇总最新财经新闻和研报\n\n直接输入你的问题开始探索!`,
|
|
||||||
timestamp: new Date().toISOString(),
|
|
||||||
},
|
|
||||||
]);
|
|
||||||
};
|
|
||||||
|
|
||||||
const switchSession = (sessionId) => {
|
|
||||||
setCurrentSessionId(sessionId);
|
|
||||||
loadSessionHistory(sessionId);
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleSendMessage = async () => {
|
|
||||||
if (!inputValue.trim() || isProcessing) return;
|
|
||||||
|
|
||||||
const userMessage = {
|
|
||||||
type: MessageTypes.USER,
|
|
||||||
content: inputValue,
|
|
||||||
timestamp: new Date().toISOString(),
|
|
||||||
files: uploadedFiles.length > 0 ? uploadedFiles : undefined,
|
|
||||||
};
|
|
||||||
|
|
||||||
addMessage(userMessage);
|
|
||||||
const userInput = inputValue;
|
|
||||||
setInputValue('');
|
|
||||||
setUploadedFiles([]);
|
|
||||||
setIsProcessing(true);
|
|
||||||
|
|
||||||
try {
|
|
||||||
addMessage({
|
|
||||||
type: MessageTypes.AGENT_THINKING,
|
|
||||||
content: '正在分析你的问题...',
|
|
||||||
timestamp: new Date().toISOString(),
|
|
||||||
});
|
|
||||||
|
|
||||||
const response = await axios.post('/mcp/agent/chat', {
|
|
||||||
message: userInput,
|
|
||||||
conversation_history: messages
|
|
||||||
.filter((m) => m.type === MessageTypes.USER || m.type === MessageTypes.AGENT_RESPONSE)
|
|
||||||
.map((m) => ({
|
|
||||||
isUser: m.type === MessageTypes.USER,
|
|
||||||
content: m.content,
|
|
||||||
})),
|
|
||||||
user_id: user?.id || 'anonymous',
|
|
||||||
user_nickname: user?.nickname || '匿名用户',
|
|
||||||
user_avatar: user?.avatar || '',
|
|
||||||
subscription_type: user?.subscription_type || 'free',
|
|
||||||
session_id: currentSessionId,
|
|
||||||
model: selectedModel,
|
|
||||||
tools: selectedTools,
|
|
||||||
files: uploadedFiles.length > 0 ? uploadedFiles : undefined,
|
|
||||||
});
|
|
||||||
|
|
||||||
setMessages((prev) => prev.filter((m) => m.type !== MessageTypes.AGENT_THINKING));
|
|
||||||
|
|
||||||
if (response.data.success) {
|
|
||||||
const data = response.data;
|
|
||||||
if (data.session_id && !currentSessionId) {
|
|
||||||
setCurrentSessionId(data.session_id);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (data.plan) {
|
|
||||||
addMessage({
|
|
||||||
type: MessageTypes.AGENT_PLAN,
|
|
||||||
content: '已制定执行计划',
|
|
||||||
plan: data.plan,
|
|
||||||
timestamp: new Date().toISOString(),
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
if (data.steps && data.steps.length > 0) {
|
|
||||||
addMessage({
|
|
||||||
type: MessageTypes.AGENT_EXECUTING,
|
|
||||||
content: '正在执行步骤...',
|
|
||||||
plan: data.plan,
|
|
||||||
stepResults: data.steps,
|
|
||||||
timestamp: new Date().toISOString(),
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
setMessages((prev) => prev.filter((m) => m.type !== MessageTypes.AGENT_EXECUTING));
|
|
||||||
|
|
||||||
addMessage({
|
|
||||||
type: MessageTypes.AGENT_RESPONSE,
|
|
||||||
content: data.final_answer || data.message || '处理完成',
|
|
||||||
plan: data.plan,
|
|
||||||
stepResults: data.steps,
|
|
||||||
metadata: data.metadata,
|
|
||||||
timestamp: new Date().toISOString(),
|
|
||||||
});
|
|
||||||
|
|
||||||
loadSessions();
|
|
||||||
}
|
|
||||||
} catch (error) {
|
|
||||||
logger.error('Agent chat error', error);
|
|
||||||
setMessages((prev) =>
|
|
||||||
prev.filter(
|
|
||||||
(m) => m.type !== MessageTypes.AGENT_THINKING && m.type !== MessageTypes.AGENT_EXECUTING
|
|
||||||
)
|
|
||||||
);
|
|
||||||
|
|
||||||
const errorMessage = error.response?.data?.error || error.message || '处理失败';
|
|
||||||
addMessage({
|
|
||||||
type: MessageTypes.ERROR,
|
|
||||||
content: `处理失败:${errorMessage}`,
|
|
||||||
timestamp: new Date().toISOString(),
|
|
||||||
});
|
|
||||||
|
|
||||||
toast({
|
|
||||||
title: '处理失败',
|
|
||||||
description: errorMessage,
|
|
||||||
status: 'error',
|
|
||||||
duration: 5000,
|
|
||||||
});
|
|
||||||
} finally {
|
|
||||||
setIsProcessing(false);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
// 文件上传处理
|
|
||||||
const handleFileSelect = (event) => {
|
|
||||||
const files = Array.from(event.target.files || []);
|
|
||||||
const fileData = files.map(file => ({
|
|
||||||
name: file.name,
|
|
||||||
size: file.size,
|
|
||||||
type: file.type,
|
|
||||||
// 实际上传时需要转换为 base64 或上传到服务器
|
|
||||||
url: URL.createObjectURL(file),
|
|
||||||
}));
|
|
||||||
setUploadedFiles(prev => [...prev, ...fileData]);
|
|
||||||
};
|
|
||||||
|
|
||||||
const removeFile = (index) => {
|
|
||||||
setUploadedFiles(prev => prev.filter((_, i) => i !== index));
|
|
||||||
};
|
|
||||||
|
|
||||||
const addMessage = (message) => {
|
|
||||||
setMessages((prev) => [
|
|
||||||
...prev,
|
|
||||||
{
|
|
||||||
id: Date.now() + Math.random(),
|
|
||||||
...message,
|
|
||||||
},
|
|
||||||
]);
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleKeyPress = (e) => {
|
|
||||||
if (e.key === 'Enter' && !e.shiftKey) {
|
|
||||||
e.preventDefault();
|
|
||||||
handleSendMessage();
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
loadSessions();
|
|
||||||
createNewSession();
|
|
||||||
}, [user]);
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
messagesEndRef.current?.scrollIntoView({ behavior: 'smooth' });
|
|
||||||
}, [messages]);
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Box flex={1} bg="gray.900">
|
<Box flex={1} bg="gray.900">
|
||||||
<Flex h="100%" overflow="hidden" position="relative">
|
<Flex h="100%" overflow="hidden" position="relative">
|
||||||
|
|||||||
Reference in New Issue
Block a user