Phase 2A: WorkflowPlannerService, WorkflowExecutorService, Python data quality, 6 bug fixes, DescriptiveResultView, multi-step R code/Word export, MVP UI reuse. V11 UI: Gemini-style, multi-task, single-page scroll, Word export. Architecture: Block-based rendering consensus (4 block types). New R tools: chi_square, correlation, descriptive, logistic_binary, mann_whitney, t_test_paired. Docs: dev summary, block-based plan, status updates, task list v2.0. Co-authored-by: Cursor <cursoragent@cursor.com>
474 lines
16 KiB
TypeScript
474 lines
16 KiB
TypeScript
/**
|
||
* SSAChatPane - V11 对话区
|
||
*
|
||
* 100% 还原 V11 原型图
|
||
* - 顶部 Header(标题 + 返回按钮 + 状态指示)
|
||
* - 居中对话列表
|
||
* - 底部悬浮输入框
|
||
*/
|
||
import React, { useState, useRef, useEffect, useCallback } from 'react';
|
||
import {
|
||
Bot,
|
||
User,
|
||
Paperclip,
|
||
ArrowUp,
|
||
FileSpreadsheet,
|
||
X,
|
||
ArrowLeft,
|
||
FileSignature,
|
||
ArrowRight,
|
||
Loader2,
|
||
AlertCircle,
|
||
CheckCircle,
|
||
BarChart2
|
||
} from 'lucide-react';
|
||
import { useNavigate } from 'react-router-dom';
|
||
import { useSSAStore } from '../stores/ssaStore';
|
||
import { useAnalysis } from '../hooks/useAnalysis';
|
||
import { useWorkflow } from '../hooks/useWorkflow';
|
||
import type { SSAMessage } from '../types';
|
||
import { TypeWriter } from './TypeWriter';
|
||
import { DataProfileCard } from './DataProfileCard';
|
||
|
||
export const SSAChatPane: React.FC = () => {
|
||
const navigate = useNavigate();
|
||
const {
|
||
currentSession,
|
||
messages,
|
||
mountedFile,
|
||
setMountedFile,
|
||
setCurrentSession,
|
||
setActivePane,
|
||
setWorkspaceOpen,
|
||
currentPlan,
|
||
isLoading,
|
||
isExecuting,
|
||
error,
|
||
setError,
|
||
addToast,
|
||
selectAnalysisRecord,
|
||
dataProfile,
|
||
dataProfileLoading,
|
||
} = useSSAStore();
|
||
|
||
const { uploadData, generatePlan, isUploading, uploadProgress } = useAnalysis();
|
||
const { generateDataProfile, generateWorkflowPlan, isProfileLoading, isPlanLoading } = useWorkflow();
|
||
const [inputValue, setInputValue] = useState('');
|
||
const [uploadStatus, setUploadStatus] = useState<'idle' | 'uploading' | 'parsing' | 'success' | 'error'>('idle');
|
||
const fileInputRef = useRef<HTMLInputElement>(null);
|
||
const chatEndRef = useRef<HTMLDivElement>(null);
|
||
const messagesContainerRef = useRef<HTMLDivElement>(null);
|
||
|
||
// 自动滚动到底部,确保最新内容可见
|
||
const scrollToBottom = useCallback(() => {
|
||
if (messagesContainerRef.current) {
|
||
messagesContainerRef.current.scrollTo({
|
||
top: messagesContainerRef.current.scrollHeight,
|
||
behavior: 'smooth'
|
||
});
|
||
}
|
||
}, []);
|
||
|
||
useEffect(() => {
|
||
// 延迟滚动,确保 DOM 更新完成
|
||
const timer = setTimeout(scrollToBottom, 100);
|
||
return () => clearTimeout(timer);
|
||
}, [messages, currentPlan, scrollToBottom]);
|
||
|
||
const handleBack = () => {
|
||
navigate(-1);
|
||
};
|
||
|
||
const handleFileSelect = async (e: React.ChangeEvent<HTMLInputElement>) => {
|
||
const file = e.target.files?.[0];
|
||
if (!file) return;
|
||
|
||
setUploadStatus('uploading');
|
||
setError(null);
|
||
|
||
try {
|
||
setUploadStatus('parsing');
|
||
const result = await uploadData(file);
|
||
|
||
setCurrentSession({
|
||
id: result.sessionId,
|
||
title: file.name.replace(/\.(csv|xlsx|xls)$/i, ''),
|
||
mode: 'analysis',
|
||
status: 'active',
|
||
dataSchema: {
|
||
columns: result.schema.columns.map((c: any) => ({
|
||
name: c.name,
|
||
type: c.type as 'numeric' | 'categorical' | 'datetime' | 'text',
|
||
uniqueValues: c.uniqueValues,
|
||
nullCount: c.nullCount,
|
||
})),
|
||
rowCount: result.schema.rowCount,
|
||
preview: [],
|
||
},
|
||
createdAt: new Date().toISOString(),
|
||
updatedAt: new Date().toISOString(),
|
||
});
|
||
setMountedFile({
|
||
name: file.name,
|
||
size: file.size,
|
||
rowCount: result.schema.rowCount,
|
||
});
|
||
setUploadStatus('success');
|
||
addToast('数据读取成功,正在进行质量核查...', 'success');
|
||
|
||
// Phase 2A: 自动触发数据质量核查
|
||
try {
|
||
await generateDataProfile(result.sessionId);
|
||
} catch (profileErr) {
|
||
console.warn('数据画像生成失败,继续使用基础模式:', profileErr);
|
||
}
|
||
} catch (err: any) {
|
||
setUploadStatus('error');
|
||
const errorMsg = err?.message || '上传失败,请检查文件格式';
|
||
setError(errorMsg);
|
||
addToast(errorMsg, 'error');
|
||
}
|
||
};
|
||
|
||
const handleRemoveFile = () => {
|
||
setMountedFile(null);
|
||
setWorkspaceOpen(false);
|
||
setUploadStatus('idle');
|
||
setError(null);
|
||
if (fileInputRef.current) {
|
||
fileInputRef.current.value = '';
|
||
}
|
||
};
|
||
|
||
const handleSend = async () => {
|
||
if (!inputValue.trim()) return;
|
||
|
||
try {
|
||
// Phase 2A: 如果已有 session,使用多步骤工作流规划
|
||
if (currentSession?.id) {
|
||
await generateWorkflowPlan(currentSession.id, inputValue);
|
||
} else {
|
||
// 没有数据时,使用旧流程
|
||
await generatePlan(inputValue);
|
||
}
|
||
setInputValue('');
|
||
} catch (err: any) {
|
||
addToast(err?.message || '生成计划失败', 'error');
|
||
}
|
||
};
|
||
|
||
const handleKeyDown = (e: React.KeyboardEvent) => {
|
||
if (e.key === 'Enter' && !e.shiftKey) {
|
||
e.preventDefault();
|
||
handleSend();
|
||
}
|
||
};
|
||
|
||
// 打开工作区,可选择特定的分析记录
|
||
const handleOpenWorkspace = useCallback((recordId?: string) => {
|
||
if (recordId) {
|
||
selectAnalysisRecord(recordId);
|
||
} else {
|
||
setWorkspaceOpen(true);
|
||
setActivePane('sap');
|
||
}
|
||
}, [selectAnalysisRecord, setWorkspaceOpen, setActivePane]);
|
||
|
||
const formatFileSize = (bytes: number) => {
|
||
if (bytes < 1024) return `${bytes}B`;
|
||
if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(0)}KB`;
|
||
return `${(bytes / (1024 * 1024)).toFixed(1)}MB`;
|
||
};
|
||
|
||
return (
|
||
<section className="ssa-chat-pane">
|
||
{/* Chat Header */}
|
||
<header className="chat-header">
|
||
<div className="chat-header-left">
|
||
<button className="back-btn" onClick={handleBack}>
|
||
<ArrowLeft size={16} />
|
||
<span>返回</span>
|
||
</button>
|
||
<span className="header-divider" />
|
||
<span className="header-title">
|
||
{currentSession?.title || '新的统计分析'}
|
||
</span>
|
||
</div>
|
||
<EngineStatus
|
||
isExecuting={isExecuting}
|
||
isLoading={isLoading || isPlanLoading}
|
||
isUploading={uploadStatus === 'uploading' || uploadStatus === 'parsing'}
|
||
isProfileLoading={isProfileLoading || dataProfileLoading}
|
||
/>
|
||
</header>
|
||
|
||
{/* Chat Messages */}
|
||
<div className="chat-messages" ref={messagesContainerRef}>
|
||
<div className="chat-messages-inner">
|
||
{/* 欢迎语 */}
|
||
<div className="message message-ai slide-up">
|
||
<div className="message-avatar ai-avatar">
|
||
<Bot size={12} />
|
||
</div>
|
||
<div className="message-bubble ai-bubble">
|
||
你好!我是 SSA-Pro 智能统计助手。
|
||
<br />
|
||
你可以直接描述研究目标,我将为你生成分析方案;或者点击下方 📎 <b>上传数据文件</b>,我们将直接开始分析。
|
||
</div>
|
||
</div>
|
||
|
||
{/* Phase 2A: 数据质量核查报告卡片 - 在欢迎语之后、用户消息之前显示 */}
|
||
{dataProfile && (
|
||
<div className="message message-ai slide-up">
|
||
<div className="message-avatar ai-avatar">
|
||
<Bot size={12} />
|
||
</div>
|
||
<div className="message-bubble ai-bubble profile-bubble">
|
||
<DataProfileCard profile={dataProfile} />
|
||
</div>
|
||
</div>
|
||
)}
|
||
|
||
{/* 动态消息 */}
|
||
{messages.map((msg: SSAMessage, idx: number) => {
|
||
const isLastAiMessage = msg.role === 'assistant' && idx === messages.length - 1;
|
||
const showTypewriter = isLastAiMessage && !msg.artifactType;
|
||
|
||
return (
|
||
<div
|
||
key={msg.id || idx}
|
||
className={`message ${msg.role === 'user' ? 'message-user' : 'message-ai'} slide-up`}
|
||
style={{ animationDelay: `${idx * 0.1}s` }}
|
||
>
|
||
<div className={`message-avatar ${msg.role === 'user' ? 'user-avatar' : 'ai-avatar'}`}>
|
||
{msg.role === 'user' ? <User size={12} /> : <Bot size={12} />}
|
||
</div>
|
||
<div className={`message-bubble ${msg.role === 'user' ? 'user-bubble' : 'ai-bubble'}`}>
|
||
{showTypewriter ? (
|
||
<TypeWriter content={msg.content} speed={15} />
|
||
) : (
|
||
msg.content
|
||
)}
|
||
|
||
{/* SAP 卡片 - 只有消息中明确标记为 sap 类型时才显示 */}
|
||
{msg.artifactType === 'sap' && msg.recordId && (
|
||
<button className="sap-card" onClick={() => handleOpenWorkspace(msg.recordId)}>
|
||
<div className="sap-card-left">
|
||
<div className="sap-card-icon">
|
||
<FileSignature size={16} />
|
||
</div>
|
||
<div className="sap-card-content">
|
||
<div className="sap-card-title">查看分析计划 (SAP)</div>
|
||
<div className="sap-card-hint">参数映射完成,等待执行</div>
|
||
</div>
|
||
</div>
|
||
<ArrowRight size={16} className="sap-card-arrow" />
|
||
</button>
|
||
)}
|
||
</div>
|
||
</div>
|
||
);
|
||
})}
|
||
|
||
{/* 数据画像生成中指示器 */}
|
||
{dataProfileLoading && (
|
||
<div className="message message-ai slide-up">
|
||
<div className="message-avatar ai-avatar">
|
||
<Bot size={12} />
|
||
</div>
|
||
<div className="message-bubble ai-bubble">
|
||
<div className="profile-loading">
|
||
<BarChart2 size={16} className="spin text-blue-500" />
|
||
<span>正在进行数据质量核查...</span>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
)}
|
||
|
||
{/* AI 正在思考指示器 */}
|
||
{(isLoading || isPlanLoading) && (
|
||
<div className="message message-ai slide-up">
|
||
<div className="message-avatar ai-avatar">
|
||
<Bot size={12} />
|
||
</div>
|
||
<div className="message-bubble ai-bubble thinking-bubble">
|
||
<div className="thinking-dots">
|
||
<span className="dot" />
|
||
<span className="dot" />
|
||
<span className="dot" />
|
||
</div>
|
||
</div>
|
||
</div>
|
||
)}
|
||
|
||
{/*
|
||
Phase 2A 新流程:
|
||
1. 上传数据 → 显示数据质量报告(已在上方处理)
|
||
2. 用户输入分析问题 → AI 回复消息中包含 SAP 卡片(通过 msg.artifactType === 'sap')
|
||
旧的"数据挂载成功消息"已移除,避免在没有用户输入时就显示 SAP 卡片
|
||
*/}
|
||
|
||
{/* 自动滚动锚点与占位垫片 - 解决底部输入框遮挡的终极方案 */}
|
||
<div ref={chatEndRef} className="scroll-spacer" />
|
||
</div>
|
||
</div>
|
||
|
||
{/* Chat Input */}
|
||
<div className="chat-input-wrapper">
|
||
<div className="chat-input-container">
|
||
<div className="input-box">
|
||
{/* 上传进度条 */}
|
||
{uploadStatus === 'uploading' && (
|
||
<div className="upload-progress-zone pop-in">
|
||
<div className="upload-progress-card">
|
||
<Loader2 size={16} className="spin text-blue-500" />
|
||
<div className="upload-progress-info">
|
||
<span className="upload-progress-text">正在上传...</span>
|
||
<div className="upload-progress-bar">
|
||
<div
|
||
className="upload-progress-fill"
|
||
style={{ width: `${uploadProgress}%` }}
|
||
/>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
)}
|
||
|
||
{/* 解析中状态 */}
|
||
{uploadStatus === 'parsing' && (
|
||
<div className="upload-progress-zone pop-in">
|
||
<div className="upload-progress-card parsing">
|
||
<Loader2 size={16} className="spin text-amber-500" />
|
||
<span className="upload-progress-text">正在解析数据结构...</span>
|
||
</div>
|
||
</div>
|
||
)}
|
||
|
||
{/* 上传错误 */}
|
||
{uploadStatus === 'error' && error && (
|
||
<div className="upload-error-zone pop-in">
|
||
<div className="upload-error-card">
|
||
<AlertCircle size={16} className="text-red-500" />
|
||
<span className="upload-error-text">{error}</span>
|
||
<button
|
||
className="upload-retry-btn"
|
||
onClick={() => {
|
||
setUploadStatus('idle');
|
||
setError(null);
|
||
}}
|
||
>
|
||
重试
|
||
</button>
|
||
</div>
|
||
</div>
|
||
)}
|
||
|
||
{/* 数据挂载区 */}
|
||
{mountedFile && uploadStatus !== 'error' && (
|
||
<div className="data-mount-zone pop-in">
|
||
<div className="mount-file-card">
|
||
<div className="mount-file-icon">
|
||
<FileSpreadsheet size={14} />
|
||
</div>
|
||
<div className="mount-file-info">
|
||
<span className="mount-file-name">{mountedFile.name}</span>
|
||
<span className="mount-file-meta">
|
||
{mountedFile.rowCount} rows • {formatFileSize(mountedFile.size)}
|
||
</span>
|
||
</div>
|
||
<CheckCircle size={14} className="text-green-500" />
|
||
<div className="mount-divider" />
|
||
<button className="mount-remove-btn" onClick={handleRemoveFile}>
|
||
<X size={12} />
|
||
</button>
|
||
</div>
|
||
</div>
|
||
)}
|
||
|
||
{/* 输入行 */}
|
||
<div className="input-row">
|
||
<input
|
||
type="file"
|
||
ref={fileInputRef}
|
||
accept=".csv,.xlsx,.xls"
|
||
onChange={handleFileSelect}
|
||
style={{ display: 'none' }}
|
||
/>
|
||
<button
|
||
className={`upload-btn ${mountedFile ? 'disabled' : ''}`}
|
||
onClick={() => fileInputRef.current?.click()}
|
||
disabled={!!mountedFile || isUploading}
|
||
>
|
||
<Paperclip size={18} />
|
||
</button>
|
||
|
||
<textarea
|
||
className="message-input"
|
||
value={inputValue}
|
||
onChange={(e) => setInputValue(e.target.value)}
|
||
onKeyDown={handleKeyDown}
|
||
placeholder="发送消息,或点击回形针 📎 上传数据触发分析..."
|
||
rows={1}
|
||
disabled={isLoading}
|
||
/>
|
||
|
||
<button
|
||
className="send-btn"
|
||
onClick={handleSend}
|
||
disabled={!inputValue.trim() || isLoading}
|
||
>
|
||
<ArrowUp size={14} />
|
||
</button>
|
||
</div>
|
||
</div>
|
||
<div className="input-hint">
|
||
AI 可能会犯错,请核实生成的统计结论。
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</section>
|
||
);
|
||
};
|
||
|
||
interface EngineStatusProps {
|
||
isExecuting: boolean;
|
||
isLoading: boolean;
|
||
isUploading: boolean;
|
||
isProfileLoading?: boolean;
|
||
}
|
||
|
||
const EngineStatus: React.FC<EngineStatusProps> = ({
|
||
isExecuting,
|
||
isLoading,
|
||
isUploading,
|
||
isProfileLoading
|
||
}) => {
|
||
const getStatus = () => {
|
||
if (isExecuting) {
|
||
return { text: 'R Engine Running...', className: 'status-running' };
|
||
}
|
||
if (isLoading) {
|
||
return { text: 'AI Processing...', className: 'status-processing' };
|
||
}
|
||
if (isProfileLoading) {
|
||
return { text: 'Data Profiling...', className: 'status-profiling' };
|
||
}
|
||
if (isUploading) {
|
||
return { text: 'Parsing Data...', className: 'status-uploading' };
|
||
}
|
||
return { text: 'R Engine Ready', className: 'status-ready' };
|
||
};
|
||
|
||
const { text, className } = getStatus();
|
||
|
||
return (
|
||
<div className={`engine-status ${className}`}>
|
||
<span className="status-dot" />
|
||
<span>{text}</span>
|
||
</div>
|
||
);
|
||
};
|
||
|
||
export default SSAChatPane;
|