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:
2026-02-22 18:53:39 +08:00
parent bf10dec4c8
commit 3446909ff7
68 changed files with 11583 additions and 412 deletions

View File

@@ -0,0 +1,362 @@
/**
* useSSAChat — Phase II 统一对话 Hook
*
* 基于 POST /api/v1/ssa/sessions/:id/chat SSE 端点,
* 提供流式对话能力,替换原有 parseIntent -> generateWorkflowPlan 流程。
*
* 能力:
* - SSE 流式消息接收OpenAI Compatible 格式)
* - 意图分类元数据解析intent_classified 事件)
* - H3isGenerating 输入锁
* - 对话历史加载GET /sessions/:id/chat/history
* - 深度思考内容reasoning_content
* - 中断请求
*/
import { useState, useCallback, useRef } from 'react';
import { getAccessToken } from '@/framework/auth/api';
import { useSSAStore } from '../stores/ssaStore';
import type { AskUserEventData, AskUserResponseData } from '../components/AskUserCard';
import type { WorkflowPlan } from '../types';
// ────────────────────────────────────────────
// Types
// ────────────────────────────────────────────
export type ChatIntentType = 'chat' | 'explore' | 'consult' | 'analyze' | 'discuss' | 'feedback';
export interface ChatMessage {
id: string;
role: 'user' | 'assistant';
content: string;
thinking?: string;
intent?: ChatIntentType;
status?: 'complete' | 'generating' | 'error';
createdAt: string;
}
export interface IntentMeta {
intent: ChatIntentType;
confidence: number;
source: 'rules' | 'llm' | 'default' | 'guard';
guardTriggered: boolean;
guardMessage?: string;
}
interface OpenAIChunk {
id?: string;
choices?: Array<{
delta: {
content?: string;
reasoning_content?: string;
};
finish_reason: string | null;
}>;
}
export interface UseSSAChatReturn {
chatMessages: ChatMessage[];
isGenerating: boolean;
currentIntent: ChatIntentType | null;
intentMeta: IntentMeta | null;
thinkingContent: string;
streamingContent: string;
error: string | null;
pendingQuestion: AskUserEventData | null;
pendingPlanConfirm: { workflowId: string } | null;
sendChatMessage: (sessionId: string, content: string, metadata?: Record<string, any>) => Promise<void>;
respondToQuestion: (sessionId: string, response: AskUserResponseData) => Promise<void>;
skipQuestion: (sessionId: string, questionId: string) => Promise<void>;
loadHistory: (sessionId: string) => Promise<void>;
abort: () => void;
clearMessages: () => void;
}
// ────────────────────────────────────────────
// Hook
// ────────────────────────────────────────────
export function useSSAChat(): UseSSAChatReturn {
const [chatMessages, setChatMessages] = useState<ChatMessage[]>([]);
const [isGenerating, setIsGenerating] = useState(false);
const [currentIntent, setCurrentIntent] = useState<ChatIntentType | null>(null);
const [pendingPlanConfirm, setPendingPlanConfirm] = useState<{ workflowId: string } | null>(null);
const [intentMeta, setIntentMeta] = useState<IntentMeta | null>(null);
const [thinkingContent, setThinkingContent] = useState('');
const [streamingContent, setStreamingContent] = useState('');
const [error, setError] = useState<string | null>(null);
const [pendingQuestion, setPendingQuestion] = useState<AskUserEventData | null>(null);
const abortRef = useRef<AbortController | null>(null);
const abort = useCallback(() => {
if (abortRef.current) {
abortRef.current.abort();
abortRef.current = null;
}
setIsGenerating(false);
}, []);
const clearMessages = useCallback(() => {
setChatMessages([]);
setCurrentIntent(null);
setIntentMeta(null);
setThinkingContent('');
setStreamingContent('');
setError(null);
setPendingQuestion(null);
setPendingPlanConfirm(null);
}, []);
/**
* 加载对话历史
*/
const loadHistory = useCallback(async (sessionId: string) => {
try {
const token = getAccessToken();
const resp = await fetch(`/api/v1/ssa/sessions/${sessionId}/chat/history`, {
headers: { Authorization: `Bearer ${token}` },
});
if (!resp.ok) return;
const data = await resp.json();
if (data.messages?.length > 0) {
const loaded: ChatMessage[] = data.messages
.filter((m: any) => m.status !== 'generating')
.map((m: any) => ({
id: m.id,
role: m.role,
content: m.content || '',
thinking: m.thinkingContent,
intent: m.intent,
status: m.status || 'complete',
createdAt: m.createdAt,
}));
setChatMessages(loaded);
}
} catch (err) {
console.warn('[useSSAChat] Failed to load history:', err);
}
}, []);
/**
* 发送消息并接收 SSE 流式响应
*/
const sendChatMessage = useCallback(async (sessionId: string, content: string, metadata?: Record<string, any>) => {
setError(null);
setIsGenerating(true);
setThinkingContent('');
setStreamingContent('');
setCurrentIntent(null);
setIntentMeta(null);
setPendingQuestion(null);
const userMsg: ChatMessage = {
id: crypto.randomUUID(),
role: 'user',
content,
status: 'complete',
createdAt: new Date().toISOString(),
};
const assistantMsgId = crypto.randomUUID();
const assistantPlaceholder: ChatMessage = {
id: assistantMsgId,
role: 'assistant',
content: '',
status: 'generating',
createdAt: new Date().toISOString(),
};
setChatMessages(prev => [...prev, userMsg, assistantPlaceholder]);
abortRef.current = new AbortController();
let fullContent = '';
let fullThinking = '';
try {
const token = getAccessToken();
const response = await fetch(`/api/v1/ssa/sessions/${sessionId}/chat`, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
Accept: 'text/event-stream',
Authorization: `Bearer ${token}`,
},
body: JSON.stringify({ content, ...(metadata ? { metadata } : {}) }),
signal: abortRef.current.signal,
});
if (!response.ok) {
throw new Error(`HTTP ${response.status}: ${response.statusText}`);
}
const reader = response.body?.getReader();
if (!reader) throw new Error('无法获取响应流');
const decoder = new TextDecoder();
let buffer = '';
while (true) {
const { done, value } = await reader.read();
if (done) break;
buffer += decoder.decode(value, { stream: true });
const lines = buffer.split('\n');
buffer = lines.pop() || '';
for (const line of lines) {
if (!line.trim() || line.startsWith(': ')) continue;
if (!line.startsWith('data: ')) continue;
const data = line.slice(6).trim();
if (data === '[DONE]') continue;
try {
const parsed = JSON.parse(data);
// 意图分类元数据
if (parsed.type === 'intent_classified') {
const meta: IntentMeta = {
intent: parsed.intent,
confidence: parsed.confidence,
source: parsed.source,
guardTriggered: parsed.guardTriggered || false,
guardMessage: parsed.guardMessage,
};
setCurrentIntent(meta.intent);
setIntentMeta(meta);
setChatMessages(prev => prev.map(m =>
m.id === assistantMsgId ? { ...m, intent: meta.intent } : m
));
continue;
}
// ask_user 事件Phase III
if (parsed.type === 'ask_user') {
setPendingQuestion(parsed as AskUserEventData);
continue;
}
// analysis_plan 事件Phase IV: 对话驱动分析)
if (parsed.type === 'analysis_plan' && parsed.plan) {
const plan = parsed.plan as WorkflowPlan;
const { addRecord, setActivePane, setWorkspaceOpen } = useSSAStore.getState();
addRecord(content, plan);
setActivePane('sap');
setWorkspaceOpen(true);
continue;
}
// plan_confirmed 事件Phase IV: 用户确认方案后触发执行)
if (parsed.type === 'plan_confirmed' && parsed.workflowId) {
setPendingPlanConfirm({ workflowId: parsed.workflowId });
setPendingQuestion(null);
continue;
}
// 错误事件
if (parsed.type === 'error') {
const errMsg = parsed.message || '处理消息时发生错误';
setError(errMsg);
setChatMessages(prev => prev.map(m =>
m.id === assistantMsgId ? { ...m, content: errMsg, status: 'error' } : m
));
continue;
}
// OpenAI Compatible 流式 chunk
const chunk = parsed as OpenAIChunk;
const delta = chunk.choices?.[0]?.delta;
if (delta) {
if (delta.reasoning_content) {
fullThinking += delta.reasoning_content;
setThinkingContent(fullThinking);
}
if (delta.content) {
fullContent += delta.content;
setStreamingContent(fullContent);
setChatMessages(prev => prev.map(m =>
m.id === assistantMsgId
? { ...m, content: fullContent, thinking: fullThinking || undefined }
: m
));
}
}
} catch {
// 解析失败,跳过
}
}
}
// 流结束,标记完成
setChatMessages(prev => prev.map(m =>
m.id === assistantMsgId
? { ...m, content: fullContent, thinking: fullThinking || undefined, status: 'complete' }
: m
));
} catch (err: any) {
if (err.name === 'AbortError') {
setChatMessages(prev => prev.map(m =>
m.id === assistantMsgId
? { ...m, content: fullContent || '(已中断)', status: 'complete' }
: m
));
} else {
const errMsg = err.message || '请求失败';
setError(errMsg);
setChatMessages(prev => prev.map(m =>
m.id === assistantMsgId ? { ...m, content: errMsg, status: 'error' } : m
));
}
} finally {
setIsGenerating(false);
setStreamingContent('');
setThinkingContent('');
abortRef.current = null;
}
}, []);
/**
* 响应 ask_user 卡片Phase III
*/
const respondToQuestion = useCallback(async (sessionId: string, response: AskUserResponseData) => {
setPendingQuestion(null);
const displayText = response.action === 'select'
? `选择了: ${response.selectedValues?.join(', ')}`
: response.freeText || '(已回复)';
await sendChatMessage(sessionId, displayText, { askUserResponse: response });
}, [sendChatMessage]);
/**
* H1: 跳过 ask_user 卡片
*/
const skipQuestion = useCallback(async (sessionId: string, questionId: string) => {
setPendingQuestion(null);
const skipResponse: AskUserResponseData = {
questionId,
action: 'skip',
};
await sendChatMessage(sessionId, '跳过了此问题', { askUserResponse: skipResponse });
}, [sendChatMessage]);
return {
chatMessages,
isGenerating,
currentIntent,
intentMeta,
thinkingContent,
streamingContent,
error,
pendingQuestion,
pendingPlanConfirm,
sendChatMessage,
respondToQuestion,
skipQuestion,
loadHistory,
abort,
clearMessages,
};
}
export default useSSAChat;