Files
AIclinicalresearch/frontend-v2/src/modules/ssa/components/SSAChatPane.tsx
HaHafeng aadceb5cde feat(ssa): Implement dual-channel architecture Phase 1-3 (QPER + LLM Agent pipeline)
Completed:
- Phase 1: DB schema (execution_mode + ssa_agent_executions), ModeToggle component, Session PATCH API
- Phase 2: AgentPlannerService + AgentCoderService (streaming) + CodeRunnerService + R Docker /execute-code endpoint
- Phase 3: AgentCodePanel (3-step confirmation UI), SSE event handling (7 agent events), streaming code display
- Three-step confirmation pipeline: plan -> user confirm -> stream code -> user confirm -> execute R code -> results
- R Docker sandbox /execute-code endpoint with 120s timeout + block_helpers preloaded
- E2E dual-channel test script (8 tests)
- Updated R engine architecture doc (v1.5) and SSA module status doc (v4.0)

Technical details:
- AgentCoderService uses LLM streaming (chatStream) for real-time code generation feedback
- ReviewerAgent temporarily disabled, prioritizing Plan -> Code -> Execute flow
- CodeRunnerService wraps user code with auto data loading (df variable injection)
- Frontend handles agent_planning, agent_plan_ready, code_generating, code_generated, code_executing, code_result events
- ask_user mechanism used for plan and code confirmation steps

Files: 24 files (4 new services, 2 new components, 1 migration, 1 E2E test, 16 modified)
Made-with: Cursor
2026-03-02 22:23:54 +08:00

