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:
362
frontend-v2/src/modules/ssa/hooks/useSSAChat.ts
Normal file
362
frontend-v2/src/modules/ssa/hooks/useSSAChat.ts
Normal file
@@ -0,0 +1,362 @@
|
||||
/**
|
||||
* useSSAChat — Phase II 统一对话 Hook
|
||||
*
|
||||
* 基于 POST /api/v1/ssa/sessions/:id/chat SSE 端点,
|
||||
* 提供流式对话能力,替换原有 parseIntent -> generateWorkflowPlan 流程。
|
||||
*
|
||||
* 能力:
|
||||
* - SSE 流式消息接收(OpenAI Compatible 格式)
|
||||
* - 意图分类元数据解析(intent_classified 事件)
|
||||
* - H3:isGenerating 输入锁
|
||||
* - 对话历史加载(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;
|
||||
Reference in New Issue
Block a user