feat(ssa): Agent channel UX optimization (Solution B) + Plan-and-Execute architecture design

SSA Agent channel improvements (12 code files, +931/-203 lines):
- Solution B: left/right separation of concerns (gaze guiding + state mutex + time-travel)
- JWT token refresh mechanism (ensureFreshToken) to fix HTTP 401 during pipeline
- Code truncation fix: LLM maxTokens 4000->8000 + CSS max-height 60vh
- Retry streaming code generation with generateCodeStream()
- R Docker structured errors: 20+ pattern matching + format_agent_error + line extraction
- Prompt iron rules: strict output format in CoderAgent System Prompt
- parseCode robustness: XML/Markdown/inference 3-tier matching + length validation
- consoleOutput type defense: handle both array and scalar from R Docker unboxedJSON
- Agent progress bar sync: derive phase from agentExecution.status
- Export report / view code buttons restored for Agent mode
- ExecutingProgress component: real-time timer + dynamic tips + step pulse animation

Architecture design (3 review reports):
- Plan-and-Execute step-by-step execution architecture approved
- Code accumulation strategy (R Docker stays stateless)
- 5 engineering guardrails: XML tags, AST pre-check, defensive prompts, high-fidelity schema, error classification circuit breaker

Docs: update SSA module status v4.1, system status v6.7, deployment changelist
Made-with: Cursor
This commit is contained in:
2026-03-07 22:32:32 +08:00
parent 87655ea7e6
commit 52989cd03f
18 changed files with 1334 additions and 230 deletions

View File