688 lines
24 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 对话区Phase II 升级)
*
* Phase II 改造:
* - handleSend 走统一 /chat SSE API替换 parseIntent → generateWorkflowPlan
* - 流式消息渲染(替换 TypeWriter 逐字效果)
* - ThinkingBlock 深度思考折叠
* - 意图标签intent badge
* - H3streaming 期间锁定输入
*
* 保留不变Header、文件上传、DataProfileCard、SAP/Result 卡片
*/
import React, { useState, useRef, useEffect, useCallback } from 'react';
import {
Bot,
User,
Paperclip,
ArrowUp,
FileSpreadsheet,
X,
ArrowLeft,
FileSignature,
ArrowRight,
Loader2,
AlertCircle,
CheckCircle,
BarChart2,
Square,
} from 'lucide-react';
import { useNavigate } from 'react-router-dom';
import { useSSAStore } from '../stores/ssaStore';
import type { AnalysisRecord } from '../stores/ssaStore';
import { useAnalysis } from '../hooks/useAnalysis';
import { useWorkflow } from '../hooks/useWorkflow';
import { useSSAChat } from '../hooks/useSSAChat';
import type { ChatMessage, ChatIntentType } from '../hooks/useSSAChat';
import type { SSAMessage } from '../types';
import ReactMarkdown from 'react-markdown';
import remarkGfm from 'remark-gfm';
import { TypeWriter } from './TypeWriter';
import { DataProfileCard } from './DataProfileCard';
import { ClarificationCard } from './ClarificationCard';
import { AskUserCard } from './AskUserCard';
import type { AskUserResponseData } from './AskUserCard';
import { ThinkingBlock } from '@/shared/components/Chat';
import { ModeToggle } from './ModeToggle';
import type { ClarificationCardData, IntentResult } from '../types';
export const SSAChatPane: React.FC = () => {
const navigate = useNavigate();
const {
currentSession,
messages,
mountedFile,
setMountedFile,
setCurrentSession,
setWorkspaceOpen,
isLoading,
isExecuting,
error,
setError,
addToast,
addMessage,
selectRecord,
analysisHistory,
dataProfile,
dataProfileLoading,
} = useSSAStore();
const { uploadData, generatePlan, isUploading, uploadProgress } = useAnalysis();
const { generateDataProfile, handleClarify, isProfileLoading, isPlanLoading } = useWorkflow();
const {
chatMessages,
isGenerating,
currentIntent,
pendingQuestion,
sendChatMessage,
respondToQuestion,
skipQuestion,
loadHistory,
abort: abortChat,
clearMessages,
} = useSSAChat();
const [inputValue, setInputValue] = useState('');
const [uploadStatus, setUploadStatus] = useState<'idle' | 'uploading' | 'parsing' | 'success' | 'error'>('idle');
const [pendingClarification, setPendingClarification] = useState<{
cards: ClarificationCardData[];
originalQuery: string;
intent: IntentResult;
} | null>(null);
const fileInputRef = useRef<HTMLInputElement>(null);
const chatEndRef = useRef<HTMLDivElement>(null);
const messagesContainerRef = useRef<HTMLDivElement>(null);
// Phase II: session 切换时加载对话历史
useEffect(() => {
if (currentSession?.id) {
loadHistory(currentSession.id);
} else {
clearMessages();
}
}, [currentSession?.id, loadHistory, clearMessages]);
// 自动滚动到底部,确保最新内容可见
const scrollToBottom = useCallback(() => {
if (messagesContainerRef.current) {
messagesContainerRef.current.scrollTo({
top: messagesContainerRef.current.scrollHeight,
behavior: 'smooth'
});
}
}, []);
useEffect(() => {
const timer = setTimeout(scrollToBottom, 100);
return () => clearTimeout(timer);
}, [messages, chatMessages, isGenerating, 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;
if (isGenerating) return;
const query = inputValue.trim();
setInputValue('');
if (currentSession?.id) {
// Phase II: 统一走 /chat SSE API
// useSSAChat 内部处理 user message 添加 + assistant placeholder + 流式接收
try {
await sendChatMessage(currentSession.id, query);
} catch (err: any) {
addToast(err?.message || '对话失败', 'error');
}
} else {
// 没有 session未上传数据走老的 generatePlan 流程
addMessage({
id: crypto.randomUUID(),
role: 'user',
content: query,
createdAt: new Date().toISOString(),
});
try {
await generatePlan(query);
} catch (err: any) {
addToast(err?.message || '生成计划失败', 'error');
}
}
};
const handleClarificationSelect = async (selections: Record<string, string>) => {
if (!currentSession?.id || !pendingClarification) return;
setPendingClarification(null);
const selectedLabel = Object.values(selections).join(', ');
addMessage({
id: crypto.randomUUID(),
role: 'user',
content: selectedLabel,
createdAt: new Date().toISOString(),
});
try {
const resp = await handleClarify(
currentSession.id,
pendingClarification.originalQuery,
selections
);
if (resp.needsClarification && resp.clarificationCards?.length > 0) {
addMessage({
id: crypto.randomUUID(),
role: 'assistant',
content: '还需要确认一下:',
createdAt: new Date().toISOString(),
});
setPendingClarification({
cards: resp.clarificationCards,
originalQuery: pendingClarification.originalQuery,
intent: resp.intent,
});
}
} 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) {
selectRecord(recordId);
} else {
setWorkspaceOpen(true);
}
}, [selectRecord, setWorkspaceOpen]);
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>
<div className="chat-header-right">
{currentSession && <ModeToggle />}
<EngineStatus
isExecuting={isExecuting}
isLoading={isLoading || isPlanLoading}
isUploading={uploadStatus === 'uploading' || uploadStatus === 'parsing'}
isProfileLoading={isProfileLoading || dataProfileLoading}
isStreaming={isGenerating}
streamIntent={currentIntent}
/>
</div>
</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>
)}
{/* 旧消息store 中的 SSAMessage保留向后兼容 */}
{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
)}
{(msg.artifactType === 'sap' || msg.artifactType === 'result') && msg.recordId && (() => {
const rec = analysisHistory.find((r: AnalysisRecord) => r.id === msg.recordId);
const isCompleted = rec?.status === 'completed';
const isSap = msg.artifactType === 'sap';
return (
<button className="sap-card" onClick={() => handleOpenWorkspace(msg.recordId)}>
<div className="sap-card-left">
<div className="sap-card-icon">
{isSap ? <FileSignature size={16} /> : <BarChart2 size={16} />}
</div>
<div className="sap-card-content">
<div className="sap-card-title">
{isSap ? '查看分析计划' : '查看分析结果'}
{isCompleted && <span className="sap-card-badge"></span>}
</div>
<div className="sap-card-hint">
{rec?.plan?.title || '点击打开工作区查看详情'}
</div>
</div>
</div>
<ArrowRight size={16} className="sap-card-arrow" />
</button>
);
})()}
</div>
</div>
);
})}
{/* Phase II: 流式对话消息(来自 useSSAChat */}
{chatMessages.map((msg: ChatMessage) => (
<div
key={msg.id}
className={`message ${msg.role === 'user' ? 'message-user' : 'message-ai'} slide-up`}
>
<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'}`}>
{/* 意图标签 */}
{msg.role === 'assistant' && msg.intent && (
<IntentBadge intent={msg.intent} />
)}
{/* 深度思考折叠 */}
{msg.role === 'assistant' && msg.thinking && (
<ThinkingBlock
content={msg.thinking}
isThinking={msg.status === 'generating'}
defaultExpanded={false}
/>
)}
{/* 消息内容 */}
{msg.status === 'generating' && !msg.content ? (
<div className="thinking-dots">
<span className="dot" />
<span className="dot" />
<span className="dot" />
</div>
) : msg.status === 'error' ? (
<div className="chat-error-msg">
<AlertCircle size={14} className="text-red-500" />
<span>{msg.content}</span>
</div>
) : (
<div className="chat-msg-content">
<ReactMarkdown remarkPlugins={[remarkGfm]}>
{msg.content}
</ReactMarkdown>
</div>
)}
</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 正在思考指示器仅老流程使用Phase II 由 chatMessages 中的 generating 状态处理) */}
{(isLoading || isPlanLoading) && !isGenerating && (
<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 Q: 追问卡片(保留向后兼容) */}
{pendingClarification && (
<div className="message-row assistant">
<div className="avatar-col"><Bot size={18} /></div>
<div className="msg-content">
<ClarificationCard
cards={pendingClarification.cards}
onSelect={handleClarificationSelect}
disabled={isLoading || isGenerating}
/>
</div>
</div>
)}
{/* Phase III/IV: AskUser 交互卡片H3 统一模型) */}
{pendingQuestion && currentSession?.id && (
<div className="message-row assistant">
<div className="avatar-col"><Bot size={18} /></div>
<div className="msg-content">
<AskUserCard
event={pendingQuestion}
onRespond={(response: AskUserResponseData) => respondToQuestion(currentSession.id, response)}
onSkip={(questionId: string) => skipQuestion(currentSession.id, questionId)}
disabled={isGenerating || isExecuting}
/>
</div>
</div>
)}
{/* 自动滚动锚点与占位垫片 - 解决底部输入框遮挡的终极方案 */}
<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 || isGenerating}
>
<Paperclip size={18} />
</button>
<textarea
className="message-input"
value={inputValue}
onChange={(e) => setInputValue(e.target.value)}
onKeyDown={handleKeyDown}
placeholder={isGenerating ? 'AI 正在回复...' : '发送消息,或点击回形针 📎 上传数据触发分析...'}
rows={1}
disabled={isLoading || isGenerating}
/>
{isGenerating ? (
<button
className="send-btn abort-btn"
onClick={abortChat}
title="中断回复"
>
<Square size={14} />
</button>
) : (
<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;
isStreaming?: boolean;
streamIntent?: ChatIntentType | null;
}
const INTENT_LABELS: Record<ChatIntentType, string> = {
chat: '对话',
explore: '数据探索',
consult: '方法咨询',
analyze: '统计分析',
discuss: '结果讨论',
feedback: '结果改进',
};
const EngineStatus: React.FC<EngineStatusProps> = ({
isExecuting,
isLoading,
isUploading,
isProfileLoading,
isStreaming,
streamIntent,
}) => {
const getStatus = () => {
if (isStreaming) {
const label = streamIntent ? INTENT_LABELS[streamIntent] : 'AI';
return { text: `${label} 生成中...`, className: 'status-streaming' };
}
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>
);
};
/**
* 意图标签组件
*/
const IntentBadge: React.FC<{ intent: ChatIntentType }> = ({ intent }) => {
const label = INTENT_LABELS[intent] || intent;
return (
<span className={`intent-badge intent-${intent}`}>
{label}
</span>
);
};
export default SSAChatPane;