Files
AIclinicalresearch/frontend-v2/src/modules/ssa/hooks/useSSAChat.ts
HaHafeng 6edfad032f feat(ssa): finalize strict stepwise agent execution flow
Align Agent mode to strict stepwise generation and execution, add deterministic and safety hardening, and sync deployment/module documentation for Phase 5A.5/5B/5C rollout.

- implement strict stepwise execution path and dependency short-circuiting
- persist step-level errors/results and stream step_* progress events
- add agent plan params patch route and schema/migration support
- improve R sanitizer/security checks and step result rendering in workspace
- update SSA module guide and deployment change checklist

Made-with: Cursor
2026-03-11 22:49:05 +08:00

753 lines
27 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.
/**
* 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, isTokenExpired, refreshAccessToken } from '@/framework/auth/api';
import { useSSAStore } from '../stores/ssaStore';
import type { AskUserEventData, AskUserResponseData } from '../components/AskUserCard';
import type { WorkflowPlan, AgentStepResult } from '../types';
// ────────────────────────────────────────────
// Types
// ────────────────────────────────────────────
export type ChatIntentType = 'chat' | 'explore' | 'consult' | 'analyze' | 'discuss' | 'feedback' | 'system';
export interface ChatMessage {
id: string;
role: 'user' | 'assistant';
content: string;
thinking?: string;
intent?: ChatIntentType;
status?: 'complete' | 'generating' | 'error';
createdAt: string;
/** Agent 执行 ID用于在对话中渲染可点击的结果卡片 */
executionId?: string;
/** Agent 执行的查询摘要 */
executionQuery?: 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 type AgentActionType = 'confirm_plan' | 'confirm_code' | 'cancel';
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>;
executeAgentAction: (sessionId: string, action: AgentActionType) => 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;
retryLastMessage: () => Promise<void>;
}
// ────────────────────────────────────────────
// Helpers
// ────────────────────────────────────────────
const MAX_AUTO_RETRY = 2;
function retryDelay(attempt: number): number {
return Math.min(1000 * (2 ** attempt), 5000);
}
function toFriendlyError(err: any): string {
const msg = (err?.message || '').toLowerCase();
if (msg.includes('failed to fetch') || msg.includes('networkerror') || msg.includes('network'))
return '网络连接不稳定,请稍后重试。';
if (msg.includes('http2') || msg.includes('protocol'))
return '网络链路出现瞬时波动,请重新发送消息。';
if (msg.includes('timeout') || msg.includes('timed out'))
return '请求超时,服务器响应较慢,请稍后重试。';
if (msg.includes('502') || msg.includes('503') || msg.includes('504'))
return '服务暂时不可用,可能正在更新中,请稍后重试。';
if (msg.includes('401') || msg.includes('登录'))
return '登录已过期,请刷新页面重新登录。';
return err?.message || '请求失败,请重试。';
}
// ────────────────────────────────────────────
// 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 lastRequestRef = useRef<{ sessionId: string; content: string; metadata?: Record<string, any> } | null>(null);
const retryCountRef = useRef(0);
const ensureFreshToken = useCallback(async (): Promise<string> => {
if (isTokenExpired()) {
try {
await refreshAccessToken();
} catch {
throw new Error('登录已过期,请重新登录');
}
}
return getAccessToken() || '';
}, []);
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 = await ensureFreshToken();
const resp = await fetch(`/api/v1/ssa/sessions/${sessionId}/chat/history`, {
headers: { Authorization: `Bearer ${token}` },
});
if (!resp.ok) return;
const data = await resp.json();
const loaded: ChatMessage[] = [];
if (data.messages?.length > 0) {
loaded.push(
...data.messages
.filter((m: any) => m.status !== 'generating')
.map((m: any) => ({
id: m.id,
role: m.role as 'user' | 'assistant',
content: m.content || '',
thinking: m.thinkingContent,
intent: m.intent,
status: (m.status || 'complete') as 'complete' | 'generating' | 'error',
createdAt: m.createdAt,
executionId: m.executionId,
executionQuery: m.executionQuery,
})),
);
}
// 从 store 中的 agentExecutionHistory 注入结果卡片
const { agentExecutionHistory } = useSSAStore.getState();
for (const exec of agentExecutionHistory) {
if (exec.status === 'completed') {
loaded.push({
id: `exec-card-${exec.id}`,
role: 'assistant',
content: `✅ 分析完成:${exec.query}`,
status: 'complete',
intent: 'system',
executionId: exec.id,
executionQuery: exec.query,
createdAt: exec.createdAt || new Date().toISOString(),
});
}
}
loaded.sort((a, b) => new Date(a.createdAt).getTime() - new Date(b.createdAt).getTime());
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>) => {
lastRequestRef.current = { sessionId, content, metadata };
setError(null);
setIsGenerating(true);
setThinkingContent('');
setStreamingContent('');
setCurrentIntent(null);
setIntentMeta(null);
setPendingQuestion(null);
const { executionMode, agentExecution } = useSSAStore.getState();
const isAgentInlineInstruction =
executionMode === 'agent'
&& !!agentExecution
&& (agentExecution.status === 'plan_pending' || agentExecution.status === 'code_pending')
&& !metadata?.agentAction
&& !metadata?.askUserResponse;
const finalMetadata = isAgentInlineInstruction
? { ...(metadata || {}), agentInlineInstruction: true }
: metadata;
const isAgentAction = !!finalMetadata?.agentAction || isAgentInlineInstruction;
const assistantMsgId = crypto.randomUUID();
const assistantPlaceholder: ChatMessage = {
id: assistantMsgId,
role: 'assistant',
content: '',
status: 'generating',
createdAt: new Date().toISOString(),
};
if (isAgentAction) {
// 右侧工作区操作:不添加用户气泡,只添加 AI 占位
setChatMessages(prev => [...prev, assistantPlaceholder]);
} else {
const userMsg: ChatMessage = {
id: crypto.randomUUID(),
role: 'user',
content,
status: 'complete',
createdAt: new Date().toISOString(),
};
setChatMessages(prev => [...prev, userMsg, assistantPlaceholder]);
}
const controller = new AbortController();
abortRef.current = controller;
let fullContent = '';
let fullThinking = '';
try {
const token = await ensureFreshToken();
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, ...(finalMetadata ? { metadata: finalMetadata } : {}) }),
signal: controller.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 } = useSSAStore.getState();
addRecord(content, plan);
continue;
}
// plan_confirmed 事件Phase IV: 用户确认方案后打开工作区)
// 不自动触发 executeWorkflow — 由用户在工作区手动点击「开始执行分析」
if (parsed.type === 'plan_confirmed') {
const { setActivePane, setWorkspaceOpen } = useSSAStore.getState();
setActivePane('sap');
setWorkspaceOpen(true);
setPendingQuestion(null);
continue;
}
// ── Agent 通道 SSE 事件 ──
if (parsed.type === 'agent_planning') {
const { pushAgentExecution, setWorkspaceOpen, setActivePane } = useSSAStore.getState();
pushAgentExecution({
id: parsed.executionId || crypto.randomUUID(),
sessionId: sessionId,
query: content,
retryCount: 0,
status: 'planning',
});
setActivePane('execution');
setWorkspaceOpen(true);
continue;
}
if (parsed.type === 'agent_plan_ready') {
const { updateAgentExecution } = useSSAStore.getState();
const initialStepResults: AgentStepResult[] = Array.isArray(parsed.plan?.steps)
? parsed.plan.steps.map((s: any) => ({
stepOrder: s.order || 0,
method: s.method || '',
status: 'pending',
retryCount: 0,
}))
: [];
updateAgentExecution({
planText: parsed.planText,
planSteps: parsed.plan?.steps,
status: 'plan_pending',
stepResults: initialStepResults.length > 0 ? initialStepResults : undefined,
currentStep: initialStepResults.length > 0 ? initialStepResults[0].stepOrder : undefined,
});
continue;
}
const patchStepResult = (stepOrder: number, patch: Partial<AgentStepResult>) => {
const { agentExecution, updateAgentExecution } = useSSAStore.getState();
const existing = agentExecution?.stepResults || [];
const idx = existing.findIndex(s => s.stepOrder === stepOrder);
let next: AgentStepResult[];
if (idx >= 0) {
next = existing.map((s, i) => (i === idx ? { ...s, ...patch } : s));
} else {
next = [
...existing,
{
stepOrder,
method: '',
status: 'pending',
retryCount: 0,
...patch,
} as AgentStepResult,
].sort((a, b) => a.stepOrder - b.stepOrder);
}
updateAgentExecution({ stepResults: next, currentStep: stepOrder });
};
if (parsed.type === 'step_coding') {
patchStepResult(parsed.stepOrder || 0, {
status: 'coding',
partialCode: parsed.partialCode,
retryCount: parsed.retryCount || 0,
});
const { updateAgentExecution } = useSSAStore.getState();
updateAgentExecution({
partialCode: parsed.partialCode,
status: 'coding',
});
continue;
}
if (parsed.type === 'step_code_ready') {
patchStepResult(parsed.stepOrder || 0, {
status: 'coding',
code: parsed.code,
partialCode: undefined,
});
const { updateAgentExecution } = useSSAStore.getState();
updateAgentExecution({
generatedCode: parsed.code,
partialCode: undefined,
status: 'coding',
});
continue;
}
if (parsed.type === 'step_executing') {
patchStepResult(parsed.stepOrder || 0, { status: 'executing' });
const { updateAgentExecution } = useSSAStore.getState();
updateAgentExecution({ status: 'executing' });
continue;
}
if (parsed.type === 'step_result') {
patchStepResult(parsed.stepOrder || 0, {
status: 'completed',
reportBlocks: parsed.reportBlocks,
durationMs: parsed.durationMs,
errorMessage: undefined,
});
continue;
}
if (parsed.type === 'step_error') {
patchStepResult(parsed.stepOrder || 0, {
status: parsed.willRetry ? 'coding' : 'error',
errorMessage: parsed.message,
retryCount: parsed.retryCount || 0,
});
continue;
}
if (parsed.type === 'step_skipped') {
patchStepResult(parsed.stepOrder || 0, {
status: 'skipped',
errorMessage: parsed.reason,
});
continue;
}
if (parsed.type === 'pipeline_aborted') {
const { updateAgentExecution } = useSSAStore.getState();
updateAgentExecution({
status: 'error',
errorMessage: parsed.error || '执行已终止',
currentStep: parsed.stepOrder,
});
continue;
}
if (parsed.type === 'code_generating') {
const { updateAgentExecution } = useSSAStore.getState();
updateAgentExecution({
partialCode: parsed.partialCode,
status: 'coding',
});
continue;
}
if (parsed.type === 'code_generated') {
const { updateAgentExecution } = useSSAStore.getState();
updateAgentExecution({
generatedCode: parsed.code || undefined,
partialCode: undefined,
status: 'code_pending',
});
continue;
}
if (parsed.type === 'code_executing') {
const { updateAgentExecution } = useSSAStore.getState();
updateAgentExecution({ status: 'executing' });
continue;
}
if (parsed.type === 'code_result') {
const { updateAgentExecution, agentExecution: curExec } = useSSAStore.getState();
updateAgentExecution({
reportBlocks: parsed.reportBlocks,
generatedCode: parsed.code || curExec?.generatedCode,
status: 'completed',
durationMs: parsed.durationMs,
stepResults: parsed.stepResults || curExec?.stepResults,
});
// 在对话中插入可点击的结果卡片
const execId = curExec?.id;
const execQuery = curExec?.query || content;
setChatMessages(prev => [...prev, {
id: crypto.randomUUID(),
role: 'assistant' as const,
content: `✅ 分析完成:${execQuery}`,
status: 'complete' as const,
intent: 'system',
executionId: execId,
executionQuery: execQuery,
createdAt: new Date().toISOString(),
}]);
continue;
}
if (parsed.type === 'code_error') {
const { updateAgentExecution } = useSSAStore.getState();
if (parsed.willRetry) {
updateAgentExecution({
errorMessage: parsed.message,
});
} else {
updateAgentExecution({
status: 'error',
errorMessage: parsed.message,
});
}
continue;
}
if (parsed.type === 'code_retry') {
const { updateAgentExecution } = useSSAStore.getState();
updateAgentExecution({
status: 'coding',
retryCount: parsed.retryCount || 0,
partialCode: undefined,
});
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
));
retryCountRef.current = 0;
} else {
const isNetworkError = /failed to fetch|networkerror|network|http2|protocol/i.test(err.message || '');
// 瞬时网络错误自动重试一次
if (isNetworkError && retryCountRef.current < MAX_AUTO_RETRY) {
retryCountRef.current += 1;
const delay = retryDelay(retryCountRef.current);
setChatMessages(prev => prev.map(m =>
m.id === assistantMsgId
? { ...m, content: `⏳ 网络波动,${(delay / 1000).toFixed(1)}s 后自动重试(第 ${retryCountRef.current}/${MAX_AUTO_RETRY} 次)...`, status: 'generating' }
: m
));
setIsGenerating(false);
if (abortRef.current === controller) {
abortRef.current = null;
}
await new Promise(r => setTimeout(r, delay));
return sendChatMessage(sessionId, content, metadata);
}
retryCountRef.current = 0;
const friendlyMsg = toFriendlyError(err);
setError(friendlyMsg);
setChatMessages(prev => prev.map(m =>
m.id === assistantMsgId
? { ...m, content: `⚠️ ${friendlyMsg}`, status: 'error' }
: m
));
}
} finally {
setIsGenerating(false);
setStreamingContent('');
setThinkingContent('');
if (abortRef.current === controller) {
abortRef.current = null;
}
}
}, []);
/**
* 右侧工作区按钮触发 Agent 操作(方案 B
* 1. 在左侧对话追加审计轨迹消息(系统风格)
* 2. 调用后端 API 触发下一步
* 3. 后端返回 SSE 事件更新右侧工作区 + 左侧简短 LLM 回复
*/
const executeAgentAction = useCallback(async (sessionId: string, action: AgentActionType) => {
const AUDIT_MESSAGES: Record<AgentActionType, string> = {
confirm_plan: '✅ 方案已确认,已进入执行确认(执行时将分步生成代码)...',
confirm_code: '✅ 代码已确认R 引擎正在执行...',
cancel: '❌ 已取消当前分析',
};
const auditContent = AUDIT_MESSAGES[action];
// 1. 追加审计轨迹消息(系统风格,不是用户消息)
const auditMsgId = crypto.randomUUID();
setChatMessages(prev => [...prev, {
id: auditMsgId,
role: 'assistant' as const,
content: auditContent,
status: 'complete' as const,
intent: 'system',
createdAt: new Date().toISOString(),
}]);
// 2. 通过 sendChatMessage 触发后端(带 agentAction metadata
await sendChatMessage(sessionId, auditContent, { agentAction: action });
}, [sendChatMessage]);
/**
* 响应 ask_user 卡片Phase III
* 将 value 解析为中文 label 用于显示
*/
const respondToQuestion = useCallback(async (sessionId: string, response: AskUserResponseData) => {
const question = pendingQuestion;
setPendingQuestion(null);
let displayText: string;
if (response.action === 'select' && response.selectedValues) {
const labels = response.selectedValues.map(val => {
const opt = question?.options?.find(o => o.value === val);
return opt?.label || val;
});
displayText = `选择了 ${labels.join('、')}`;
} else if (response.action === 'free_text') {
displayText = response.freeText || '(已回复)';
} else {
displayText = '(已回复)';
}
await sendChatMessage(sessionId, displayText, { askUserResponse: response });
}, [sendChatMessage, pendingQuestion]);
/**
* 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]);
const retryLastMessage = useCallback(async () => {
const last = lastRequestRef.current;
if (!last) return;
retryCountRef.current = 0;
setChatMessages(prev => {
let lastErrIdx = -1;
for (let i = prev.length - 1; i >= 0; i--) {
if (prev[i].status === 'error') { lastErrIdx = i; break; }
}
return lastErrIdx >= 0 ? prev.filter((_: ChatMessage, i: number) => i !== lastErrIdx) : prev;
});
await sendChatMessage(last.sessionId, last.content, last.metadata);
}, [sendChatMessage]);
return {
chatMessages,
isGenerating,
currentIntent,
intentMeta,
thinkingContent,
streamingContent,
error,
pendingQuestion,
pendingPlanConfirm,
sendChatMessage,
executeAgentAction,
respondToQuestion,
skipQuestion,
loadHistory,
abort,
clearMessages,
retryLastMessage,
};
}
export default useSSAChat;