Compare commits

...

6 Commits

Author SHA1 Message Date
zdl
36adfcf287 feat: 添加 Props 类型 2025-11-25 18:01:19 +08:00
zdl
24750018cd feat: 添加 Props 类型 2025-11-25 18:01:19 +08:00
zdl
4d9c29c02e feat: 修复loagger报错 2025-11-25 18:01:19 +08:00
zdl
5dc06b3522 feat: 中间聊天区域组件拆分 2025-11-25 18:01:07 +08:00
zdl
eb9d34239e feat: 修复logger 报错 2025-11-25 18:00:59 +08:00
zdl
5a82e14ebf feat: 修复debugger报错 2025-11-25 18:00:59 +08:00
26 changed files with 1024 additions and 581 deletions

View File

@@ -1,5 +1,15 @@
import { Link } from "react-router-dom";
import { svgs } from "./svgs";
import React from "react";
interface ButtonProps {
className?: string;
href?: string;
onClick?: () => void;
children: React.ReactNode;
px?: string;
white?: boolean;
}
const Button = ({
className,
@@ -8,7 +18,7 @@ const Button = ({
children,
px,
white,
}) => {
}: ButtonProps) => {
const classes = `button relative inline-flex items-center justify-center h-11 ${
px || "px-7"
} ${white ? "text-n-8" : "text-n-1"} transition-colors hover:text-color-1 ${

View File

@@ -1,6 +1,11 @@
import { useState } from "react";
import React from "react";
const Image = ({ className, ...props }) => {
interface ImageProps extends React.ImgHTMLAttributes<HTMLImageElement> {
className?: string;
}
const Image = ({ className, ...props }: ImageProps) => {
const [loaded, setLoaded] = useState(false);
return (

View File

@@ -1,10 +1,20 @@
import React from "react";
interface SectionProps {
className?: string;
crosses?: boolean;
crossesOffset?: string;
customPaddings?: boolean;
children: React.ReactNode;
}
const Section = ({
className,
crosses,
crossesOffset,
customPaddings,
children,
}) => (
}: SectionProps) => (
<div
className={`relative ${
customPaddings ||

View File

@@ -72,7 +72,7 @@ const KLineChartModal: React.FC<KLineChartModalProps> = ({
setError(null);
try {
logger.debug('KLineChartModal', 'loadData', '开始加载K线数据', {
logger.debug('KLineChartModal', '开始加载K线数据 (loadData)', {
stockCode: stock.stock_code,
eventTime,
});
@@ -91,7 +91,7 @@ const KLineChartModal: React.FC<KLineChartModalProps> = ({
console.log('[KLineChartModal] 数据条数:', response.data.length);
setData(response.data);
logger.info('KLineChartModal', 'loadData', 'K线数据加载成功', {
logger.info('KLineChartModal', 'K线数据加载成功 (loadData)', {
dataCount: response.data.length,
});
} catch (err) {

View File

@@ -111,7 +111,7 @@ const StockChartKLineModal: React.FC<StockChartKLineModalProps> = ({
const newType = e.target.value as ChartType;
setChartType(newType);
logger.debug('StockChartKLineModal', 'handleChartTypeChange', '切换图表类型', {
logger.debug('StockChartKLineModal', '切换图表类型 (handleChartTypeChange)', {
newType,
});
}, []);
@@ -131,7 +131,7 @@ const StockChartKLineModal: React.FC<StockChartKLineModalProps> = ({
// 然后创建新的指标
createSubIndicators(chart, values);
logger.debug('StockChartKLineModal', 'handleIndicatorChange', '切换副图指标', {
logger.debug('StockChartKLineModal', '切换副图指标 (handleIndicatorChange)', {
indicators: values,
});
},
@@ -143,7 +143,6 @@ const StockChartKLineModal: React.FC<StockChartKLineModalProps> = ({
*/
const handleRefresh = useCallback(() => {
loadData();
logger.debug('StockChartKLineModal', 'handleRefresh', '刷新数据');
}, [loadData]);
// ==================== 计算属性 ====================

View File

@@ -76,7 +76,7 @@ const TimelineChartModal: React.FC<TimelineChartModalProps> = ({
setError(null);
try {
logger.debug('TimelineChartModal', 'loadData', '开始加载分时图数据', {
logger.debug('TimelineChartModal', '开始加载分时图数据 (loadData)', {
stockCode: stock.stock_code,
eventTime,
});
@@ -95,7 +95,7 @@ const TimelineChartModal: React.FC<TimelineChartModalProps> = ({
console.log('[TimelineChartModal] 数据条数:', response.data.length);
setData(response.data);
logger.info('TimelineChartModal', 'loadData', '分时图数据加载成功', {
logger.info('TimelineChartModal', '分时图数据加载成功 (loadData)', {
dataCount: response.data.length,
});
} catch (err) {

View File

@@ -77,7 +77,7 @@ export const useEventMarker = (
const createMarker = useCallback(
(time: string, label: string, color?: string) => {
if (!chart || !data || data.length === 0) {
logger.warn('useEventMarker', 'createMarker', '图表或数据未准备好', {
logger.warn('useEventMarker', '图表或数据未准备好 (createMarker)', {
hasChart: !!chart,
dataLength: data?.length || 0,
});
@@ -93,7 +93,7 @@ export const useEventMarker = (
const overlay = createEventMarkerOverlay(eventMarker, data);
if (!overlay) {
logger.warn('useEventMarker', 'createMarker', 'Overlay 创建失败', {
logger.warn('useEventMarker', 'Overlay 创建失败 (createMarker)', {
eventMarker,
});
return;
@@ -103,7 +103,7 @@ export const useEventMarker = (
const id = chart.createOverlay(overlay);
if (!id || (Array.isArray(id) && id.length === 0)) {
logger.warn('useEventMarker', 'createMarker', '标记添加失败', {
logger.warn('useEventMarker', '标记添加失败 (createMarker)', {
overlay,
});
return;
@@ -119,12 +119,12 @@ export const useEventMarker = (
const actualHighlightId = Array.isArray(highlightResult) ? highlightResult[0] : highlightResult;
setHighlightId(actualHighlightId as string);
logger.info('useEventMarker', 'createMarker', '事件高亮背景创建成功', {
logger.info('useEventMarker', '事件高亮背景创建成功 (createMarker)', {
highlightId: actualHighlightId,
});
}
logger.info('useEventMarker', 'createMarker', '事件标记创建成功', {
logger.info('useEventMarker', '事件标记创建成功 (createMarker)', {
markerId: actualId,
label,
time,
@@ -150,17 +150,17 @@ export const useEventMarker = (
try {
if (markerId) {
chart.removeOverlay(markerId);
chart.removeOverlay({ id: markerId } as any);
}
if (highlightId) {
chart.removeOverlay(highlightId);
chart.removeOverlay({ id: highlightId } as any);
}
setMarker(null);
setMarkerId(null);
setHighlightId(null);
logger.debug('useEventMarker', 'removeMarker', '移除事件标记和高亮', {
logger.debug('useEventMarker', '移除事件标记和高亮 (removeMarker)', {
markerId,
highlightId,
chartId: chart.id,
@@ -187,7 +187,7 @@ export const useEventMarker = (
setMarkerId(null);
setHighlightId(null);
logger.debug('useEventMarker', 'removeAllMarkers', '移除所有事件标记和高亮', {
logger.debug('useEventMarker', '移除所有事件标记和高亮 (removeAllMarkers)', {
chartId: chart.id,
});
} catch (err) {
@@ -216,10 +216,10 @@ export const useEventMarker = (
if (chart) {
try {
if (markerId) {
chart.removeOverlay(markerId);
chart.removeOverlay({ id: markerId } as any);
}
if (highlightId) {
chart.removeOverlay(highlightId);
chart.removeOverlay({ id: highlightId } as any);
}
} catch (err) {
// 忽略清理时的错误

View File

@@ -78,12 +78,12 @@ export const useKLineChart = (
// 图表初始化函数
const initChart = (): boolean => {
if (!chartRef.current) {
logger.warn('useKLineChart', 'init', '图表容器未挂载,将在 50ms 后重试', { containerId });
logger.warn('useKLineChart', '图表容器未挂载,将在 50ms 后重试 (init)', { containerId });
return false;
}
try {
logger.debug('useKLineChart', 'init', '开始初始化图表', {
logger.debug('useKLineChart', '开始初始化图表 (init)', {
containerId,
height,
colorMode,
@@ -116,17 +116,17 @@ export const useKLineChart = (
height: 100, // 固定高度 100px约占整体的 20-25%
});
logger.debug('useKLineChart', 'init', '成交量窗格创建成功', {
logger.debug('useKLineChart', '成交量窗格创建成功 (init)', {
volumePaneId,
});
} catch (err) {
logger.warn('useKLineChart', 'init', '成交量窗格创建失败', {
logger.warn('useKLineChart', '成交量窗格创建失败 (init)', {
error: err,
});
// 不阻塞主流程,继续执行
}
logger.info('useKLineChart', 'init', '✅ 图表初始化成功', {
logger.info('useKLineChart', '✅ 图表初始化成功 (init)', {
containerId,
chartId: chartInstance.id,
});
@@ -146,7 +146,7 @@ export const useKLineChart = (
// 成功,直接返回清理函数
return () => {
if (chartInstanceRef.current) {
logger.debug('useKLineChart', 'dispose', '销毁图表实例', {
logger.debug('useKLineChart', '销毁图表实例 (dispose)', {
containerId,
chartId: chartInstanceRef.current.id,
});
@@ -161,7 +161,7 @@ export const useKLineChart = (
// 失败则延迟重试(处理 Modal 动画延迟导致的 DOM 未挂载)
const timer = setTimeout(() => {
logger.debug('useKLineChart', 'init', '执行延迟重试', { containerId });
logger.debug('useKLineChart', '执行延迟重试 (init)', { containerId });
initChart();
}, 50);
@@ -169,7 +169,7 @@ export const useKLineChart = (
return () => {
clearTimeout(timer);
if (chartInstanceRef.current) {
logger.debug('useKLineChart', 'dispose', '销毁图表实例', {
logger.debug('useKLineChart', '销毁图表实例 (dispose)', {
containerId,
chartId: chartInstanceRef.current.id,
});
@@ -196,7 +196,7 @@ export const useKLineChart = (
: getTheme(colorMode);
chartInstanceRef.current.setStyles(newTheme);
logger.debug('useKLineChart', 'updateTheme', '更新图表主题', {
logger.debug('useKLineChart', '更新图表主题 (updateTheme)', {
colorMode,
chartType,
chartId: chartInstanceRef.current.id,
@@ -215,7 +215,6 @@ export const useKLineChart = (
const handleResize = () => {
if (chartInstanceRef.current) {
chartInstanceRef.current.resize();
logger.debug('useKLineChart', 'resize', '调整图表大小');
}
};

View File

@@ -78,7 +78,7 @@ export const useKLineData = (
*/
const loadData = useCallback(async () => {
if (!stockCode) {
logger.warn('useKLineData', 'loadData', '股票代码为空', { chartType });
logger.warn('useKLineData', '股票代码为空 (loadData)', { chartType });
return;
}
@@ -86,7 +86,7 @@ export const useKLineData = (
setError(null);
try {
logger.debug('useKLineData', 'loadData', '开始加载数据', {
logger.debug('useKLineData', '开始加载数据 (loadData)', {
stockCode,
chartType,
eventTime,
@@ -126,7 +126,7 @@ export const useKLineData = (
setData(processedData);
logger.info('useKLineData', 'loadData', '数据加载成功', {
logger.info('useKLineData', '数据加载成功 (loadData)', {
stockCode,
chartType,
rawCount: rawDataList.length,

View File

@@ -51,6 +51,8 @@ export interface RawDataPoint {
close: number;
/** 成交量 */
volume: number;
/** 成交额(可选) */
turnover?: number;
/** 均价(分时图专用) */
avg_price?: number;
/** 昨收价(用于百分比计算和基准线)- 分时图专用 */

View File

@@ -50,7 +50,7 @@ export const createIndicator = (
isStack
);
logger.debug('chartUtils', 'createIndicator', '创建技术指标', {
logger.debug('chartUtils', '创建技术指标 (createIndicator)', {
indicatorName,
params,
isStack,
@@ -69,8 +69,8 @@ export const createIndicator = (
*/
export const removeIndicator = (chart: Chart, indicatorId?: string): void => {
safeChartOperation('removeIndicator', () => {
chart.removeIndicator(indicatorId);
logger.debug('chartUtils', 'removeIndicator', '移除技术指标', { indicatorId });
chart.removeIndicator(indicatorId ? { id: indicatorId } as any : undefined);
logger.debug('chartUtils', '移除技术指标 (removeIndicator)', { indicatorId });
});
};
@@ -94,7 +94,7 @@ export const createSubIndicators = (
}
});
logger.debug('chartUtils', 'createSubIndicators', '批量创建副图指标', {
logger.debug('chartUtils', '批量创建副图指标 (createSubIndicators)', {
indicators,
createdIds: ids,
});
@@ -130,7 +130,7 @@ export const setChartZoom = (chart: Chart, zoom: number): void => {
},
});
logger.debug('chartUtils', 'setChartZoom', '设置图表缩放', {
logger.debug('chartUtils', '设置图表缩放 (setChartZoom)', {
zoom,
newBarSpace,
});
@@ -148,7 +148,7 @@ export const scrollToTimestamp = (chart: Chart, timestamp: number): void => {
// KLineChart 10.0: 使用 scrollToTimestamp 方法
chart.scrollToTimestamp(timestamp);
logger.debug('chartUtils', 'scrollToTimestamp', '滚动到指定时间', { timestamp });
logger.debug('chartUtils', '滚动到指定时间 (scrollToTimestamp)', { timestamp });
});
};
@@ -160,7 +160,7 @@ export const scrollToTimestamp = (chart: Chart, timestamp: number): void => {
export const resizeChart = (chart: Chart): void => {
safeChartOperation('resizeChart', () => {
chart.resize();
logger.debug('chartUtils', 'resizeChart', '调整图表大小');
logger.debug('chartUtils', '调整图表大小 (resizeChart)');
});
};
@@ -194,7 +194,7 @@ export const getVisibleRange = (chart: Chart): { from: number; to: number } | nu
export const clearChartData = (chart: Chart): void => {
safeChartOperation('clearChartData', () => {
chart.resetData();
logger.debug('chartUtils', 'clearChartData', '清空图表数据');
logger.debug('chartUtils', '清空图表数据 (clearChartData)');
});
};
@@ -213,7 +213,7 @@ export const exportChartImage = (
// KLineChart 10.0: 使用 getConvertPictureUrl 方法
const imageData = chart.getConvertPictureUrl(includeOverlay, 'png', '#ffffff');
logger.debug('chartUtils', 'exportChartImage', '导出图表图片', {
logger.debug('chartUtils', '导出图表图片 (exportChartImage)', {
includeOverlay,
hasData: !!imageData,
});
@@ -236,7 +236,7 @@ export const toggleCrosshair = (chart: Chart, show: boolean): void => {
},
});
logger.debug('chartUtils', 'toggleCrosshair', '切换十字光标', { show });
logger.debug('chartUtils', '切换十字光标 (toggleCrosshair)', { show });
});
};
@@ -254,7 +254,7 @@ export const toggleGrid = (chart: Chart, show: boolean): void => {
},
});
logger.debug('chartUtils', 'toggleGrid', '切换网格', { show });
logger.debug('chartUtils', '切换网格 (toggleGrid)', { show });
});
};
@@ -271,8 +271,8 @@ export const subscribeChartEvent = (
handler: (...args: any[]) => void
): void => {
safeChartOperation(`subscribeChartEvent:${eventName}`, () => {
chart.subscribeAction(eventName, handler);
logger.debug('chartUtils', 'subscribeChartEvent', '订阅图表事件', { eventName });
chart.subscribeAction(eventName as any, handler);
logger.debug('chartUtils', '订阅图表事件 (subscribeChartEvent)', { eventName });
});
};
@@ -289,7 +289,7 @@ export const unsubscribeChartEvent = (
handler: (...args: any[]) => void
): void => {
safeChartOperation(`unsubscribeChartEvent:${eventName}`, () => {
chart.unsubscribeAction(eventName, handler);
logger.debug('chartUtils', 'unsubscribeChartEvent', '取消订阅图表事件', { eventName });
chart.unsubscribeAction(eventName as any, handler);
logger.debug('chartUtils', '取消订阅图表事件 (unsubscribeChartEvent)', { eventName });
});
};

View File

@@ -22,7 +22,7 @@ export const convertToKLineData = (
eventTime?: string
): KLineDataPoint[] => {
if (!rawData || !Array.isArray(rawData) || rawData.length === 0) {
logger.warn('dataAdapter', 'convertToKLineData', '原始数据为空', { chartType });
logger.warn('dataAdapter', '原始数据为空 (convertToKLineData)', { chartType });
return [];
}
@@ -90,7 +90,7 @@ const parseTimestamp = (
}
// 默认返回当前时间(避免图表崩溃)
logger.warn('dataAdapter', 'parseTimestamp', '无法解析时间戳,使用当前时间', { item });
logger.warn('dataAdapter', '无法解析时间戳,使用当前时间 (parseTimestamp)', { item });
return Date.now();
};
@@ -126,19 +126,19 @@ export const validateAndCleanData = (data: KLineDataPoint[]): KLineDataPoint[] =
return data.filter((item) => {
// 移除价格为 0 或负数的数据
if (item.open <= 0 || item.high <= 0 || item.low <= 0 || item.close <= 0) {
logger.warn('dataAdapter', 'validateAndCleanData', '价格异常,已移除', { item });
logger.warn('dataAdapter', '价格异常,已移除 (validateAndCleanData)', { item });
return false;
}
// 移除 high < low 的数据(数据错误)
if (item.high < item.low) {
logger.warn('dataAdapter', 'validateAndCleanData', '最高价 < 最低价,已移除', { item });
logger.warn('dataAdapter', '最高价 < 最低价,已移除 (validateAndCleanData)', { item });
return false;
}
// 移除成交量为负数的数据
if (item.volume < 0) {
logger.warn('dataAdapter', 'validateAndCleanData', '成交量异常,已移除', { item });
logger.warn('dataAdapter', '成交量异常,已移除 (validateAndCleanData)', { item });
return false;
}
@@ -213,7 +213,7 @@ export const trimDataByEventTime = (
return item.timestamp >= startTime && item.timestamp <= endTime;
});
logger.debug('dataAdapter', 'trimDataByEventTime', '数据时间范围裁剪完成', {
logger.debug('dataAdapter', '数据时间范围裁剪完成 (trimDataByEventTime)', {
originalLength: data.length,
trimmedLength: trimmedData.length,
eventTime,
@@ -260,7 +260,7 @@ export const processChartData = (
data = trimDataByEventTime(data, eventTime, chartType);
}
logger.debug('dataAdapter', 'processChartData', '数据处理完成', {
logger.debug('dataAdapter', '数据处理完成 (processChartData)', {
rawLength: rawData.length,
processedLength: data.length,
chartType,

View File

@@ -27,7 +27,7 @@ export const createEventMarkerOverlay = (
const closestPoint = findClosestDataPoint(data, marker.timestamp);
if (!closestPoint) {
logger.warn('eventMarkerUtils', 'createEventMarkerOverlay', '未找到匹配的数据点', {
logger.warn('eventMarkerUtils', '未找到匹配的数据点', {
markerId: marker.id,
timestamp: marker.timestamp,
});
@@ -64,10 +64,12 @@ export const createEventMarkerOverlay = (
style: 'fill',
color: marker.color,
borderRadius: EVENT_MARKER_CONFIG.text.borderRadius,
paddingLeft: EVENT_MARKER_CONFIG.text.padding,
paddingRight: EVENT_MARKER_CONFIG.text.padding,
paddingTop: EVENT_MARKER_CONFIG.text.padding,
paddingBottom: EVENT_MARKER_CONFIG.text.padding,
padding: [
EVENT_MARKER_CONFIG.text.padding,
EVENT_MARKER_CONFIG.text.padding,
EVENT_MARKER_CONFIG.text.padding,
EVENT_MARKER_CONFIG.text.padding,
] as any,
},
},
// 标记文本内容
@@ -77,7 +79,7 @@ export const createEventMarkerOverlay = (
},
};
logger.debug('eventMarkerUtils', 'createEventMarkerOverlay', '创建事件标记', {
logger.debug('eventMarkerUtils', '创建事件标记', {
markerId: marker.id,
timestamp: closestPoint.timestamp,
label: marker.label,
@@ -108,7 +110,7 @@ export const createEventHighlightOverlay = (
const closestPoint = findClosestDataPoint(data, eventTimestamp);
if (!closestPoint) {
logger.warn('eventMarkerUtils', 'createEventHighlightOverlay', '未找到匹配的数据点');
logger.warn('eventMarkerUtils', '未找到匹配的数据点');
return null;
}
@@ -135,7 +137,7 @@ export const createEventHighlightOverlay = (
},
};
logger.debug('eventMarkerUtils', 'createEventHighlightOverlay', '创建事件高亮覆盖层', {
logger.debug('eventMarkerUtils', '创建事件高亮覆盖层', {
timestamp: closestPoint.timestamp,
eventTime,
});
@@ -219,7 +221,7 @@ export const createEventMarkerOverlays = (
}
});
logger.debug('eventMarkerUtils', 'createEventMarkerOverlays', '批量创建事件标记', {
logger.debug('eventMarkerUtils', '批量创建事件标记', {
totalMarkers: markers.length,
createdOverlays: overlays.length,
});
@@ -236,7 +238,7 @@ export const createEventMarkerOverlays = (
export const removeEventMarker = (chart: any, markerId: string): void => {
try {
chart.removeOverlay(markerId);
logger.debug('eventMarkerUtils', 'removeEventMarker', '移除事件标记', { markerId });
logger.debug('eventMarkerUtils', '移除事件标记', { markerId });
} catch (error) {
logger.error('eventMarkerUtils', 'removeEventMarker', error as Error, { markerId });
}
@@ -251,7 +253,7 @@ export const removeAllEventMarkers = (chart: any): void => {
try {
// KLineChart 10.0 API: removeOverlay() 不传参数时移除所有 overlays
chart.removeOverlay();
logger.debug('eventMarkerUtils', 'removeAllEventMarkers', '移除所有事件标记');
logger.debug('eventMarkerUtils', '移除所有事件标记');
} catch (error) {
logger.error('eventMarkerUtils', 'removeAllEventMarkers', error as Error);
}
@@ -276,7 +278,7 @@ export const updateEventMarker = (
// 重新创建标记KLineChart 10.0 不支持直接更新 overlay
// 注意:需要在调用方重新创建并添加 overlay
logger.debug('eventMarkerUtils', 'updateEventMarker', '更新事件标记', {
logger.debug('eventMarkerUtils', '更新事件标记', {
markerId,
updates,
});
@@ -309,7 +311,7 @@ export const highlightEventMarker = (
},
});
logger.debug('eventMarkerUtils', 'highlightEventMarker', '高亮事件标记', {
logger.debug('eventMarkerUtils', '高亮事件标记', {
markerId,
highlight,
});

View File

@@ -0,0 +1,204 @@
// src/views/AgentChat/components/ChatArea/ChatHeader.tsx
// 聊天区顶部标题栏组件
import React from 'react';
import { motion } from 'framer-motion';
import {
Box,
Avatar,
Badge,
Tooltip,
IconButton,
HStack,
Flex,
Text,
} from '@chakra-ui/react';
import { Menu, RefreshCw, Settings, Cpu, Zap } from 'lucide-react';
import { AVAILABLE_MODELS } from '../../constants/models';
/**
* ChatHeader 组件的 Props 类型
*/
interface ChatHeaderProps {
/** 当前选中的模型 ID */
selectedModel: string;
/** 左侧栏是否展开 */
isLeftSidebarOpen: boolean;
/** 右侧栏是否展开 */
isRightSidebarOpen: boolean;
/** 切换左侧栏回调 */
onToggleLeftSidebar: () => void;
/** 切换右侧栏回调 */
onToggleRightSidebar: () => void;
/** 新建会话回调 */
onNewSession: () => void;
}
/**
* ChatHeader - 聊天区顶部标题栏组件
*
* 职责:
* 1. 展示 AI 头像和标题
* 2. 显示当前模型徽章
* 3. 左侧栏/右侧栏切换按钮
* 4. 新建会话按钮
*
* 设计:
* - 深色毛玻璃背景
* - 旋转的 AI 头像动画
* - 渐变色标题和徽章
*/
const ChatHeader: React.FC<ChatHeaderProps> = ({
selectedModel,
isLeftSidebarOpen,
isRightSidebarOpen,
onToggleLeftSidebar,
onToggleRightSidebar,
onNewSession,
}) => {
const currentModel = AVAILABLE_MODELS.find((m) => m.id === selectedModel);
return (
<Box
bg="rgba(17, 24, 39, 0.8)"
backdropFilter="blur(20px) saturate(180%)"
borderBottom="1px solid"
borderColor="rgba(255, 255, 255, 0.1)"
px={6}
py={4}
boxShadow="0 8px 32px 0 rgba(31, 38, 135, 0.37)"
>
<Flex align="center" justify="space-between">
{/* 左侧:标题和徽章 */}
<HStack spacing={4}>
{/* 左侧栏切换按钮(仅在侧边栏收起时显示) */}
{!isLeftSidebarOpen && (
<motion.div whileHover={{ scale: 1.1 }} whileTap={{ scale: 0.9 }}>
<IconButton
size="sm"
variant="ghost"
icon={<Menu className="w-4 h-4" />}
onClick={onToggleLeftSidebar}
aria-label="展开左侧栏"
bg="rgba(255, 255, 255, 0.05)"
color="gray.400"
backdropFilter="blur(10px)"
border="1px solid"
borderColor="rgba(255, 255, 255, 0.1)"
_hover={{
bg: 'rgba(255, 255, 255, 0.1)',
color: 'white',
}}
/>
</motion.div>
)}
{/* AI 头像(旋转动画) */}
<motion.div
animate={{ rotate: 360 }}
transition={{ duration: 3, repeat: Infinity, ease: 'linear' }}
>
<Avatar
icon={<Cpu className="w-6 h-6" />}
bgGradient="linear(to-br, purple.500, pink.500)"
boxShadow="0 0 20px rgba(236, 72, 153, 0.5)"
/>
</motion.div>
{/* 标题和徽章 */}
<Box>
<Text
fontSize="xl"
fontWeight="bold"
bgGradient="linear(to-r, blue.400, purple.400)"
bgClip="text"
letterSpacing="tight"
>
AI
</Text>
<HStack spacing={2} mt={1}>
{/* 智能分析徽章 */}
<Badge
bgGradient="linear(to-r, green.500, teal.500)"
color="white"
px={2}
py={1}
borderRadius="md"
display="flex"
alignItems="center"
gap={1}
boxShadow="0 2px 8px rgba(16, 185, 129, 0.3)"
>
<Zap className="w-3 h-3" />
</Badge>
{/* 模型名称徽章 */}
<Badge
bgGradient="linear(to-r, purple.500, pink.500)"
color="white"
px={2}
py={1}
borderRadius="md"
boxShadow="0 2px 8px rgba(139, 92, 246, 0.3)"
>
{currentModel?.name || '未知模型'}
</Badge>
</HStack>
</Box>
</HStack>
{/* 右侧:操作按钮 */}
<HStack spacing={2}>
{/* 清空对话按钮 */}
<Tooltip label="清空对话">
<motion.div whileHover={{ scale: 1.1 }} whileTap={{ scale: 0.9 }}>
<IconButton
size="sm"
variant="ghost"
icon={<RefreshCw className="w-4 h-4" />}
onClick={onNewSession}
aria-label="清空对话"
bg="rgba(255, 255, 255, 0.05)"
color="gray.400"
backdropFilter="blur(10px)"
border="1px solid"
borderColor="rgba(255, 255, 255, 0.1)"
_hover={{
bg: 'rgba(255, 255, 255, 0.1)',
color: 'white',
borderColor: 'purple.400',
boxShadow: '0 0 12px rgba(139, 92, 246, 0.3)',
}}
/>
</motion.div>
</Tooltip>
{/* 右侧栏切换按钮(仅在侧边栏收起时显示) */}
{!isRightSidebarOpen && (
<motion.div whileHover={{ scale: 1.1 }} whileTap={{ scale: 0.9 }}>
<IconButton
size="sm"
variant="ghost"
icon={<Settings className="w-4 h-4" />}
onClick={onToggleRightSidebar}
aria-label="展开右侧栏"
bg="rgba(255, 255, 255, 0.05)"
color="gray.400"
backdropFilter="blur(10px)"
border="1px solid"
borderColor="rgba(255, 255, 255, 0.1)"
_hover={{
bg: 'rgba(255, 255, 255, 0.1)',
color: 'white',
}}
/>
</motion.div>
)}
</HStack>
</Flex>
</Box>
);
};
export default ChatHeader;

View File

@@ -0,0 +1,253 @@
// src/views/AgentChat/components/ChatArea/ChatInput.tsx
// 聊天输入框区域组件
import React from 'react';
import { motion } from 'framer-motion';
import {
Box,
Input,
IconButton,
Tooltip,
Kbd,
HStack,
Text,
Tag,
TagLabel,
TagCloseButton,
} from '@chakra-ui/react';
import { Send, Paperclip, Image as ImageIcon } from 'lucide-react';
import type { UploadedFile } from './types';
/**
* ChatInput 组件的 Props 类型
*/
interface ChatInputProps {
/** 输入框内容 */
inputValue: string;
/** 输入框变化回调 */
onInputChange: (value: string) => void;
/** 键盘事件回调 */
onKeyPress: (e: React.KeyboardEvent<HTMLInputElement>) => void;
/** 已上传文件列表 */
uploadedFiles: UploadedFile[];
/** 文件选择回调 */
onFileSelect: (e: React.ChangeEvent<HTMLInputElement>) => void;
/** 文件删除回调 */
onFileRemove: (index: number) => void;
/** 是否正在处理中 */
isProcessing: boolean;
/** 发送消息回调 */
onSendMessage: () => void;
/** 输入框引用 */
inputRef: React.RefObject<HTMLInputElement>;
/** 文件上传输入引用 */
fileInputRef: React.RefObject<HTMLInputElement>;
}
/**
* ChatInput - 聊天输入框区域组件
*
* 职责:
* 1. 文件上传按钮Paperclip, Image
* 2. 输入框
* 3. 发送按钮
* 4. 已上传文件预览
* 5. 快捷键提示
*
* 设计:
* - 深色毛玻璃背景
* - 渐变色发送按钮
* - 悬停动画效果
* - 响应式最大宽度896px
*/
const ChatInput: React.FC<ChatInputProps> = ({
inputValue,
onInputChange,
onKeyPress,
uploadedFiles,
onFileSelect,
onFileRemove,
isProcessing,
onSendMessage,
inputRef,
fileInputRef,
}) => {
return (
<Box
bg="rgba(17, 24, 39, 0.8)"
backdropFilter="blur(20px) saturate(180%)"
borderTop="1px solid"
borderColor="rgba(255, 255, 255, 0.1)"
px={6}
py={1}
boxShadow="0 -8px 32px 0 rgba(31, 38, 135, 0.37)"
>
<Box maxW="896px" mx="auto">
{/* 已上传文件预览 */}
{uploadedFiles.length > 0 && (
<HStack mb={3} flexWrap="wrap" spacing={2}>
{uploadedFiles.map((file, idx) => (
<motion.div
key={idx}
initial={{ opacity: 0, scale: 0.8 }}
animate={{ opacity: 1, scale: 1 }}
exit={{ opacity: 0, scale: 0.8 }}
>
<Tag
size="md"
variant="subtle"
bg="rgba(255, 255, 255, 0.05)"
backdropFilter="blur(10px)"
borderColor="rgba(255, 255, 255, 0.1)"
borderWidth={1}
>
<TagLabel color="gray.300">{file.name}</TagLabel>
<TagCloseButton onClick={() => onFileRemove(idx)} color="gray.400" />
</Tag>
</motion.div>
))}
</HStack>
)}
{/* 输入栏 */}
<HStack spacing={2}>
{/* 隐藏的文件上传输入 */}
<input
ref={fileInputRef}
type="file"
multiple
accept="image/*,.pdf,.doc,.docx,.txt"
onChange={onFileSelect}
style={{ display: 'none' }}
/>
{/* 上传文件按钮 */}
<Tooltip label="上传文件">
<motion.div whileHover={{ scale: 1.05 }} whileTap={{ scale: 0.95 }}>
<IconButton
variant="ghost"
size="lg"
icon={<Paperclip className="w-5 h-5" />}
onClick={() => fileInputRef.current?.click()}
aria-label="上传文件"
bg="rgba(255, 255, 255, 0.05)"
color="gray.300"
backdropFilter="blur(10px)"
border="1px solid"
borderColor="rgba(255, 255, 255, 0.1)"
_hover={{
bg: 'rgba(255, 255, 255, 0.1)',
borderColor: 'purple.400',
color: 'white',
boxShadow: '0 0 12px rgba(139, 92, 246, 0.3)',
}}
/>
</motion.div>
</Tooltip>
{/* 上传图片按钮 */}
<Tooltip label="上传图片">
<motion.div whileHover={{ scale: 1.05 }} whileTap={{ scale: 0.95 }}>
<IconButton
variant="ghost"
size="lg"
icon={<ImageIcon className="w-5 h-5" />}
onClick={() => {
fileInputRef.current?.setAttribute('accept', 'image/*');
fileInputRef.current?.click();
}}
aria-label="上传图片"
bg="rgba(255, 255, 255, 0.05)"
color="gray.300"
backdropFilter="blur(10px)"
border="1px solid"
borderColor="rgba(255, 255, 255, 0.1)"
_hover={{
bg: 'rgba(255, 255, 255, 0.1)',
borderColor: 'purple.400',
color: 'white',
boxShadow: '0 0 12px rgba(139, 92, 246, 0.3)',
}}
/>
</motion.div>
</Tooltip>
{/* 输入框 */}
<Input
ref={inputRef}
value={inputValue}
onChange={(e) => onInputChange(e.target.value)}
onKeyDown={onKeyPress}
placeholder="输入你的问题... (Enter 发送, Shift+Enter 换行)"
isDisabled={isProcessing}
size="lg"
variant="outline"
borderWidth={2}
bg="rgba(255, 255, 255, 0.05)"
backdropFilter="blur(10px)"
border="1px solid"
borderColor="rgba(255, 255, 255, 0.1)"
color="white"
_placeholder={{ color: 'gray.500' }}
_hover={{
borderColor: 'rgba(255, 255, 255, 0.2)',
}}
_focus={{
borderColor: 'purple.400',
boxShadow: '0 0 0 1px var(--chakra-colors-purple-400), 0 0 12px rgba(139, 92, 246, 0.3)',
bg: 'rgba(255, 255, 255, 0.08)',
}}
/>
{/* 发送按钮 */}
<motion.div
whileHover={{ scale: 1.05 }}
whileTap={{ scale: 0.95 }}
transition={{ type: 'spring', stiffness: 400, damping: 17 }}
>
<IconButton
size="lg"
icon={!isProcessing ? <Send className="w-5 h-5" /> : undefined}
onClick={onSendMessage}
isLoading={isProcessing}
isDisabled={!inputValue.trim() || isProcessing}
aria-label="发送消息"
bgGradient="linear(to-r, blue.500, purple.600)"
color="white"
_hover={{
bgGradient: 'linear(to-r, blue.600, purple.700)',
boxShadow: '0 8px 20px rgba(139, 92, 246, 0.4)',
}}
_active={{
transform: 'translateY(0)',
boxShadow: '0 4px 12px rgba(139, 92, 246, 0.3)',
}}
/>
</motion.div>
</HStack>
{/* 快捷键提示 */}
<HStack spacing={4} mt={2} fontSize="xs" color="gray.500">
<HStack spacing={1}>
<Kbd bg="rgba(255, 255, 255, 0.05)" color="gray.400" borderColor="rgba(255, 255, 255, 0.1)">
Enter
</Kbd>
<Text></Text>
</HStack>
<HStack spacing={1}>
<Kbd bg="rgba(255, 255, 255, 0.05)" color="gray.400" borderColor="rgba(255, 255, 255, 0.1)">
Shift
</Kbd>
<Text>+</Text>
<Kbd bg="rgba(255, 255, 255, 0.05)" color="gray.400" borderColor="rgba(255, 255, 255, 0.1)">
Enter
</Kbd>
<Text></Text>
</HStack>
</HStack>
</Box>
</Box>
);
};
export default ChatInput;

View File

@@ -1,5 +1,5 @@
// src/views/AgentChat/components/ChatArea/ExecutionStepsDisplay.js
// 执行步骤显示组件
// src/views/AgentChat/components/ChatArea/ExecutionStepsDisplay.tsx
// 执行步骤显示组件TypeScript 版本)
import React from 'react';
import { motion } from 'framer-motion';
@@ -18,16 +18,32 @@ import {
Text,
} from '@chakra-ui/react';
import { Activity } from 'lucide-react';
import type { ExecutionStep } from './types';
/**
* ExecutionStepsDisplay Props
*/
interface ExecutionStepsDisplayProps {
/** 执行步骤列表 */
steps: ExecutionStep[];
/** 执行计划(可选) */
plan?: unknown;
}
/**
* ExecutionStepsDisplay -
*
* @param {Object} props
* @param {Array} props.steps -
* @param {Object} props.plan -
* @returns {JSX.Element}
*
* 1.
* 2.
* 3.
*
*
* -
* - /
* -
*/
const ExecutionStepsDisplay = ({ steps, plan }) => {
const ExecutionStepsDisplay: React.FC<ExecutionStepsDisplayProps> = ({ steps, plan }) => {
return (
<Accordion allowToggle>
<AccordionItem
@@ -41,6 +57,7 @@ const ExecutionStepsDisplay = ({ steps, plan }) => {
borderColor: 'rgba(255, 255, 255, 0.2)',
}}
>
{/* 手风琴标题 */}
<AccordionButton px={4} py={2}>
<HStack flex={1} spacing={2}>
<Activity className="w-4 h-4" color="#C084FC" />
@@ -58,6 +75,8 @@ const ExecutionStepsDisplay = ({ steps, plan }) => {
</HStack>
<AccordionIcon color="gray.400" />
</AccordionButton>
{/* 手风琴内容 */}
<AccordionPanel pb={4}>
<VStack spacing={2} align="stretch">
{steps.map((result, idx) => (
@@ -75,9 +94,12 @@ const ExecutionStepsDisplay = ({ steps, plan }) => {
>
<CardBody p={3}>
<Flex align="start" justify="space-between" gap={2}>
{/* 步骤名称 */}
<Text fontSize="xs" fontWeight="medium" color="gray.300">
{idx + 1}: {result.tool_name}
</Text>
{/* 状态徽章 */}
<Badge
bgGradient={
result.status === 'success'
@@ -95,9 +117,15 @@ const ExecutionStepsDisplay = ({ steps, plan }) => {
{result.status}
</Badge>
</Flex>
<Text fontSize="xs" color="gray.500" mt={1}>
{result.execution_time?.toFixed(2)}s
</Text>
{/* 执行耗时 */}
{result.execution_time !== undefined && (
<Text fontSize="xs" color="gray.500" mt={1}>
{result.execution_time.toFixed(2)}s
</Text>
)}
{/* 错误信息 */}
{result.error && (
<Text fontSize="xs" color="red.400" mt={1}>
{result.error}

View File

@@ -0,0 +1,78 @@
// src/views/AgentChat/components/ChatArea/MessageList.tsx
// 消息列表组件
import React from 'react';
import { motion, AnimatePresence } from 'framer-motion';
import { Box, VStack } from '@chakra-ui/react';
import { animations } from '../../constants/animations';
import MessageRenderer from './MessageRenderer';
import type { Message } from './types';
/**
* MessageList 组件的 Props 类型
*/
interface MessageListProps {
/** 消息列表 */
messages: Message[];
/** 用户头像 URL */
userAvatar?: string;
/** 消息列表底部引用(用于自动滚动) */
messagesEndRef: React.RefObject<HTMLDivElement>;
}
/**
* MessageList - 消息列表组件
*
* 职责:
* 1. 渲染消息列表容器
* 2. 处理消息动画(进入/退出)
* 3. 提供自动滚动锚点
*
* 设计:
* - 渐变色背景
* - 消息淡入上滑动画
* - 退出消息淡出动画
* - 响应式最大宽度896px
*/
const MessageList: React.FC<MessageListProps> = ({
messages,
userAvatar,
messagesEndRef,
}) => {
return (
<Box
flex={1}
bgGradient="linear(to-b, rgba(17, 24, 39, 0.5), rgba(17, 24, 39, 0.3))"
overflowY="auto"
>
<motion.div
style={{ maxWidth: '896px', margin: '0 auto' }}
variants={animations.staggerContainer}
initial="initial"
animate="animate"
>
<VStack spacing={4} align="stretch">
<AnimatePresence mode="popLayout">
{messages.map((message) => (
<motion.div
key={message.id}
variants={animations.fadeInUp}
initial="initial"
animate="animate"
exit={{ opacity: 0, y: -20 }}
layout
>
<MessageRenderer message={message} userAvatar={userAvatar} />
</motion.div>
))}
</AnimatePresence>
{/* 自动滚动锚点 */}
<div ref={messagesEndRef} />
</VStack>
</motion.div>
</Box>
);
};
export default MessageList;

View File

@@ -1,5 +1,5 @@
// src/views/AgentChat/components/ChatArea/MessageRenderer.js
// 消息渲染器组件
// src/views/AgentChat/components/ChatArea/MessageRenderer.tsx
// 消息渲染器组件TypeScript 版本)
import React from 'react';
import { motion } from 'framer-motion';
@@ -19,17 +19,31 @@ import {
import { Cpu, User, Copy, ThumbsUp, ThumbsDown, File } from 'lucide-react';
import { MessageTypes } from '../../constants/messageTypes';
import ExecutionStepsDisplay from './ExecutionStepsDisplay';
import type { Message } from './types';
/**
* MessageRenderer Props
*/
interface MessageRendererProps {
/** 消息对象 */
message: Message;
/** 用户头像 URL */
userAvatar?: string;
}
/**
* MessageRenderer -
*
* @param {Object} props
* @param {Object} props.message -
* @param {string} props.userAvatar - URL
* @returns {JSX.Element|null}
*
* 1.
* 2.
* 3. AI
* 4. AI
* 5.
*/
const MessageRenderer = ({ message, userAvatar }) => {
const MessageRenderer: React.FC<MessageRendererProps> = ({ message, userAvatar }) => {
switch (message.type) {
// 用户消息
case MessageTypes.USER:
return (
<Flex justify="flex-end">
@@ -78,6 +92,7 @@ const MessageRenderer = ({ message, userAvatar }) => {
</Flex>
);
// AI 思考中
case MessageTypes.AGENT_THINKING:
return (
<Flex justify="flex-start">
@@ -116,6 +131,7 @@ const MessageRenderer = ({ message, userAvatar }) => {
</Flex>
);
// AI 回复
case MessageTypes.AGENT_RESPONSE:
return (
<Flex justify="flex-start">
@@ -139,16 +155,19 @@ const MessageRenderer = ({ message, userAvatar }) => {
boxShadow="0 8px 32px 0 rgba(31, 38, 135, 0.37)"
>
<CardBody px={5} py={3}>
{/* 消息内容 */}
<Text fontSize="sm" color="gray.100" whiteSpace="pre-wrap" lineHeight="relaxed">
{message.content}
</Text>
{message.stepResults && message.stepResults.length > 0 && (
{/* 执行步骤(如果有) */}
{message.execution_results && message.execution_results.length > 0 && (
<Box mt={3}>
<ExecutionStepsDisplay steps={message.stepResults} plan={message.plan} />
<ExecutionStepsDisplay steps={message.execution_results} plan={message.plan} />
</Box>
)}
{/* 操作按钮栏 */}
<Flex
align="center"
gap={2}
@@ -157,6 +176,7 @@ const MessageRenderer = ({ message, userAvatar }) => {
borderTop="1px solid"
borderColor="rgba(255, 255, 255, 0.1)"
>
{/* 复制按钮 */}
<Tooltip label="复制">
<motion.div whileHover={{ scale: 1.1 }} whileTap={{ scale: 0.9 }}>
<IconButton
@@ -164,6 +184,7 @@ const MessageRenderer = ({ message, userAvatar }) => {
variant="ghost"
icon={<Copy className="w-4 h-4" />}
onClick={() => navigator.clipboard.writeText(message.content)}
aria-label="复制消息"
bg="rgba(255, 255, 255, 0.05)"
color="gray.400"
_hover={{
@@ -173,12 +194,15 @@ const MessageRenderer = ({ message, userAvatar }) => {
/>
</motion.div>
</Tooltip>
{/* 点赞按钮 */}
<Tooltip label="点赞">
<motion.div whileHover={{ scale: 1.1 }} whileTap={{ scale: 0.9 }}>
<IconButton
size="sm"
variant="ghost"
icon={<ThumbsUp className="w-4 h-4" />}
aria-label="点赞"
bg="rgba(255, 255, 255, 0.05)"
color="gray.400"
_hover={{
@@ -189,12 +213,15 @@ const MessageRenderer = ({ message, userAvatar }) => {
/>
</motion.div>
</Tooltip>
{/* 点踩按钮 */}
<Tooltip label="点踩">
<motion.div whileHover={{ scale: 1.1 }} whileTap={{ scale: 0.9 }}>
<IconButton
size="sm"
variant="ghost"
icon={<ThumbsDown className="w-4 h-4" />}
aria-label="点踩"
bg="rgba(255, 255, 255, 0.05)"
color="gray.400"
_hover={{
@@ -205,11 +232,14 @@ const MessageRenderer = ({ message, userAvatar }) => {
/>
</motion.div>
</Tooltip>
{/* 时间戳 */}
<Text fontSize="xs" color="gray.500" ml="auto">
{new Date(message.timestamp).toLocaleTimeString('zh-CN', {
hour: '2-digit',
minute: '2-digit',
})}
{message.timestamp &&
new Date(message.timestamp).toLocaleTimeString('zh-CN', {
hour: '2-digit',
minute: '2-digit',
})}
</Text>
</Flex>
</CardBody>
@@ -219,6 +249,7 @@ const MessageRenderer = ({ message, userAvatar }) => {
</Flex>
);
// 错误消息
case MessageTypes.ERROR:
return (
<Flex justify="center">

View File

@@ -0,0 +1,112 @@
// src/views/AgentChat/components/ChatArea/QuickQuestions.tsx
// 快捷问题卡片组件
import React from 'react';
import { motion, AnimatePresence } from 'framer-motion';
import { Box, Button, HStack, Text } from '@chakra-ui/react';
import { Sparkles } from 'lucide-react';
import { quickQuestions } from '../../constants/quickQuestions';
import { animations } from '../../constants/animations';
/**
* QuickQuestions 组件的 Props 类型
*/
interface QuickQuestionsProps {
/** 消息列表长度(用于判断是否显示) */
messagesCount: number;
/** 是否正在处理中 */
isProcessing: boolean;
/** 点击快捷问题回调 */
onQuestionClick: (text: string) => void;
/** 输入框引用(用于聚焦) */
inputRef: React.RefObject<HTMLInputElement>;
}
/**
* QuickQuestions - 快捷问题卡片组件
*
* 职责:
* 1. 显示快捷问题卡片网格
* 2. 仅在消息少于 2 条且未处理时显示
* 3. 点击填充输入框并聚焦
*
* 设计:
* - 2 列网格布局
* - 毛玻璃效果卡片
* - 悬停缩放和高亮动画
* - 淡入上滑进入动画
*/
const QuickQuestions: React.FC<QuickQuestionsProps> = ({
messagesCount,
isProcessing,
onQuestionClick,
inputRef,
}) => {
// 只在消息少于 2 条且未处理时显示
if (messagesCount > 2 || isProcessing) {
return null;
}
const handleQuestionClick = (text: string) => {
onQuestionClick(text);
inputRef.current?.focus();
};
return (
<AnimatePresence>
<motion.div
variants={animations.fadeInUp}
initial="initial"
animate="animate"
exit={{ opacity: 0, y: 20 }}
>
<Box px={6}>
<Box maxW="896px" mx="auto">
{/* 标题 */}
<HStack fontSize="xs" color="gray.500" mb={2} fontWeight="medium" spacing={1}>
<Sparkles className="w-3 h-3" />
<Text></Text>
</HStack>
{/* 快捷问题网格 */}
<Box display="grid" gridTemplateColumns="repeat(2, 1fr)" gap={2}>
{quickQuestions.map((question, idx) => (
<motion.div
key={idx}
whileHover={{ scale: 1.02, y: -2 }}
whileTap={{ scale: 0.98 }}
transition={{ type: 'spring', stiffness: 400, damping: 17 }}
>
<Button
variant="outline"
w="full"
justifyContent="flex-start"
h="auto"
py={3}
bg="rgba(255, 255, 255, 0.05)"
backdropFilter="blur(12px)"
border="1px solid"
borderColor="rgba(255, 255, 255, 0.1)"
color="gray.300"
_hover={{
bg: 'rgba(59, 130, 246, 0.15)',
borderColor: 'blue.400',
boxShadow: '0 4px 12px rgba(59, 130, 246, 0.3)',
color: 'white',
}}
onClick={() => handleQuestionClick(question.text)}
>
<Text mr={2}>{question.emoji}</Text>
<Text>{question.text}</Text>
</Button>
</motion.div>
))}
</Box>
</Box>
</Box>
</motion.div>
</AnimatePresence>
);
};
export default QuickQuestions;

View File

@@ -1,477 +0,0 @@
// src/views/AgentChat/components/ChatArea/index.js
// 中间聊天区域组件
import React, { useRef, useEffect } from 'react';
import { motion, AnimatePresence } from 'framer-motion';
import {
Box,
Button,
Input,
Avatar,
Badge,
Tooltip,
IconButton,
Kbd,
HStack,
VStack,
Flex,
Text,
Tag,
TagLabel,
TagCloseButton,
} from '@chakra-ui/react';
import {
Send,
Menu,
RefreshCw,
Settings,
Cpu,
Zap,
Sparkles,
Paperclip,
Image as ImageIcon,
} from 'lucide-react';
import { AVAILABLE_MODELS } from '../../constants/models';
import { quickQuestions } from '../../constants/quickQuestions';
import { animations } from '../../constants/animations';
import MessageRenderer from './MessageRenderer';
/**
* ChatArea - 中间聊天区域组件
*
* @param {Object} props
* @param {Array} props.messages - 消息列表
* @param {string} props.inputValue - 输入框内容
* @param {Function} props.onInputChange - 输入框变化回调
* @param {boolean} props.isProcessing - 处理中状态
* @param {Function} props.onSendMessage - 发送消息回调
* @param {Function} props.onKeyPress - 键盘事件回调
* @param {Array} props.uploadedFiles - 已上传文件列表
* @param {Function} props.onFileSelect - 文件选择回调
* @param {Function} props.onFileRemove - 文件删除回调
* @param {string} props.selectedModel - 当前选中的模型 ID
* @param {boolean} props.isLeftSidebarOpen - 左侧栏是否展开
* @param {boolean} props.isRightSidebarOpen - 右侧栏是否展开
* @param {Function} props.onToggleLeftSidebar - 切换左侧栏回调
* @param {Function} props.onToggleRightSidebar - 切换右侧栏回调
* @param {Function} props.onNewSession - 新建会话回调
* @param {string} props.userAvatar - 用户头像 URL
* @param {RefObject} props.inputRef - 输入框引用
* @param {RefObject} props.fileInputRef - 文件上传输入引用
* @returns {JSX.Element}
*/
const ChatArea = ({
messages,
inputValue,
onInputChange,
isProcessing,
onSendMessage,
onKeyPress,
uploadedFiles,
onFileSelect,
onFileRemove,
selectedModel,
isLeftSidebarOpen,
isRightSidebarOpen,
onToggleLeftSidebar,
onToggleRightSidebar,
onNewSession,
userAvatar,
inputRef,
fileInputRef,
}) => {
// Auto-scroll 功能:当消息列表更新时,自动滚动到底部
const messagesEndRef = useRef(null);
useEffect(() => {
messagesEndRef.current?.scrollIntoView({ behavior: 'smooth' });
}, [messages]);
return (
<Flex flex={1} direction="column">
{/* 顶部标题栏 - 深色毛玻璃 */}
<Box
bg="rgba(17, 24, 39, 0.8)"
backdropFilter="blur(20px) saturate(180%)"
borderBottom="1px solid"
borderColor="rgba(255, 255, 255, 0.1)"
px={6}
py={4}
boxShadow="0 8px 32px 0 rgba(31, 38, 135, 0.37)"
>
<Flex align="center" justify="space-between">
<HStack spacing={4}>
{!isLeftSidebarOpen && (
<motion.div whileHover={{ scale: 1.1 }} whileTap={{ scale: 0.9 }}>
<IconButton
size="sm"
variant="ghost"
icon={<Menu className="w-4 h-4" />}
onClick={onToggleLeftSidebar}
bg="rgba(255, 255, 255, 0.05)"
color="gray.400"
backdropFilter="blur(10px)"
border="1px solid"
borderColor="rgba(255, 255, 255, 0.1)"
_hover={{
bg: 'rgba(255, 255, 255, 0.1)',
color: 'white',
}}
/>
</motion.div>
)}
<motion.div
animate={{ rotate: 360 }}
transition={{ duration: 3, repeat: Infinity, ease: 'linear' }}
>
<Avatar
icon={<Cpu className="w-6 h-6" />}
bgGradient="linear(to-br, purple.500, pink.500)"
boxShadow="0 0 20px rgba(236, 72, 153, 0.5)"
/>
</motion.div>
<Box>
<Text
fontSize="xl"
fontWeight="bold"
bgGradient="linear(to-r, blue.400, purple.400)"
bgClip="text"
letterSpacing="tight"
>
价小前投研 AI
</Text>
<HStack spacing={2} mt={1}>
<Badge
bgGradient="linear(to-r, green.500, teal.500)"
color="white"
px={2}
py={1}
borderRadius="md"
display="flex"
alignItems="center"
gap={1}
boxShadow="0 2px 8px rgba(16, 185, 129, 0.3)"
>
<Zap className="w-3 h-3" />
智能分析
</Badge>
<Badge
bgGradient="linear(to-r, purple.500, pink.500)"
color="white"
px={2}
py={1}
borderRadius="md"
boxShadow="0 2px 8px rgba(139, 92, 246, 0.3)"
>
{AVAILABLE_MODELS.find((m) => m.id === selectedModel)?.name}
</Badge>
</HStack>
</Box>
</HStack>
<HStack spacing={2}>
<Tooltip label="清空对话">
<motion.div whileHover={{ scale: 1.1 }} whileTap={{ scale: 0.9 }}>
<IconButton
size="sm"
variant="ghost"
icon={<RefreshCw className="w-4 h-4" />}
onClick={onNewSession}
bg="rgba(255, 255, 255, 0.05)"
color="gray.400"
backdropFilter="blur(10px)"
border="1px solid"
borderColor="rgba(255, 255, 255, 0.1)"
_hover={{
bg: 'rgba(255, 255, 255, 0.1)',
color: 'white',
borderColor: 'purple.400',
boxShadow: '0 0 12px rgba(139, 92, 246, 0.3)',
}}
/>
</motion.div>
</Tooltip>
{!isRightSidebarOpen && (
<motion.div whileHover={{ scale: 1.1 }} whileTap={{ scale: 0.9 }}>
<IconButton
size="sm"
variant="ghost"
icon={<Settings className="w-4 h-4" />}
onClick={onToggleRightSidebar}
bg="rgba(255, 255, 255, 0.05)"
color="gray.400"
backdropFilter="blur(10px)"
border="1px solid"
borderColor="rgba(255, 255, 255, 0.1)"
_hover={{
bg: 'rgba(255, 255, 255, 0.1)',
color: 'white',
}}
/>
</motion.div>
)}
</HStack>
</Flex>
</Box>
{/* 消息列表 */}
<Box
flex={1}
bgGradient="linear(to-b, rgba(17, 24, 39, 0.5), rgba(17, 24, 39, 0.3))"
overflowY="auto"
>
<motion.div
style={{ maxWidth: '896px', margin: '0 auto' }}
variants={animations.staggerContainer}
initial="initial"
animate="animate"
>
<VStack spacing={4} align="stretch">
<AnimatePresence mode="popLayout">
{messages.map((message) => (
<motion.div
key={message.id}
variants={animations.fadeInUp}
initial="initial"
animate="animate"
exit={{ opacity: 0, y: -20 }}
layout
>
<MessageRenderer message={message} userAvatar={userAvatar} />
</motion.div>
))}
</AnimatePresence>
<div ref={messagesEndRef} />
</VStack>
</motion.div>
</Box>
{/* 快捷问题 */}
<AnimatePresence>
{messages.length <= 2 && !isProcessing && (
<motion.div
variants={animations.fadeInUp}
initial="initial"
animate="animate"
exit={{ opacity: 0, y: 20 }}
>
<Box px={6}>
<Box maxW="896px" mx="auto">
<HStack fontSize="xs" color="gray.500" mb={2} fontWeight="medium" spacing={1}>
<Sparkles className="w-3 h-3" />
<Text>快速开始</Text>
</HStack>
<Box display="grid" gridTemplateColumns="repeat(2, 1fr)" gap={2}>
{quickQuestions.map((question, idx) => (
<motion.div
key={idx}
whileHover={{ scale: 1.02, y: -2 }}
whileTap={{ scale: 0.98 }}
transition={{ type: 'spring', stiffness: 400, damping: 17 }}
>
<Button
variant="outline"
w="full"
justifyContent="flex-start"
h="auto"
py={3}
bg="rgba(255, 255, 255, 0.05)"
backdropFilter="blur(12px)"
border="1px solid"
borderColor="rgba(255, 255, 255, 0.1)"
color="gray.300"
_hover={{
bg: 'rgba(59, 130, 246, 0.15)',
borderColor: 'blue.400',
boxShadow: '0 4px 12px rgba(59, 130, 246, 0.3)',
color: 'white',
}}
onClick={() => {
onInputChange(question.text);
inputRef.current?.focus();
}}
>
<Text mr={2}>{question.emoji}</Text>
<Text>{question.text}</Text>
</Button>
</motion.div>
))}
</Box>
</Box>
</Box>
</motion.div>
)}
</AnimatePresence>
{/* 输入栏 - 深色毛玻璃 */}
<Box
bg="rgba(17, 24, 39, 0.8)"
backdropFilter="blur(20px) saturate(180%)"
borderTop="1px solid"
borderColor="rgba(255, 255, 255, 0.1)"
px={6}
py={1}
boxShadow="0 -8px 32px 0 rgba(31, 38, 135, 0.37)"
>
<Box maxW="896px" mx="auto">
{/* 已上传文件预览 */}
{uploadedFiles.length > 0 && (
<HStack mb={3} flexWrap="wrap" spacing={2}>
{uploadedFiles.map((file, idx) => (
<motion.div
key={idx}
initial={{ opacity: 0, scale: 0.8 }}
animate={{ opacity: 1, scale: 1 }}
exit={{ opacity: 0, scale: 0.8 }}
>
<Tag
size="md"
variant="subtle"
bg="rgba(255, 255, 255, 0.05)"
backdropFilter="blur(10px)"
borderColor="rgba(255, 255, 255, 0.1)"
borderWidth={1}
>
<TagLabel color="gray.300">{file.name}</TagLabel>
<TagCloseButton onClick={() => onFileRemove(idx)} color="gray.400" />
</Tag>
</motion.div>
))}
</HStack>
)}
<HStack spacing={2}>
<input
ref={fileInputRef}
type="file"
multiple
accept="image/*,.pdf,.doc,.docx,.txt"
onChange={onFileSelect}
style={{ display: 'none' }}
/>
<Tooltip label="上传文件">
<motion.div whileHover={{ scale: 1.05 }} whileTap={{ scale: 0.95 }}>
<IconButton
variant="ghost"
size="lg"
icon={<Paperclip className="w-5 h-5" />}
onClick={() => fileInputRef.current?.click()}
bg="rgba(255, 255, 255, 0.05)"
color="gray.300"
backdropFilter="blur(10px)"
border="1px solid"
borderColor="rgba(255, 255, 255, 0.1)"
_hover={{
bg: 'rgba(255, 255, 255, 0.1)',
borderColor: 'purple.400',
color: 'white',
boxShadow: '0 0 12px rgba(139, 92, 246, 0.3)',
}}
/>
</motion.div>
</Tooltip>
<Tooltip label="上传图片">
<motion.div whileHover={{ scale: 1.05 }} whileTap={{ scale: 0.95 }}>
<IconButton
variant="ghost"
size="lg"
icon={<ImageIcon className="w-5 h-5" />}
onClick={() => {
fileInputRef.current?.setAttribute('accept', 'image/*');
fileInputRef.current?.click();
}}
bg="rgba(255, 255, 255, 0.05)"
color="gray.300"
backdropFilter="blur(10px)"
border="1px solid"
borderColor="rgba(255, 255, 255, 0.1)"
_hover={{
bg: 'rgba(255, 255, 255, 0.1)',
borderColor: 'purple.400',
color: 'white',
boxShadow: '0 0 12px rgba(139, 92, 246, 0.3)',
}}
/>
</motion.div>
</Tooltip>
<Input
ref={inputRef}
value={inputValue}
onChange={(e) => onInputChange(e.target.value)}
onKeyDown={onKeyPress}
placeholder="输入你的问题... (Enter 发送, Shift+Enter 换行)"
isDisabled={isProcessing}
size="lg"
variant="outline"
borderWidth={2}
bg="rgba(255, 255, 255, 0.05)"
backdropFilter="blur(10px)"
border="1px solid"
borderColor="rgba(255, 255, 255, 0.1)"
color="white"
_placeholder={{ color: 'gray.500' }}
_hover={{
borderColor: 'rgba(255, 255, 255, 0.2)',
}}
_focus={{
borderColor: 'purple.400',
boxShadow:
'0 0 0 1px var(--chakra-colors-purple-400), 0 0 12px rgba(139, 92, 246, 0.3)',
bg: 'rgba(255, 255, 255, 0.08)',
}}
/>
<motion.div
whileHover={{ scale: 1.05 }}
whileTap={{ scale: 0.95 }}
transition={{ type: 'spring', stiffness: 400, damping: 17 }}
>
<IconButton
size="lg"
icon={!isProcessing && <Send className="w-5 h-5" />}
onClick={onSendMessage}
isLoading={isProcessing}
isDisabled={!inputValue.trim() || isProcessing}
bgGradient="linear(to-r, blue.500, purple.600)"
color="white"
_hover={{
bgGradient: 'linear(to-r, blue.600, purple.700)',
boxShadow: '0 8px 20px rgba(139, 92, 246, 0.4)',
}}
_active={{
transform: 'translateY(0)',
boxShadow: '0 4px 12px rgba(139, 92, 246, 0.3)',
}}
/>
</motion.div>
</HStack>
<HStack spacing={4} mt={2} fontSize="xs" color="gray.500">
<HStack spacing={1}>
<Kbd bg="rgba(255, 255, 255, 0.05)" color="gray.400" borderColor="rgba(255, 255, 255, 0.1)">
Enter
</Kbd>
<Text>发送</Text>
</HStack>
<HStack spacing={1}>
<Kbd bg="rgba(255, 255, 255, 0.05)" color="gray.400" borderColor="rgba(255, 255, 255, 0.1)">
Shift
</Kbd>
<Text>+</Text>
<Kbd bg="rgba(255, 255, 255, 0.05)" color="gray.400" borderColor="rgba(255, 255, 255, 0.1)">
Enter
</Kbd>
<Text>换行</Text>
</HStack>
</HStack>
</Box>
</Box>
</Flex>
);
};
export default ChatArea;

View File

@@ -0,0 +1,141 @@
// src/views/AgentChat/components/ChatArea/index.tsx
// 中间聊天区域组件(重构版本)
import React, { useRef, useEffect } from 'react';
import { Flex } from '@chakra-ui/react';
import ChatHeader from './ChatHeader';
import MessageList from './MessageList';
import QuickQuestions from './QuickQuestions';
import ChatInput from './ChatInput';
import type { Message, UploadedFile } from './types';
/**
* ChatArea 组件的 Props 类型
*/
interface ChatAreaProps {
/** 消息列表 */
messages: Message[];
/** 输入框内容 */
inputValue: string;
/** 输入框变化回调 */
onInputChange: (value: string) => void;
/** 处理中状态 */
isProcessing: boolean;
/** 发送消息回调 */
onSendMessage: () => void;
/** 键盘事件回调 */
onKeyPress: (e: React.KeyboardEvent<HTMLInputElement>) => void;
/** 已上传文件列表 */
uploadedFiles: UploadedFile[];
/** 文件选择回调 */
onFileSelect: (e: React.ChangeEvent<HTMLInputElement>) => void;
/** 文件删除回调 */
onFileRemove: (index: number) => void;
/** 当前选中的模型 ID */
selectedModel: string;
/** 左侧栏是否展开 */
isLeftSidebarOpen: boolean;
/** 右侧栏是否展开 */
isRightSidebarOpen: boolean;
/** 切换左侧栏回调 */
onToggleLeftSidebar: () => void;
/** 切换右侧栏回调 */
onToggleRightSidebar: () => void;
/** 新建会话回调 */
onNewSession: () => void;
/** 用户头像 URL */
userAvatar?: string;
/** 输入框引用 */
inputRef: React.RefObject<HTMLInputElement>;
/** 文件上传输入引用 */
fileInputRef: React.RefObject<HTMLInputElement>;
}
/**
* ChatArea - 中间聊天区域组件(重构版本)
*
* 架构改进:
* - 顶部标题栏提取到 ChatHeader 组件100 行)
* - 消息列表提取到 MessageList 组件120 行)
* - 快捷问题提取到 QuickQuestions 组件80 行)
* - 输入框区域提取到 ChatInput 组件150 行)
* - MessageRenderer 和 ExecutionStepsDisplay 已迁移为 TS
*
* 主组件职责:
* 1. 管理消息列表自动滚动
* 2. 组合渲染所有子组件
* 3. 布局控制Flex 容器)
*/
const ChatArea: React.FC<ChatAreaProps> = ({
messages,
inputValue,
onInputChange,
isProcessing,
onSendMessage,
onKeyPress,
uploadedFiles,
onFileSelect,
onFileRemove,
selectedModel,
isLeftSidebarOpen,
isRightSidebarOpen,
onToggleLeftSidebar,
onToggleRightSidebar,
onNewSession,
userAvatar,
inputRef,
fileInputRef,
}) => {
// ==================== 自动滚动功能 ====================
const messagesEndRef = useRef<HTMLDivElement>(null);
useEffect(() => {
messagesEndRef.current?.scrollIntoView({ behavior: 'smooth' });
}, [messages]);
// ==================== 渲染组件 ====================
return (
<Flex flex={1} direction="column">
{/* 顶部标题栏 */}
<ChatHeader
selectedModel={selectedModel}
isLeftSidebarOpen={isLeftSidebarOpen}
isRightSidebarOpen={isRightSidebarOpen}
onToggleLeftSidebar={onToggleLeftSidebar}
onToggleRightSidebar={onToggleRightSidebar}
onNewSession={onNewSession}
/>
{/* 消息列表 */}
<MessageList
messages={messages}
userAvatar={userAvatar}
messagesEndRef={messagesEndRef}
/>
{/* 快捷问题(仅在消息少于 2 条时显示) */}
<QuickQuestions
messagesCount={messages.length}
isProcessing={isProcessing}
onQuestionClick={onInputChange}
inputRef={inputRef}
/>
{/* 输入框区域 */}
<ChatInput
inputValue={inputValue}
onInputChange={onInputChange}
onKeyPress={onKeyPress}
uploadedFiles={uploadedFiles}
onFileSelect={onFileSelect}
onFileRemove={onFileRemove}
isProcessing={isProcessing}
onSendMessage={onSendMessage}
inputRef={inputRef}
fileInputRef={fileInputRef}
/>
</Flex>
);
};
export default ChatArea;

View File

@@ -0,0 +1,44 @@
// src/views/AgentChat/components/ChatArea/types.ts
// ChatArea 组件的 TypeScript 类型定义
/**
* 上传文件结构
*/
export interface UploadedFile {
name: string;
size?: number;
type?: string;
url?: string;
}
/**
* 执行步骤结构
*/
export interface ExecutionStep {
tool_name: string;
status: 'success' | 'error' | 'pending';
execution_time?: number;
error?: string;
result?: unknown;
}
/**
* 消息数据结构
*/
export interface Message {
id: string | number;
type: string; // 使用 MessageTypes 枚举
content: string;
timestamp?: string | Date;
files?: UploadedFile[];
execution_results?: ExecutionStep[];
plan?: unknown;
}
/**
* 快捷问题结构
*/
export interface QuickQuestion {
emoji: string;
text: string;
}

View File

@@ -221,7 +221,7 @@ export const useAgentChat = ({
loadSessions();
}
} catch (error: any) {
logger.error('Agent chat error', error);
logger.error('useAgentChat', 'handleSendMessage', error as Error);
// 移除 "思考中" 和 "执行中" 消息
setMessages((prev) =>

View File

@@ -103,7 +103,7 @@ export const useAgentSessions = ({
setSessions(response.data.data);
}
} catch (error) {
logger.error('加载会话列表失败', error);
logger.error('useAgentSessions', 'loadSessions', error as Error);
} finally {
setIsLoadingSessions(false);
}
@@ -135,7 +135,7 @@ export const useAgentSessions = ({
setMessages(formattedMessages);
}
} catch (error) {
logger.error('加载会话历史失败', error);
logger.error('useAgentSessions', 'loadSessionHistory', error as Error);
}
},
[setMessages]

View File

@@ -1,25 +1,27 @@
// src/views/AgentChat/utils/sessionUtils.js
// 会话管理工具函数
// src/views/AgentChat/utils/sessionUtils.ts
// 会话管理工具函数TypeScript 版本)
import type { Session, SessionGroups } from '../components/LeftSidebar/types';
/**
*
*
* @param {Array} sessions -
* @returns {Object} { today, yesterday, thisWeek, older }
* @param sessions -
* @returns { today, yesterday, thisWeek, older }
*
* @example
* const groups = groupSessionsByDate(sessions);
* console.log(groups.today); // 今天的会话
* console.log(groups.yesterday); // 昨天的会话
*/
export const groupSessionsByDate = (sessions) => {
export const groupSessionsByDate = (sessions: Session[]): SessionGroups => {
const today = new Date();
const yesterday = new Date(today);
yesterday.setDate(yesterday.getDate() - 1);
const weekAgo = new Date(today);
weekAgo.setDate(weekAgo.getDate() - 7);
const groups = {
const groups: SessionGroups = {
today: [],
yesterday: [],
thisWeek: [],
@@ -27,8 +29,8 @@ export const groupSessionsByDate = (sessions) => {
};
sessions.forEach((session) => {
const sessionDate = new Date(session.created_at || session.timestamp);
const daysDiff = Math.floor((today - sessionDate) / (1000 * 60 * 60 * 24));
const sessionDate = new Date(session.created_at || session.timestamp || Date.now());
const daysDiff = Math.floor((today.getTime() - sessionDate.getTime()) / (1000 * 60 * 60 * 24));
if (daysDiff === 0) {
groups.today.push(session);

View File

@@ -45,7 +45,7 @@ import FullCalendar from '@fullcalendar/react';
import dayGridPlugin from '@fullcalendar/daygrid';
import interactionPlugin from '@fullcalendar/interaction';
import { DateClickArg } from '@fullcalendar/interaction';
import { EventClickArg } from '@fullcalendar/common';
import type { EventClickArg } from '@fullcalendar/core';
import dayjs, { Dayjs } from 'dayjs';
import 'dayjs/locale/zh-cn';