feat(ssa): Complete Phase I-IV intelligent dialogue and tool system development
Phase I - Session Blackboard + READ Layer: - SessionBlackboardService with Postgres-Only cache - DataProfileService for data overview generation - PicoInferenceService for LLM-driven PICO extraction - Frontend DataContextCard and VariableDictionaryPanel - E2E tests: 31/31 passed Phase II - Conversation Layer LLM + Intent Router: - ConversationService with SSE streaming - IntentRouterService (rule-first + LLM fallback, 6 intents) - SystemPromptService with 6-segment dynamic assembly - TokenTruncationService for context management - ChatHandlerService as unified chat entry - Frontend SSAChatPane and useSSAChat hook - E2E tests: 38/38 passed Phase III - Method Consultation + AskUser Standardization: - ToolRegistryService with Repository Pattern - MethodConsultService with DecisionTable + LLM enhancement - AskUserService with global interrupt handling - Frontend AskUserCard component - E2E tests: 13/13 passed Phase IV - Dialogue-Driven Analysis + QPER Integration: - ToolOrchestratorService (plan/execute/report) - analysis_plan SSE event for WorkflowPlan transmission - Dual-channel confirmation (ask_user card + workspace button) - PICO as optional hint for LLM parsing - E2E tests: 25/25 passed R Statistics Service: - 5 new R tools: anova_one, baseline_table, fisher, linear_reg, wilcoxon - Enhanced guardrails and block helpers - Comprehensive test suite (run_all_tools_test.js) Documentation: - Updated system status document (v5.9) - Updated SSA module status and development plan (v1.8) Total E2E: 107/107 passed (Phase I: 31, Phase II: 38, Phase III: 13, Phase IV: 25) Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
@@ -1,18 +1,22 @@
|
||||
/**
|
||||
* SSAChatPane - V11 对话区
|
||||
*
|
||||
* 100% 还原 V11 原型图
|
||||
* - 顶部 Header(标题 + 返回按钮 + 状态指示)
|
||||
* - 居中对话列表
|
||||
* - 底部悬浮输入框
|
||||
* 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,
|
||||
import {
|
||||
Bot,
|
||||
User,
|
||||
Paperclip,
|
||||
ArrowUp,
|
||||
FileSpreadsheet,
|
||||
X,
|
||||
ArrowLeft,
|
||||
FileSignature,
|
||||
@@ -20,17 +24,23 @@ import {
|
||||
Loader2,
|
||||
AlertCircle,
|
||||
CheckCircle,
|
||||
BarChart2
|
||||
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 { 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 type { ClarificationCardData, IntentResult } from '../types';
|
||||
|
||||
export const SSAChatPane: React.FC = () => {
|
||||
@@ -55,7 +65,21 @@ export const SSAChatPane: React.FC = () => {
|
||||
} = useSSAStore();
|
||||
|
||||
const { uploadData, generatePlan, isUploading, uploadProgress } = useAnalysis();
|
||||
const { generateDataProfile, generateWorkflowPlan, parseIntent, handleClarify, isProfileLoading, isPlanLoading } = useWorkflow();
|
||||
const { generateDataProfile, handleClarify, executeWorkflow, isProfileLoading, isPlanLoading } = useWorkflow();
|
||||
const {
|
||||
chatMessages,
|
||||
isGenerating,
|
||||
currentIntent,
|
||||
pendingQuestion,
|
||||
pendingPlanConfirm,
|
||||
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<{
|
||||
@@ -67,6 +91,24 @@ export const SSAChatPane: React.FC = () => {
|
||||
const chatEndRef = useRef<HTMLDivElement>(null);
|
||||
const messagesContainerRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
// Phase IV: plan_confirmed → 自动触发 executeWorkflow
|
||||
useEffect(() => {
|
||||
if (pendingPlanConfirm?.workflowId && currentSession?.id) {
|
||||
executeWorkflow(currentSession.id, pendingPlanConfirm.workflowId).catch((err: any) => {
|
||||
addToast(err?.message || '执行失败', 'error');
|
||||
});
|
||||
}
|
||||
}, [pendingPlanConfirm, currentSession?.id, executeWorkflow, addToast]);
|
||||
|
||||
// Phase II: session 切换时加载对话历史
|
||||
useEffect(() => {
|
||||
if (currentSession?.id) {
|
||||
loadHistory(currentSession.id);
|
||||
} else {
|
||||
clearMessages();
|
||||
}
|
||||
}, [currentSession?.id, loadHistory, clearMessages]);
|
||||
|
||||
// 自动滚动到底部,确保最新内容可见
|
||||
const scrollToBottom = useCallback(() => {
|
||||
if (messagesContainerRef.current) {
|
||||
@@ -80,7 +122,7 @@ export const SSAChatPane: React.FC = () => {
|
||||
useEffect(() => {
|
||||
const timer = setTimeout(scrollToBottom, 100);
|
||||
return () => clearTimeout(timer);
|
||||
}, [messages, scrollToBottom]);
|
||||
}, [messages, chatMessages, isGenerating, scrollToBottom]);
|
||||
|
||||
const handleBack = () => {
|
||||
navigate(-1);
|
||||
@@ -149,45 +191,32 @@ export const SSAChatPane: React.FC = () => {
|
||||
|
||||
const handleSend = async () => {
|
||||
if (!inputValue.trim()) return;
|
||||
if (isGenerating) return;
|
||||
|
||||
const query = inputValue;
|
||||
const query = inputValue.trim();
|
||||
setInputValue('');
|
||||
|
||||
// Immediately show user message in chat
|
||||
addMessage({
|
||||
id: crypto.randomUUID(),
|
||||
role: 'user',
|
||||
content: query,
|
||||
createdAt: new Date().toISOString(),
|
||||
});
|
||||
|
||||
try {
|
||||
if (currentSession?.id) {
|
||||
// Phase Q: 先做意图解析,低置信度时追问
|
||||
const intentResp = await parseIntent(currentSession.id, query);
|
||||
|
||||
if (intentResp.needsClarification && intentResp.clarificationCards?.length > 0) {
|
||||
addMessage({
|
||||
id: crypto.randomUUID(),
|
||||
role: 'assistant',
|
||||
content: `我大致理解了你的意图(${intentResp.intent.reasoning}),但为了生成更精确的分析方案,想确认几个细节:`,
|
||||
createdAt: new Date().toISOString(),
|
||||
});
|
||||
setPendingClarification({
|
||||
cards: intentResp.clarificationCards,
|
||||
originalQuery: query,
|
||||
intent: intentResp.intent,
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
// 置信度足够 → 直接生成工作流计划(不再重复添加用户消息)
|
||||
await generateWorkflowPlan(currentSession.id, query);
|
||||
} else {
|
||||
await generatePlan(query);
|
||||
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');
|
||||
}
|
||||
} catch (err: any) {
|
||||
addToast(err?.message || '生成计划失败', 'error');
|
||||
}
|
||||
};
|
||||
|
||||
@@ -264,11 +293,13 @@ export const SSAChatPane: React.FC = () => {
|
||||
{currentSession?.title || '新的统计分析'}
|
||||
</span>
|
||||
</div>
|
||||
<EngineStatus
|
||||
isExecuting={isExecuting}
|
||||
<EngineStatus
|
||||
isExecuting={isExecuting}
|
||||
isLoading={isLoading || isPlanLoading}
|
||||
isUploading={uploadStatus === 'uploading' || uploadStatus === 'parsing'}
|
||||
isProfileLoading={isProfileLoading || dataProfileLoading}
|
||||
isStreaming={isGenerating}
|
||||
streamIntent={currentIntent}
|
||||
/>
|
||||
</header>
|
||||
|
||||
@@ -299,13 +330,13 @@ export const SSAChatPane: React.FC = () => {
|
||||
</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
|
||||
<div
|
||||
key={msg.id || idx}
|
||||
className={`message ${msg.role === 'user' ? 'message-user' : 'message-ai'} slide-up`}
|
||||
style={{ animationDelay: `${idx * 0.1}s` }}
|
||||
@@ -320,7 +351,6 @@ export const SSAChatPane: React.FC = () => {
|
||||
msg.content
|
||||
)}
|
||||
|
||||
{/* SAP / Result 卡片 - 统一行为:selectRecord + 打开工作区 */}
|
||||
{(msg.artifactType === 'sap' || msg.artifactType === 'result') && msg.recordId && (() => {
|
||||
const rec = analysisHistory.find((r: AnalysisRecord) => r.id === msg.recordId);
|
||||
const isCompleted = rec?.status === 'completed';
|
||||
@@ -350,6 +380,49 @@ export const SSAChatPane: React.FC = () => {
|
||||
);
|
||||
})}
|
||||
|
||||
{/* 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">{msg.content}</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
|
||||
{/* 数据画像生成中指示器 */}
|
||||
{dataProfileLoading && (
|
||||
<div className="message message-ai slide-up">
|
||||
@@ -365,8 +438,8 @@ export const SSAChatPane: React.FC = () => {
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* AI 正在思考指示器 */}
|
||||
{(isLoading || isPlanLoading) && (
|
||||
{/* AI 正在思考指示器(仅老流程使用,Phase II 由 chatMessages 中的 generating 状态处理) */}
|
||||
{(isLoading || isPlanLoading) && !isGenerating && (
|
||||
<div className="message message-ai slide-up">
|
||||
<div className="message-avatar ai-avatar">
|
||||
<Bot size={12} />
|
||||
@@ -381,14 +454,7 @@ export const SSAChatPane: React.FC = () => {
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/*
|
||||
Phase 2A 新流程:
|
||||
1. 上传数据 → 显示数据质量报告(已在上方处理)
|
||||
2. 用户输入分析问题 → AI 回复消息中包含 SAP 卡片(通过 msg.artifactType === 'sap')
|
||||
旧的"数据挂载成功消息"已移除,避免在没有用户输入时就显示 SAP 卡片
|
||||
*/}
|
||||
|
||||
{/* Phase Q: 追问卡片 */}
|
||||
{/* Phase Q: 追问卡片(保留向后兼容) */}
|
||||
{pendingClarification && (
|
||||
<div className="message-row assistant">
|
||||
<div className="avatar-col"><Bot size={18} /></div>
|
||||
@@ -396,7 +462,22 @@ export const SSAChatPane: React.FC = () => {
|
||||
<ClarificationCard
|
||||
cards={pendingClarification.cards}
|
||||
onSelect={handleClarificationSelect}
|
||||
disabled={isLoading}
|
||||
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>
|
||||
@@ -489,31 +570,41 @@ export const SSAChatPane: React.FC = () => {
|
||||
onChange={handleFileSelect}
|
||||
style={{ display: 'none' }}
|
||||
/>
|
||||
<button
|
||||
<button
|
||||
className={`upload-btn ${mountedFile ? 'disabled' : ''}`}
|
||||
onClick={() => fileInputRef.current?.click()}
|
||||
disabled={!!mountedFile || isUploading}
|
||||
disabled={!!mountedFile || isUploading || isGenerating}
|
||||
>
|
||||
<Paperclip size={18} />
|
||||
</button>
|
||||
|
||||
|
||||
<textarea
|
||||
className="message-input"
|
||||
value={inputValue}
|
||||
onChange={(e) => setInputValue(e.target.value)}
|
||||
onKeyDown={handleKeyDown}
|
||||
placeholder="发送消息,或点击回形针 📎 上传数据触发分析..."
|
||||
placeholder={isGenerating ? 'AI 正在回复...' : '发送消息,或点击回形针 📎 上传数据触发分析...'}
|
||||
rows={1}
|
||||
disabled={isLoading}
|
||||
disabled={isLoading || isGenerating}
|
||||
/>
|
||||
|
||||
<button
|
||||
className="send-btn"
|
||||
onClick={handleSend}
|
||||
disabled={!inputValue.trim() || isLoading}
|
||||
>
|
||||
<ArrowUp size={14} />
|
||||
</button>
|
||||
|
||||
{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">
|
||||
@@ -530,15 +621,32 @@ interface EngineStatusProps {
|
||||
isLoading: boolean;
|
||||
isUploading: boolean;
|
||||
isProfileLoading?: boolean;
|
||||
isStreaming?: boolean;
|
||||
streamIntent?: ChatIntentType | null;
|
||||
}
|
||||
|
||||
const EngineStatus: React.FC<EngineStatusProps> = ({
|
||||
isExecuting,
|
||||
isLoading,
|
||||
const INTENT_LABELS: Record<ChatIntentType, string> = {
|
||||
chat: '对话',
|
||||
explore: '数据探索',
|
||||
consult: '方法咨询',
|
||||
analyze: '统计分析',
|
||||
discuss: '结果讨论',
|
||||
feedback: '结果改进',
|
||||
};
|
||||
|
||||
const EngineStatus: React.FC<EngineStatusProps> = ({
|
||||
isExecuting,
|
||||
isLoading,
|
||||
isUploading,
|
||||
isProfileLoading
|
||||
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' };
|
||||
}
|
||||
@@ -564,4 +672,16 @@ const EngineStatus: React.FC<EngineStatusProps> = ({
|
||||
);
|
||||
};
|
||||
|
||||
/**
|
||||
* 意图标签组件
|
||||
*/
|
||||
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;
|
||||
|
||||
Reference in New Issue
Block a user