Files
AIclinicalresearch/frontend-v2/src/modules/ssa/components/SSAChatPane.tsx
HaHafeng 428a22adf2 feat(ssa): Complete Phase 2A frontend integration - multi-step workflow end-to-end
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>
2026-02-20 23:09:27 +08:00

474 lines
16 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/**
* 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;