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:
@@ -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;
|
||||
|
||||
@@ -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 && (
|
||||
|
||||
@@ -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`;
|
||||
|
||||
@@ -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}>
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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] })),
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
/* ── 方案 B:Agent 操作按钮 ── */
|
||||
.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;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user