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
753 lines
27 KiB
TypeScript
753 lines
27 KiB
TypeScript
/**
|
||
* 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, 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;
|