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
688 lines
24 KiB
TypeScript
688 lines
24 KiB
TypeScript
/**
|
||
* SSAChatPane - V11 对话区(Phase II 升级)
|
||
*
|
||
* Phase II 改造:
|
||
* - handleSend 走统一 /chat SSE API(替换 parseIntent → generateWorkflowPlan)
|
||
* - 流式消息渲染(替换 TypeWriter 逐字效果)
|
||
* - ThinkingBlock 深度思考折叠
|
||
* - 意图标签(intent badge)
|
||
* - H3:streaming 期间锁定输入
|
||
*
|
||
* 保留不变: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;
|