@@ -1,10 +1,10 @@
/**
* AgentCodePanel — Agent 通道工作区面板
* AgentCodePanel — Agent 通道工作区面板(方案 B右侧集中操作
*
* 分步展示:计划 → 流式代码生成 → 执行结果
* 在 Agent 模式下替代 WorkflowTimeline
* 所有确认/取消操作在此面板内完成,左侧对话区只做审计纪要
*/
import React from 'react';
import React, { useState, useEffect, useRef } from 'react';
import {
Code,
CheckCircle,
@@ -14,10 +14,16 @@ import {
Sparkles,
FileText,
Play,
Ban,
} from 'lucide-react';
import { useSSAStore } from '../stores/ssaStore';
import type { AgentExecutionStatus } from '../types';
export interface AgentCodePanelProps {
onAction?: (action: 'confirm_plan' | 'confirm_code' | 'cancel') => void;
actionLoading?: boolean;
}
const STATUS_LABEL: Record<AgentExecutionStatus, string> = {
pending: '等待中',
planning: '制定计划中...',
@@ -43,7 +49,7 @@ const StatusBadge: React.FC<{ status: AgentExecutionStatus }> = ({ status }) =>
);
};
export const AgentCodePanel: React.FC = () => {
export const AgentCodePanel: React.FC<AgentCodePanelProps> = ({ onAction, actionLoading }) => {
const { agentExecution, executionMode } = useSSAStore();
if (executionMode !== 'agent') return null;
@@ -52,7 +58,7 @@ export const AgentCodePanel: React.FC = () => {
return (
<div className="agent-code-panel">
<div className="agent-code-header">
<h4><Sparkles size={14} /> Agent </h4>
<h4><Sparkles size={14} /> Agent 线</h4>
</div>
<div className="agent-code-empty">
<Sparkles size={32} style={{ color: '#94a3b8', marginBottom: 12 }} />
@@ -65,14 +71,19 @@ export const AgentCodePanel: React.FC = () => {
const { status, planText, planSteps, generatedCode, partialCode, errorMessage, retryCount, durationMs } = agentExecution;
const displayCode = generatedCode || partialCode;
const isStreamingCode = status === 'coding' && !!partialCode && !generatedCode;
const isStreamingCode = status === 'coding' && !!partialCode;
const displayCode = isStreamingCode ? partialCode : (generatedCode || partialCode);
return (
<div className="agent-code-panel">
<div className="agent-code-header">
<h4><Code size={14} /> Agent 线</h4>
<StatusBadge status={status} />
<div style={{ display: 'flex', alignItems: 'center', gap: 8 }}>
{retryCount > 0 && status !== 'completed' && status !== 'error' && (
<span className="badge-retry"><RefreshCw size={11} /> #{retryCount}</span>
)}
<StatusBadge status={status} />
</div>
</div>
{/* Step 1: 分析计划 */}
@@ -100,6 +111,28 @@ export const AgentCodePanel: React.FC = () => {
{planText && !planSteps?.length && (
<div className="agent-plan-text">{planText}</div>
)}
{/* 计划确认操作按钮 */}
{status === 'plan_pending' && onAction && (
<div className="agent-action-bar">
<button
className="agent-action-btn primary"
onClick={() => onAction('confirm_plan')}
disabled={actionLoading}
>
{actionLoading ? <Loader2 size={14} className="spin" /> : <CheckCircle size={14} />}
</button>
<button
className="agent-action-btn secondary"
onClick={() => onAction('cancel')}
disabled={actionLoading}
>
<Ban size={14} />
</button>
</div>
)}
</div>
)}
@@ -123,18 +156,51 @@ export const AgentCodePanel: React.FC = () => {
</div>
)}
</div>
{/* 代码确认操作按钮 */}
{status === 'code_pending' && onAction && (
<div className="agent-action-bar">
<button
className="agent-action-btn primary"
onClick={() => onAction('confirm_code')}
disabled={actionLoading}
>
{actionLoading ? <Loader2 size={14} className="spin" /> : <Play size={14} />}
</button>
<button
className="agent-action-btn secondary"
onClick={() => onAction('cancel')}
disabled={actionLoading}
>
<Ban size={14} />
</button>
</div>
)}
</div>
)}
{/* 重试信息 */}
{retryCount > 0 && (
<div className="agent-retry-info">
<RefreshCw size={14} />
<span> {retryCount} Agent </span>
{/* 重试状态 + 上次错误 */}
{retryCount > 0 && status === 'coding' && (
<div className="agent-retry-section">
<div className="agent-retry-info">
<RefreshCw size={14} />
<span> {retryCount} Agent </span>
</div>
{errorMessage && (
<div className="agent-retry-error">
<XCircle size={12} />
<div className="agent-retry-error-detail">
<span className="agent-retry-error-label"></span>
<pre>{errorMessage}</pre>
</div>
</div>
)}
</div>
)}
{/* 错误信息 */}
{/* 最终失败错误 */}
{status === 'error' && errorMessage && errorMessage !== '用户取消' && (
<div className="agent-error-bar">
<XCircle size={13} />
@@ -142,12 +208,9 @@ export const AgentCodePanel: React.FC = () => {
</div>
)}
{/* 执行中 */}
{/* 执行中 — 带计时器和步骤提示 */}
{status === 'executing' && (
<div className="agent-executing-bar">
<Loader2 size={14} className="spin" />
<span>R ...</span>
</div>
<ExecutingProgress planSteps={planSteps} retryCount={retryCount} />
)}
{/* 完成状态 */}
@@ -161,4 +224,65 @@ export const AgentCodePanel: React.FC = () => {
);
};
const ExecutingProgress: React.FC<{
planSteps?: Array<{ order: number; method: string; description: string }>;
retryCount: number;
}> = ({ planSteps, retryCount }) => {
const [elapsed, setElapsed] = useState(0);
const startRef = useRef(Date.now());
useEffect(() => {
startRef.current = Date.now();
setElapsed(0);
const timer = setInterval(() => {
setElapsed(Math.floor((Date.now() - startRef.current) / 1000));
}, 1000);
return () => clearInterval(timer);
}, []);
const minutes = Math.floor(elapsed / 60);
const seconds = elapsed % 60;
const timeStr = minutes > 0
? `${minutes}:${String(seconds).padStart(2, '0')}`
: `${seconds}s`;
const tipMessages = [
'正在加载数据并检查结构...',
'执行描述性统计分析...',
'运行统计检验...',
'构建回归模型...',
'生成图表和报告模块...',
'仍在计算中,复杂分析可能需要较长时间...',
];
const tipIdx = Math.min(Math.floor(elapsed / 10), tipMessages.length - 1);
return (
<div className="agent-executing-panel">
<div className="agent-executing-header">
<Loader2 size={16} className="spin" />
<span className="agent-executing-title">
R {retryCount > 0 ? `(第 ${retryCount + 1} 次尝试)` : ''}
</span>
<span className="agent-executing-timer">{timeStr}</span>
</div>
<div className="agent-executing-tip">{tipMessages[tipIdx]}</div>
{planSteps && planSteps.length > 0 && (
<div className="agent-executing-steps">
{planSteps.map((s, i) => (
<div key={i} className="agent-exec-step">
<span className="exec-step-dot" />
<span className="exec-step-label">{s.order}. {s.method}</span>
</div>
))}
</div>
)}
{elapsed >= 30 && (
<div className="agent-executing-patience">
2
</div>
)}
</div>
);
};
export default AgentCodePanel;

View File

@@ -75,6 +75,7 @@ export const SSAChatPane: React.FC = () => {
currentIntent,
pendingQuestion,
sendChatMessage,
executeAgentAction,
respondToQuestion,
skipQuestion,
loadHistory,
@@ -102,6 +103,17 @@ export const SSAChatPane: React.FC = () => {
}
}, [currentSession?.id, loadHistory, clearMessages]);
// 方案 B: 注册 agentActionHandler让右侧工作区按钮能触发 Agent 操作
const { setAgentActionHandler, selectAgentExecution, agentExecutionHistory } = useSSAStore();
useEffect(() => {
if (currentSession?.id) {
setAgentActionHandler((action: string) =>
executeAgentAction(currentSession.id, action as any),
);
}
return () => setAgentActionHandler(null);
}, [currentSession?.id, executeAgentAction, setAgentActionHandler]);
// 自动滚动到底部,确保最新内容可见
const scrollToBottom = useCallback(() => {
if (messagesContainerRef.current) {
@@ -377,51 +389,85 @@ 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} />
)}
{chatMessages.map((msg: ChatMessage) => {
const isSystemAudit = (msg as any).intent === 'system';
{/* 深度思考折叠 */}
{msg.role === 'assistant' && msg.thinking && (
<ThinkingBlock
content={msg.thinking}
isThinking={msg.status === 'generating'}
defaultExpanded={false}
/>
)}
// 系统审计消息(方案 B右侧操作的审计纪要
if (isSystemAudit) {
return (
<div key={msg.id} className="message message-system slide-up">
<div className="system-audit-msg">{msg.content}</div>
</div>
);
}
{/* 消息内容 */}
{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">
<ReactMarkdown remarkPlugins={[remarkGfm]}>
{msg.content}
</ReactMarkdown>
</div>
)}
return (
<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 && msg.intent !== 'system' && (
<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">
<ReactMarkdown remarkPlugins={[remarkGfm]}>
{msg.content}
</ReactMarkdown>
</div>
)}
</div>
</div>
);
})}
{/* 时光机卡片Agent 执行历史(点击可切换右侧工作区) */}
{agentExecutionHistory.filter(e => e.status === 'completed').length > 1 && (
<div className="agent-history-cards slide-up">
{agentExecutionHistory
.filter(e => e.status === 'completed')
.slice(0, -1)
.map(exec => (
<button
key={exec.id}
className="agent-history-card"
onClick={() => selectAgentExecution(exec.id)}
>
<BarChart2 size={14} />
<span className="agent-history-query">{exec.query}</span>
<span className="agent-history-badge"></span>
<ArrowRight size={12} />
</button>
))}
</div>
))}
)}
{/* 数据画像生成中指示器 */}
{dataProfileLoading && (

View File

@@ -14,6 +14,8 @@ export const SSACodeModal: React.FC = () => {
addToast,
currentRecordId,
analysisHistory,
executionMode,
agentExecution,
} = useSSAStore();
const [code, setCode] = useState<string>('');
@@ -27,26 +29,31 @@ export const SSACodeModal: React.FC = () => {
if (!codeModalVisible) return;
setIsLoading(true);
try {
const steps = record?.steps ?? [];
const successSteps = steps.filter(
(s) => (s.status === 'success' || s.status === 'warning') && s.result
);
if (successSteps.length > 0) {
const allCode = successSteps
.map((s) => {
const stepCode = (s.result as any)?.reproducible_code;
const header = `# ========================================\n# 步骤 ${s.step_number}: ${s.tool_name}\n# ========================================\n`;
return header + (stepCode || '# 该步骤暂无可用代码');
})
.join('\n\n');
setCode(allCode);
if (executionMode === 'agent' && agentExecution?.generatedCode) {
const header = `# ========================================\n# Agent 生成的 R 代码\n# 分析任务: ${agentExecution.query || '统计分析'}\n# ========================================\n`;
setCode(header + agentExecution.generatedCode);
} else {
setCode('# 暂无可用代码\n# 请先执行分析');
const steps = record?.steps ?? [];
const successSteps = steps.filter(
(s) => (s.status === 'success' || s.status === 'warning') && s.result
);
if (successSteps.length > 0) {
const allCode = successSteps
.map((s) => {
const stepCode = (s.result as any)?.reproducible_code;
const header = `# ========================================\n# 步骤 ${s.step_number}: ${s.tool_name}\n# ========================================\n`;
return header + (stepCode || '# 该步骤暂无可用代码');
})
.join('\n\n');
setCode(allCode);
} else {
setCode('# 暂无可用代码\n# 请先执行分析');
}
}
} finally {
setIsLoading(false);
}
}, [codeModalVisible, record]);
}, [codeModalVisible, record, executionMode, agentExecution]);
if (!codeModalVisible) return null;
@@ -56,7 +63,10 @@ export const SSACodeModal: React.FC = () => {
const now = new Date();
const pad = (n: number) => n.toString().padStart(2, '0');
const ts = `${now.getFullYear()}${pad(now.getMonth() + 1)}${pad(now.getDate())}_${pad(now.getHours())}${pad(now.getMinutes())}${pad(now.getSeconds())}`;
const title = (record?.plan?.title || 'analysis')
const rawTitle = executionMode === 'agent'
? (agentExecution?.query || 'agent_analysis')
: (record?.plan?.title || 'analysis');
const title = rawTitle
.replace(/[^a-zA-Z0-9\u4e00-\u9fa5_-]/g, '_')
.slice(0, 30);
return `SSA_${title}_${ts}.R`;

View File

@@ -51,6 +51,9 @@ export const SSAWorkspacePane: React.FC = () => {
dataContext,
executionMode,
agentExecution,
agentActionHandler,
agentActionLoading,
setAgentActionLoading,
} = useSSAStore();
const { executeWorkflow, cancelWorkflow, isExecuting: isWorkflowExecuting } = useWorkflow();
@@ -73,8 +76,18 @@ export const SSAWorkspacePane: React.FC = () => {
const steps = record?.steps ?? [];
const progress = record?.progress ?? 0;
const conclusion = record?.conclusionReport ?? null;
const phase = record?.status ?? 'planning';
const hasResults = steps.some(stepHasResult);
const agentStatus = agentExecution?.status;
const agentHasResults = !!(agentExecution?.status === 'completed' && agentExecution.reportBlocks?.length);
const phase = executionMode === 'agent'
? (agentStatus === 'completed' ? 'completed'
: agentStatus === 'error' ? 'error'
: (agentStatus === 'executing' || agentStatus === 'coding' || agentStatus === 'code_pending') ? 'executing'
: 'planning')
: (record?.status ?? 'planning');
const hasResults = executionMode === 'agent' ? agentHasResults : steps.some(stepHasResult);
// Scroll to results when switching to a completed record
useEffect(() => {
@@ -99,6 +112,16 @@ export const SSAWorkspacePane: React.FC = () => {
const handleClose = () => setWorkspaceOpen(false);
const handleAgentAction = useCallback(async (action: string) => {
if (!agentActionHandler) return;
setAgentActionLoading(true);
try {
await agentActionHandler(action);
} finally {
setAgentActionLoading(false);
}
}, [agentActionHandler, setAgentActionLoading]);
const { dataProfile } = useSSAStore();
const variableDictionary = dataContext.variableDictionary;
@@ -182,17 +205,24 @@ export const SSAWorkspacePane: React.FC = () => {
const handleExportReport = async () => {
try {
const allBlocks = steps
.filter(stepHasResult)
.flatMap((s) => {
const r = s.result as any;
return (s.reportBlocks ?? r?.report_blocks ?? []) as ReportBlock[];
});
let allBlocks: ReportBlock[];
if (executionMode === 'agent' && agentExecution?.reportBlocks) {
allBlocks = agentExecution.reportBlocks;
} else {
allBlocks = steps
.filter(stepHasResult)
.flatMap((s) => {
const r = s.result as any;
return (s.reportBlocks ?? r?.report_blocks ?? []) as ReportBlock[];
});
}
if (allBlocks.length > 0) {
await exportBlocksToWord(allBlocks, {
title: plan?.title || currentSession?.title || '统计分析报告',
});
const title = (executionMode === 'agent'
? agentExecution?.query
: plan?.title) || currentSession?.title || '统计分析报告';
await exportBlocksToWord(allBlocks, { title });
}
addToast('报告导出成功', 'success');
} catch (err: any) {
@@ -200,7 +230,15 @@ export const SSAWorkspacePane: React.FC = () => {
}
};
const handleExportCode = () => setCodeModalVisible(true);
const [showAgentCode, setShowAgentCode] = useState(false);
const handleExportCode = () => {
if (executionMode === 'agent' && agentExecution?.generatedCode) {
setShowAgentCode(prev => !prev);
} else {
setCodeModalVisible(true);
}
};
const scrollToSection = (section: 'sap' | 'execution' | 'result') => {
const refs: Record<string, React.RefObject<HTMLDivElement | null>> = {
@@ -305,7 +343,10 @@ export const SSAWorkspacePane: React.FC = () => {
{/* ===== Agent 模式工作区 ===== */}
{executionMode === 'agent' && (
<div className="section-block">
<AgentCodePanel />
<AgentCodePanel
onAction={handleAgentAction}
actionLoading={agentActionLoading}
/>
{/* Agent 模式的报告输出复用 DynamicReport */}
{agentExecution?.status === 'completed' && agentExecution.reportBlocks && agentExecution.reportBlocks.length > 0 && (
<div className="section-block result-section" ref={resultRef}>

View File

@@ -14,7 +14,7 @@
*/
import { useState, useCallback, useRef } from 'react';
import { getAccessToken } from '@/framework/auth/api';
import { getAccessToken, isTokenExpired, refreshAccessToken } from '@/framework/auth/api';
import { useSSAStore } from '../stores/ssaStore';
import type { AskUserEventData, AskUserResponseData } from '../components/AskUserCard';
import type { WorkflowPlan } from '../types';
@@ -54,6 +54,8 @@ interface OpenAIChunk {
}>;
}
export type AgentActionType = 'confirm_plan' | 'confirm_code' | 'cancel';
export interface UseSSAChatReturn {
chatMessages: ChatMessage[];
isGenerating: boolean;
@@ -65,6 +67,7 @@ export interface UseSSAChatReturn {
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>;
@@ -89,6 +92,17 @@ export function useSSAChat(): UseSSAChatReturn {
const abortRef = useRef<AbortController | null>(null);
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();
@@ -113,7 +127,7 @@ export function useSSAChat(): UseSSAChatReturn {
*/
const loadHistory = useCallback(async (sessionId: string) => {
try {
const token = getAccessToken();
const token = await ensureFreshToken();
const resp = await fetch(`/api/v1/ssa/sessions/${sessionId}/chat/history`, {
headers: { Authorization: `Bearer ${token}` },
});
@@ -151,13 +165,7 @@ export function useSSAChat(): UseSSAChatReturn {
setIntentMeta(null);
setPendingQuestion(null);
const userMsg: ChatMessage = {
id: crypto.randomUUID(),
role: 'user',
content,
status: 'complete',
createdAt: new Date().toISOString(),
};
const isAgentAction = !!metadata?.agentAction;
const assistantMsgId = crypto.randomUUID();
const assistantPlaceholder: ChatMessage = {
@@ -168,14 +176,26 @@ export function useSSAChat(): UseSSAChatReturn {
createdAt: new Date().toISOString(),
};
setChatMessages(prev => [...prev, userMsg, assistantPlaceholder]);
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]);
}
abortRef.current = new AbortController();
let fullContent = '';
let fullThinking = '';
try {
const token = getAccessToken();
const token = await ensureFreshToken();
const response = await fetch(`/api/v1/ssa/sessions/${sessionId}/chat`, {
method: 'POST',
headers: {
@@ -260,10 +280,10 @@ export function useSSAChat(): UseSSAChatReturn {
// ── Agent 通道 SSE 事件 ──
if (parsed.type === 'agent_planning') {
const { setAgentExecution, setWorkspaceOpen, setActivePane } = useSSAStore.getState();
setAgentExecution({
const { pushAgentExecution, setWorkspaceOpen, setActivePane } = useSSAStore.getState();
pushAgentExecution({
id: parsed.executionId || crypto.randomUUID(),
sessionId: '',
sessionId: sessionId,
query: content,
retryCount: 0,
status: 'planning',
@@ -321,10 +341,16 @@ export function useSSAChat(): UseSSAChatReturn {
if (parsed.type === 'code_error') {
const { updateAgentExecution } = useSSAStore.getState();
updateAgentExecution({
status: 'error',
errorMessage: parsed.message,
});
if (parsed.willRetry) {
updateAgentExecution({
errorMessage: parsed.message,
});
} else {
updateAgentExecution({
status: 'error',
errorMessage: parsed.message,
});
}
continue;
}
@@ -333,8 +359,7 @@ export function useSSAChat(): UseSSAChatReturn {
updateAgentExecution({
status: 'coding',
retryCount: parsed.retryCount || 0,
generatedCode: parsed.code,
errorMessage: undefined,
partialCode: undefined,
});
continue;
}
@@ -401,6 +426,36 @@ export function useSSAChat(): UseSSAChatReturn {
}
}, []);
/**
* 右侧工作区按钮触发 Agent 操作(方案 B
* 1. 在左侧对话追加审计轨迹消息(系统风格)
* 2. 调用后端 API 触发下一步
* 3. 后端返回 SSE 事件更新右侧工作区 + 左侧简短 LLM 回复
*/
const executeAgentAction = useCallback(async (sessionId: string, action: AgentActionType) => {
const AUDIT_MESSAGES: Record<AgentActionType, string> = {
confirm_plan: '✅ 方案已确认,正在生成 R 代码...',
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' as any,
createdAt: new Date().toISOString(),
}]);
// 2. 通过 sendChatMessage 触发后端(带 agentAction metadata
await sendChatMessage(sessionId, auditContent, { agentAction: action });
}, [sendChatMessage]);
/**
* 响应 ask_user 卡片Phase III
* 将 value 解析为中文 label 用于显示
@@ -448,6 +503,7 @@ export function useSSAChat(): UseSSAChatReturn {
pendingQuestion,
pendingPlanConfirm,
sendChatMessage,
executeAgentAction,
respondToQuestion,
skipQuestion,
loadHistory,

View File

@@ -79,12 +79,22 @@ interface SSAState {
executionMode: ExecutionMode;
/** Agent 通道当前执行记录 */
agentExecution: AgentExecutionRecord | null;
/** Agent 通道执行历史(时光机) */
agentExecutionHistory: AgentExecutionRecord[];
/** Agent 操作回调(由 SSAChatPane 设置SSAWorkspacePane 消费) */
agentActionHandler: ((action: string) => Promise<void>) | null;
/** Agent 操作加载状态 */
agentActionLoading: boolean;
// ---- actions ----
setMode: (mode: SSAMode) => void;
setExecutionMode: (mode: ExecutionMode) => void;
setAgentExecution: (exec: AgentExecutionRecord | null) => void;
updateAgentExecution: (patch: Partial<AgentExecutionRecord>) => void;
pushAgentExecution: (exec: AgentExecutionRecord) => void;
selectAgentExecution: (id: string) => void;
setAgentActionHandler: (handler: ((action: string) => Promise<void>) | null) => void;
setAgentActionLoading: (loading: boolean) => void;
setCurrentSession: (session: SSASession | null) => void;
addMessage: (message: SSAMessage) => void;
setMessages: (messages: SSAMessage[]) => void;
@@ -153,6 +163,9 @@ const initialState = {
hasUnsavedPlanChanges: false,
executionMode: 'qper' as ExecutionMode,
agentExecution: null as AgentExecutionRecord | null,
agentExecutionHistory: [] as AgentExecutionRecord[],
agentActionHandler: null as ((action: string) => Promise<void>) | null,
agentActionLoading: false,
dataContext: {
dataOverview: null,
variableDictionary: [],
@@ -172,9 +185,26 @@ export const useSSAStore = create<SSAState>((set) => ({
setExecutionMode: (mode) => set({ executionMode: mode }),
setAgentExecution: (exec) => set({ agentExecution: exec }),
updateAgentExecution: (patch) =>
set((state) => {
const updated = state.agentExecution ? { ...state.agentExecution, ...patch } : null;
// 同步更新历史记录
const history = updated
? state.agentExecutionHistory.map(h => h.id === updated.id ? updated : h)
: state.agentExecutionHistory;
return { agentExecution: updated, agentExecutionHistory: history };
}),
pushAgentExecution: (exec) =>
set((state) => ({
agentExecution: state.agentExecution ? { ...state.agentExecution, ...patch } : null,
agentExecution: exec,
agentExecutionHistory: [...state.agentExecutionHistory, exec],
})),
selectAgentExecution: (id) =>
set((state) => {
const found = state.agentExecutionHistory.find(h => h.id === id);
return found ? { agentExecution: found, workspaceOpen: true } : {};
}),
setAgentActionHandler: (handler) => set({ agentActionHandler: handler }),
setAgentActionLoading: (loading) => set({ agentActionLoading: loading }),
setCurrentSession: (session) => set({ currentSession: session }),
addMessage: (message) =>
set((state) => ({ messages: [...state.messages, message] })),

View File

@@ -1345,6 +1345,18 @@
font-weight: 500;
}
.badge-retry {
display: inline-flex;
align-items: center;
gap: 4px;
font-size: 10px;
padding: 2px 8px;
border-radius: 10px;
background: #3b2f1e;
color: #fbbf24;
font-weight: 500;
}
.badge-streaming {
display: inline-flex;
align-items: center;
@@ -1408,7 +1420,7 @@
/* 代码区域 */
.agent-code-body {
padding: 12px 16px;
max-height: 400px;
max-height: 60vh;
overflow-y: auto;
}
@@ -1419,7 +1431,8 @@
line-height: 1.6;
color: #e2e8f0;
white-space: pre-wrap;
word-break: break-all;
word-break: break-word;
overflow-wrap: break-word;
}
.agent-code-body pre.streaming {
@@ -1448,17 +1461,53 @@
}
/* 状态条 */
.agent-retry-section {
border-top: 1px solid #534120;
}
.agent-retry-info {
display: flex;
align-items: center;
gap: 8px;
padding: 10px 16px;
background: #3b2f1e;
border-top: 1px solid #534120;
font-size: 12px;
color: #fbbf24;
}
.agent-retry-error {
display: flex;
align-items: flex-start;
gap: 8px;
padding: 8px 16px;
background: #2d2020;
font-size: 11px;
color: #f87171;
}
.agent-retry-error-detail {
flex: 1;
min-width: 0;
}
.agent-retry-error-label {
display: block;
font-weight: 600;
margin-bottom: 4px;
color: #fca5a5;
}
.agent-retry-error pre {
margin: 0;
font-size: 11px;
line-height: 1.5;
white-space: pre-wrap;
word-break: break-word;
color: #fecaca;
max-height: 120px;
overflow-y: auto;
}
.agent-error-bar {
display: flex;
align-items: flex-start;
@@ -1488,6 +1537,92 @@
color: #93c5fd;
}
.agent-executing-panel {
border-top: 1px solid #1e40af;
background: linear-gradient(135deg, #1e3a5f 0%, #1a2744 100%);
padding: 14px 16px;
}
.agent-executing-header {
display: flex;
align-items: center;
gap: 8px;
}
.agent-executing-title {
font-size: 13px;
font-weight: 600;
color: #93c5fd;
flex: 1;
}
.agent-executing-timer {
font-family: 'JetBrains Mono', monospace;
font-size: 14px;
font-weight: 700;
color: #60a5fa;
background: #172554;
padding: 2px 10px;
border-radius: 6px;
min-width: 50px;
text-align: center;
}
.agent-executing-tip {
margin-top: 8px;
font-size: 11px;
color: #94a3b8;
padding-left: 24px;
animation: fade-in 0.5s ease;
}
@keyframes fade-in {
from { opacity: 0; transform: translateY(-4px); }
to { opacity: 1; transform: translateY(0); }
}
.agent-executing-steps {
margin-top: 10px;
padding-left: 24px;
display: flex;
flex-wrap: wrap;
gap: 6px;
}
.agent-exec-step {
display: inline-flex;
align-items: center;
gap: 4px;
font-size: 10px;
color: #64748b;
background: #1e293b;
padding: 2px 8px;
border-radius: 4px;
}
.exec-step-dot {
width: 5px;
height: 5px;
border-radius: 50%;
background: #3b82f6;
animation: pulse-dot 1.5s ease infinite;
}
@keyframes pulse-dot {
0%, 100% { opacity: 0.3; }
50% { opacity: 1; }
}
.agent-executing-patience {
margin-top: 10px;
padding: 8px 12px;
padding-left: 24px;
font-size: 11px;
color: #fbbf24;
background: rgba(251, 191, 36, 0.08);
border-radius: 6px;
}
.agent-success-bar {
display: flex;
align-items: center;
@@ -1498,3 +1633,109 @@
font-size: 12px;
color: #86efac;
}
/* ── 方案 BAgent 操作按钮 ── */
.agent-action-bar {
display: flex;
align-items: center;
gap: 10px;
padding: 12px 16px;
border-top: 1px solid rgba(148, 163, 184, 0.12);
}
.agent-action-btn {
display: inline-flex;
align-items: center;
gap: 6px;
padding: 8px 18px;
border: none;
border-radius: 6px;
font-size: 13px;
font-weight: 500;
cursor: pointer;
transition: all 0.2s;
}
.agent-action-btn.primary {
background: #2563eb;
color: #fff;
}
.agent-action-btn.primary:hover:not(:disabled) {
background: #1d4ed8;
}
.agent-action-btn.secondary {
background: rgba(148, 163, 184, 0.15);
color: #94a3b8;
}
.agent-action-btn.secondary:hover:not(:disabled) {
background: rgba(148, 163, 184, 0.25);
color: #cbd5e1;
}
.agent-action-btn:disabled {
opacity: 0.5;
cursor: not-allowed;
}
/* ── 方案 B系统审计消息 ── */
.message-system {
display: flex;
justify-content: center;
padding: 4px 0;
}
.system-audit-msg {
display: inline-flex;
align-items: center;
gap: 4px;
padding: 4px 14px;
background: rgba(148, 163, 184, 0.08);
border: 1px solid rgba(148, 163, 184, 0.12);
border-radius: 12px;
font-size: 12px;
color: #94a3b8;
}
/* ── 方案 B时光机卡片 ── */
.agent-history-cards {
display: flex;
flex-direction: column;
gap: 6px;
padding: 8px 48px;
}
.agent-history-card {
display: flex;
align-items: center;
gap: 8px;
padding: 8px 14px;
background: rgba(30, 41, 59, 0.5);
border: 1px solid rgba(148, 163, 184, 0.12);
border-radius: 8px;
color: #94a3b8;
font-size: 12px;
cursor: pointer;
transition: all 0.2s;
}
.agent-history-card:hover {
background: rgba(30, 41, 59, 0.8);
border-color: rgba(59, 130, 246, 0.3);
color: #e2e8f0;
}
.agent-history-query {
flex: 1;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.agent-history-badge {
padding: 2px 8px;
background: rgba(34, 197, 94, 0.15);
color: #86efac;
border-radius: 4px;
font-size: 10px;
}