fix(ssa): Fix 7 integration bugs and refactor frontend unified state management
Bug fixes: - Fix garbled error messages in chat (TypeWriter rendering issue) - Fix R engine NA crash in descriptive.R (defensive isTRUE/is.na checks) - Fix intent misclassification for statistical significance queries - Fix step 2 results not displayed (accept warning status alongside success) - Fix incomplete R code download (only step 1 included) - Fix multi-task state confusion (clicking old card shows new results) - Add R engine and backend parameter logging for debugging Refactor - Unified Record Architecture: - Replace 12 global singleton fields with AnalysisRecord as single source of truth - Remove isWorkflowMode branching across all components - One Analysis = One Record = N Steps paradigm - selectRecord only sets currentRecordId, all rendering derives from currentRecord - Fix cross-hook-instance issue: executeWorkflow fallback to store currentRecordId Updated files: ssaStore, useWorkflow, useAnalysis, SSAChatPane, SSAWorkspacePane, SSACodeModal, WorkflowTimeline, QueryService, WorkflowExecutorService, descriptive.R Tested: Manual integration test passed - multi-task switching, R code completeness Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
@@ -24,6 +24,7 @@ import {
|
||||
} from 'lucide-react';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
import { useSSAStore } from '../stores/ssaStore';
|
||||
import type { AnalysisRecord } from '../stores/ssaStore';
|
||||
import { useAnalysis } from '../hooks/useAnalysis';
|
||||
import { useWorkflow } from '../hooks/useWorkflow';
|
||||
import type { SSAMessage } from '../types';
|
||||
@@ -40,16 +41,15 @@ export const SSAChatPane: React.FC = () => {
|
||||
mountedFile,
|
||||
setMountedFile,
|
||||
setCurrentSession,
|
||||
setActivePane,
|
||||
setWorkspaceOpen,
|
||||
currentPlan,
|
||||
isLoading,
|
||||
isExecuting,
|
||||
error,
|
||||
setError,
|
||||
addToast,
|
||||
addMessage,
|
||||
selectAnalysisRecord,
|
||||
selectRecord,
|
||||
analysisHistory,
|
||||
dataProfile,
|
||||
dataProfileLoading,
|
||||
} = useSSAStore();
|
||||
@@ -78,10 +78,9 @@ export const SSAChatPane: React.FC = () => {
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
// 延迟滚动,确保 DOM 更新完成
|
||||
const timer = setTimeout(scrollToBottom, 100);
|
||||
return () => clearTimeout(timer);
|
||||
}, [messages, currentPlan, scrollToBottom]);
|
||||
}, [messages, scrollToBottom]);
|
||||
|
||||
const handleBack = () => {
|
||||
navigate(-1);
|
||||
@@ -154,18 +153,20 @@ export const SSAChatPane: React.FC = () => {
|
||||
const query = inputValue;
|
||||
setInputValue('');
|
||||
|
||||
// Immediately show user message in chat
|
||||
addMessage({
|
||||
id: crypto.randomUUID(),
|
||||
role: 'user',
|
||||
content: query,
|
||||
createdAt: new Date().toISOString(),
|
||||
});
|
||||
|
||||
try {
|
||||
if (currentSession?.id) {
|
||||
// Phase Q: 先做意图解析,低置信度时追问
|
||||
const intentResp = await parseIntent(currentSession.id, query);
|
||||
|
||||
if (intentResp.needsClarification && intentResp.clarificationCards?.length > 0) {
|
||||
addMessage({
|
||||
id: crypto.randomUUID(),
|
||||
role: 'user',
|
||||
content: query,
|
||||
createdAt: new Date().toISOString(),
|
||||
});
|
||||
addMessage({
|
||||
id: crypto.randomUUID(),
|
||||
role: 'assistant',
|
||||
@@ -180,7 +181,7 @@ export const SSAChatPane: React.FC = () => {
|
||||
return;
|
||||
}
|
||||
|
||||
// 置信度足够 → 直接生成工作流计划
|
||||
// 置信度足够 → 直接生成工作流计划(不再重复添加用户消息)
|
||||
await generateWorkflowPlan(currentSession.id, query);
|
||||
} else {
|
||||
await generatePlan(query);
|
||||
@@ -235,15 +236,13 @@ export const SSAChatPane: React.FC = () => {
|
||||
}
|
||||
};
|
||||
|
||||
// 打开工作区,可选择特定的分析记录
|
||||
const handleOpenWorkspace = useCallback((recordId?: string) => {
|
||||
if (recordId) {
|
||||
selectAnalysisRecord(recordId);
|
||||
selectRecord(recordId);
|
||||
} else {
|
||||
setWorkspaceOpen(true);
|
||||
setActivePane('sap');
|
||||
}
|
||||
}, [selectAnalysisRecord, setWorkspaceOpen, setActivePane]);
|
||||
}, [selectRecord, setWorkspaceOpen]);
|
||||
|
||||
const formatFileSize = (bytes: number) => {
|
||||
if (bytes < 1024) return `${bytes}B`;
|
||||
@@ -321,21 +320,31 @@ export const SSAChatPane: React.FC = () => {
|
||||
msg.content
|
||||
)}
|
||||
|
||||
{/* SAP 卡片 - 只有消息中明确标记为 sap 类型时才显示 */}
|
||||
{msg.artifactType === 'sap' && msg.recordId && (
|
||||
<button className="sap-card" onClick={() => handleOpenWorkspace(msg.recordId)}>
|
||||
<div className="sap-card-left">
|
||||
<div className="sap-card-icon">
|
||||
<FileSignature size={16} />
|
||||
{/* SAP / Result 卡片 - 统一行为:selectRecord + 打开工作区 */}
|
||||
{(msg.artifactType === 'sap' || msg.artifactType === 'result') && msg.recordId && (() => {
|
||||
const rec = analysisHistory.find((r: AnalysisRecord) => r.id === msg.recordId);
|
||||
const isCompleted = rec?.status === 'completed';
|
||||
const isSap = msg.artifactType === 'sap';
|
||||
return (
|
||||
<button className="sap-card" onClick={() => handleOpenWorkspace(msg.recordId)}>
|
||||
<div className="sap-card-left">
|
||||
<div className="sap-card-icon">
|
||||
{isSap ? <FileSignature size={16} /> : <BarChart2 size={16} />}
|
||||
</div>
|
||||
<div className="sap-card-content">
|
||||
<div className="sap-card-title">
|
||||
{isSap ? '查看分析计划' : '查看分析结果'}
|
||||
{isCompleted && <span className="sap-card-badge">已完成</span>}
|
||||
</div>
|
||||
<div className="sap-card-hint">
|
||||
{rec?.plan?.title || '点击打开工作区查看详情'}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="sap-card-content">
|
||||
<div className="sap-card-title">查看分析计划 (SAP)</div>
|
||||
<div className="sap-card-hint">参数映射完成,等待执行</div>
|
||||
</div>
|
||||
</div>
|
||||
<ArrowRight size={16} className="sap-card-arrow" />
|
||||
</button>
|
||||
)}
|
||||
<ArrowRight size={16} className="sap-card-arrow" />
|
||||
</button>
|
||||
);
|
||||
})()}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -1,85 +1,79 @@
|
||||
/**
|
||||
* SSACodeModal - V11 R代码模态框
|
||||
*
|
||||
* 100% 还原 V11 原型图
|
||||
* 调用后端 API 获取真实执行代码
|
||||
* SSACodeModal - R 代码模态框 (Unified Record Architecture)
|
||||
*
|
||||
* 从 currentRecord.steps 聚合所有步骤的可复现代码。
|
||||
*/
|
||||
import React, { useEffect, useState } from 'react';
|
||||
import { X, Download, Loader2 } from 'lucide-react';
|
||||
import { useSSAStore } from '../stores/ssaStore';
|
||||
import { useAnalysis } from '../hooks/useAnalysis';
|
||||
|
||||
export const SSACodeModal: React.FC = () => {
|
||||
const { codeModalVisible, setCodeModalVisible, executionResult, addToast, isWorkflowMode, workflowSteps } = useSSAStore();
|
||||
const { downloadCode } = useAnalysis();
|
||||
const {
|
||||
codeModalVisible,
|
||||
setCodeModalVisible,
|
||||
addToast,
|
||||
currentRecordId,
|
||||
analysisHistory,
|
||||
} = useSSAStore();
|
||||
|
||||
const [code, setCode] = useState<string>('');
|
||||
const [isLoading, setIsLoading] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
if (codeModalVisible) {
|
||||
loadCode();
|
||||
}
|
||||
}, [codeModalVisible]);
|
||||
const record = currentRecordId
|
||||
? analysisHistory.find((r) => r.id === currentRecordId) ?? null
|
||||
: null;
|
||||
|
||||
const loadCode = async () => {
|
||||
useEffect(() => {
|
||||
if (!codeModalVisible) return;
|
||||
setIsLoading(true);
|
||||
try {
|
||||
if (isWorkflowMode && workflowSteps.length > 0) {
|
||||
const allCode = workflowSteps
|
||||
.filter(s => s.status === 'success' && s.result)
|
||||
.map(s => {
|
||||
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 || `# 该步骤暂无可用代码`);
|
||||
return header + (stepCode || '# 该步骤暂无可用代码');
|
||||
})
|
||||
.join('\n\n');
|
||||
setCode(allCode || '# 暂无可用代码\n# 请先执行分析');
|
||||
} else {
|
||||
const result = await downloadCode();
|
||||
const text = await result.blob.text();
|
||||
setCode(text);
|
||||
}
|
||||
} catch (error) {
|
||||
if (executionResult?.reproducibleCode) {
|
||||
setCode(executionResult.reproducibleCode);
|
||||
setCode(allCode);
|
||||
} else {
|
||||
setCode('# 暂无可用代码\n# 请先执行分析');
|
||||
}
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
}
|
||||
};
|
||||
}, [codeModalVisible, record]);
|
||||
|
||||
if (!codeModalVisible) return null;
|
||||
|
||||
const handleClose = () => {
|
||||
setCodeModalVisible(false);
|
||||
const handleClose = () => setCodeModalVisible(false);
|
||||
|
||||
const generateFilename = () => {
|
||||
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')
|
||||
.replace(/[^a-zA-Z0-9\u4e00-\u9fa5_-]/g, '_')
|
||||
.slice(0, 30);
|
||||
return `SSA_${title}_${ts}.R`;
|
||||
};
|
||||
|
||||
const handleDownload = async () => {
|
||||
const handleDownload = () => {
|
||||
try {
|
||||
if (isWorkflowMode && code) {
|
||||
const blob = new Blob([code], { type: 'text/plain' });
|
||||
const url = URL.createObjectURL(blob);
|
||||
const a = document.createElement('a');
|
||||
a.href = url;
|
||||
a.download = 'workflow_analysis.R';
|
||||
a.click();
|
||||
URL.revokeObjectURL(url);
|
||||
addToast('R 脚本已下载', 'success');
|
||||
handleClose();
|
||||
} else {
|
||||
const result = await downloadCode();
|
||||
const url = URL.createObjectURL(result.blob);
|
||||
const a = document.createElement('a');
|
||||
a.href = url;
|
||||
a.download = result.filename;
|
||||
a.click();
|
||||
URL.revokeObjectURL(url);
|
||||
addToast('R 脚本已下载', 'success');
|
||||
handleClose();
|
||||
}
|
||||
} catch (error) {
|
||||
const blob = new Blob([code], { type: 'text/plain' });
|
||||
const url = URL.createObjectURL(blob);
|
||||
const a = document.createElement('a');
|
||||
a.href = url;
|
||||
a.download = generateFilename();
|
||||
a.click();
|
||||
URL.revokeObjectURL(url);
|
||||
addToast('R 脚本已下载', 'success');
|
||||
handleClose();
|
||||
} catch {
|
||||
addToast('下载失败', 'error');
|
||||
}
|
||||
};
|
||||
@@ -101,7 +95,7 @@ export const SSACodeModal: React.FC = () => {
|
||||
<X size={16} />
|
||||
</button>
|
||||
</header>
|
||||
|
||||
|
||||
<div className="code-modal-body">
|
||||
{isLoading ? (
|
||||
<div className="code-loading">
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -1,9 +1,20 @@
|
||||
/**
|
||||
* 多步骤工作流时间线组件
|
||||
*
|
||||
* Phase 2A: 在工作区显示多步骤分析计划的垂直时间线
|
||||
* WorkflowTimeline - 多步骤分析计划时间线
|
||||
*
|
||||
* 精美卡片式布局:标题区 → 护栏横幅 → 步骤卡片列表 → 底部提示
|
||||
*/
|
||||
import React from 'react';
|
||||
import {
|
||||
FlaskConical,
|
||||
BarChart3,
|
||||
Shield,
|
||||
AlertTriangle,
|
||||
CheckCircle,
|
||||
XCircle,
|
||||
Loader2,
|
||||
Clock,
|
||||
ListChecks,
|
||||
} from 'lucide-react';
|
||||
import type { WorkflowPlan, WorkflowStepDef, WorkflowStepResult, WorkflowStepStatus } from '../types';
|
||||
|
||||
interface WorkflowTimelineProps {
|
||||
@@ -13,109 +24,151 @@ interface WorkflowTimelineProps {
|
||||
isExecuting?: boolean;
|
||||
}
|
||||
|
||||
const statusConfig: Record<WorkflowStepStatus | 'pending', {
|
||||
icon: string;
|
||||
color: string;
|
||||
bg: string;
|
||||
animation?: string;
|
||||
const statusStyle: Record<WorkflowStepStatus | 'pending', {
|
||||
borderColor: string;
|
||||
dotBg: string;
|
||||
dotColor: string;
|
||||
}> = {
|
||||
pending: { icon: '○', color: '#94a3b8', bg: '#f1f5f9' },
|
||||
running: { icon: '◎', color: '#2563eb', bg: '#eff6ff', animation: 'pulse' },
|
||||
success: { icon: '✓', color: '#059669', bg: '#ecfdf5' },
|
||||
failed: { icon: '✕', color: '#dc2626', bg: '#fef2f2' },
|
||||
skipped: { icon: '⊘', color: '#94a3b8', bg: '#f8fafc' },
|
||||
warning: { icon: '!', color: '#d97706', bg: '#fffbeb' },
|
||||
pending: { borderColor: '#e2e8f0', dotBg: '#f1f5f9', dotColor: '#94a3b8' },
|
||||
running: { borderColor: '#3b82f6', dotBg: '#dbeafe', dotColor: '#2563eb' },
|
||||
success: { borderColor: '#10b981', dotBg: '#d1fae5', dotColor: '#059669' },
|
||||
failed: { borderColor: '#ef4444', dotBg: '#fee2e2', dotColor: '#dc2626' },
|
||||
skipped: { borderColor: '#e2e8f0', dotBg: '#f8fafc', dotColor: '#94a3b8' },
|
||||
warning: { borderColor: '#f59e0b', dotBg: '#fef3c7', dotColor: '#d97706' },
|
||||
};
|
||||
|
||||
const toolIcons: Record<string, string> = {
|
||||
't_test': '📊',
|
||||
'welch_t_test': '📊',
|
||||
'paired_t_test': '🔗',
|
||||
'mann_whitney_u': '📈',
|
||||
'wilcoxon_signed_rank': '📈',
|
||||
'chi_square_test': '📋',
|
||||
'fisher_exact_test': '📋',
|
||||
'one_way_anova': '📉',
|
||||
'kruskal_wallis': '📉',
|
||||
'pearson_correlation': '🔄',
|
||||
'spearman_correlation': '🔄',
|
||||
'logistic_regression': '📐',
|
||||
'default': '🔬',
|
||||
const StatusDot: React.FC<{ status: WorkflowStepStatus | 'pending'; num: number }> = ({ status, num }) => {
|
||||
const s = statusStyle[status];
|
||||
if (status === 'running') {
|
||||
return (
|
||||
<span className="wt-dot running" style={{ background: s.dotBg, borderColor: s.dotColor }}>
|
||||
<Loader2 size={14} className="spin" style={{ color: s.dotColor }} />
|
||||
</span>
|
||||
);
|
||||
}
|
||||
if (status === 'success') {
|
||||
return (
|
||||
<span className="wt-dot" style={{ background: s.dotBg, borderColor: s.dotColor }}>
|
||||
<CheckCircle size={14} style={{ color: s.dotColor }} />
|
||||
</span>
|
||||
);
|
||||
}
|
||||
if (status === 'failed') {
|
||||
return (
|
||||
<span className="wt-dot" style={{ background: s.dotBg, borderColor: s.dotColor }}>
|
||||
<XCircle size={14} style={{ color: s.dotColor }} />
|
||||
</span>
|
||||
);
|
||||
}
|
||||
return (
|
||||
<span className="wt-dot" style={{ background: s.dotBg, borderColor: s.dotColor, color: s.dotColor }}>
|
||||
{num}
|
||||
</span>
|
||||
);
|
||||
};
|
||||
|
||||
const getToolIcon = (toolCode: string): string => {
|
||||
return toolIcons[toolCode] || toolIcons.default;
|
||||
const HIDDEN_PARAMS = new Set(['session_id', 'sessionId', 'data_path', 'dataPath', 'original_filename']);
|
||||
|
||||
const formatValue = (value: unknown): string => {
|
||||
if (value === null || value === undefined) return '—';
|
||||
if (Array.isArray(value)) {
|
||||
if (value.length === 0) return '—';
|
||||
if (value.length <= 4) return value.join(', ');
|
||||
return `${value.slice(0, 3).join(', ')} … 等${value.length}项`;
|
||||
}
|
||||
const s = String(value);
|
||||
return s.length > 50 ? s.slice(0, 47) + '…' : s;
|
||||
};
|
||||
|
||||
interface StepItemProps {
|
||||
const PARAM_LABELS: Record<string, string> = {
|
||||
variables: '分析变量',
|
||||
outcome_var: '结局变量 (Y)',
|
||||
predictors: '自变量 (X)',
|
||||
group_var: '分组变量',
|
||||
value_var: '因变量',
|
||||
method: '统计方法',
|
||||
alternative: '假设方向',
|
||||
conf_level: '置信水平',
|
||||
var_x: '变量 X',
|
||||
var_y: '变量 Y',
|
||||
};
|
||||
|
||||
interface StepCardProps {
|
||||
step: WorkflowStepDef;
|
||||
result?: WorkflowStepResult;
|
||||
isLast: boolean;
|
||||
isCurrent: boolean;
|
||||
}
|
||||
|
||||
const StepItem: React.FC<StepItemProps> = ({ step, result, isLast, isCurrent }) => {
|
||||
const status = result?.status || 'pending';
|
||||
const config = statusConfig[status];
|
||||
|
||||
const StepCard: React.FC<StepCardProps> = ({ step, result, isLast, isCurrent }) => {
|
||||
const status: WorkflowStepStatus | 'pending' = result?.status || 'pending';
|
||||
const s = statusStyle[status];
|
||||
|
||||
const visibleParams = step.params
|
||||
? Object.entries(step.params).filter(([k]) => !HIDDEN_PARAMS.has(k))
|
||||
: [];
|
||||
|
||||
return (
|
||||
<div className={`timeline-step ${status} ${isCurrent ? 'current' : ''}`}>
|
||||
<div className="step-connector">
|
||||
<div
|
||||
className={`step-dot ${config.animation || ''}`}
|
||||
style={{ backgroundColor: config.bg, borderColor: config.color }}
|
||||
>
|
||||
<span style={{ color: config.color }}>{config.icon}</span>
|
||||
</div>
|
||||
{!isLast && <div className="step-line" style={{ borderColor: result?.status === 'success' ? '#059669' : '#e2e8f0' }} />}
|
||||
<div className={`wt-step-row ${isCurrent ? 'wt-current' : ''}`}>
|
||||
{/* Left rail */}
|
||||
<div className="wt-rail">
|
||||
<StatusDot status={status} num={step.step_number} />
|
||||
{!isLast && (
|
||||
<div
|
||||
className="wt-line"
|
||||
style={{ borderColor: status === 'success' ? '#10b981' : '#e2e8f0' }}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="step-content">
|
||||
<div className="step-header">
|
||||
<span className="step-number">步骤 {step.step_number}</span>
|
||||
<span className="tool-icon">{getToolIcon(step.tool_code)}</span>
|
||||
<span className="tool-name">{step.tool_name}</span>
|
||||
{step.is_sensitivity && (
|
||||
<span className="sensitivity-badge">敏感性分析</span>
|
||||
)}
|
||||
{result?.duration_ms && (
|
||||
<span className="step-duration">{result.duration_ms}ms</span>
|
||||
|
||||
{/* Card */}
|
||||
<div className="wt-card" style={{ borderLeftColor: s.borderColor }}>
|
||||
<div className="wt-card-head">
|
||||
<div className="wt-card-title-row">
|
||||
<span className="wt-step-badge">步骤 {step.step_number}</span>
|
||||
<span className="wt-tool-name">{step.tool_name}</span>
|
||||
{step.is_sensitivity && <span className="wt-sensitivity">敏感性</span>}
|
||||
</div>
|
||||
{result?.duration_ms != null && (
|
||||
<span className="wt-duration">{result.duration_ms}ms</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="step-description">{step.description}</div>
|
||||
|
||||
{step.description && (
|
||||
<p className="wt-card-desc">{step.description}</p>
|
||||
)}
|
||||
|
||||
{step.switch_condition && (
|
||||
<div className="step-guardrail">
|
||||
🛡️ 护栏:{step.switch_condition}
|
||||
<div className="wt-guardrail-inline">
|
||||
<Shield size={12} />
|
||||
<span>{step.switch_condition}</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{step.params && Object.keys(step.params).length > 0 && (
|
||||
<div className="step-params">
|
||||
{Object.entries(step.params).slice(0, 3).map(([key, value]) => (
|
||||
<span key={key} className="param-tag">
|
||||
{key}: {String(value)}
|
||||
</span>
|
||||
|
||||
{visibleParams.length > 0 && (
|
||||
<div className="wt-params-grid">
|
||||
{visibleParams.slice(0, 5).map(([key, value]) => (
|
||||
<div key={key} className="wt-param-item">
|
||||
<span className="wt-param-label">{PARAM_LABELS[key] || key}</span>
|
||||
<span className="wt-param-val">{formatValue(value)}</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{result?.status === 'success' && result.result?.p_value !== undefined && (
|
||||
<div className="step-result-preview">
|
||||
<span className="result-badge">
|
||||
<div className="wt-result-row">
|
||||
<span className="wt-p-badge">
|
||||
p = {result.result.p_value < 0.001 ? '< 0.001' : result.result.p_value.toFixed(4)}
|
||||
</span>
|
||||
{result.result.p_value < 0.05 && (
|
||||
<span className="significant-badge">显著 *</span>
|
||||
)}
|
||||
{result.result.p_value < 0.05 && <span className="wt-sig-badge">显著 *</span>}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{result?.status === 'failed' && result.error && (
|
||||
<div className="step-error">
|
||||
<span className="error-icon">⚠️</span>
|
||||
<span className="error-message">{result.error}</span>
|
||||
<div className="wt-error-row">
|
||||
<AlertTriangle size={13} />
|
||||
<span>{typeof result.error === 'object' ? (result.error as any)?.userHint || JSON.stringify(result.error) : result.error}</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
@@ -129,78 +182,82 @@ export const WorkflowTimeline: React.FC<WorkflowTimelineProps> = ({
|
||||
currentStep,
|
||||
isExecuting = false,
|
||||
}) => {
|
||||
const getStepResult = (stepNumber: number): WorkflowStepResult | undefined => {
|
||||
return stepResults.find(r => r.step_number === stepNumber);
|
||||
};
|
||||
|
||||
const completedSteps = stepResults.filter(r => r.status === 'success').length;
|
||||
const progress = plan.total_steps > 0 ? (completedSteps / plan.total_steps) * 100 : 0;
|
||||
const getResult = (n: number) => stepResults.find(r => r.step_number === n);
|
||||
const done = stepResults.filter(r => r.status === 'success').length;
|
||||
const pct = plan.total_steps > 0 ? (done / plan.total_steps) * 100 : 0;
|
||||
|
||||
return (
|
||||
<div className="workflow-timeline">
|
||||
<div className="timeline-header">
|
||||
<div className="header-info">
|
||||
<h3 className="timeline-title">{plan.title}</h3>
|
||||
<p className="timeline-description">{plan.description}</p>
|
||||
<div className="wt-root">
|
||||
{/* Header */}
|
||||
<div className="wt-header">
|
||||
<div className="wt-header-icon">
|
||||
<FlaskConical size={20} />
|
||||
</div>
|
||||
<div className="header-meta">
|
||||
<span className="step-count">
|
||||
共 {plan.total_steps} 个分析步骤
|
||||
</span>
|
||||
{plan.estimated_time_seconds && (
|
||||
<span className="estimated-time">
|
||||
预计 {Math.ceil(plan.estimated_time_seconds / 60)} 分钟
|
||||
<div className="wt-header-body">
|
||||
<h3 className="wt-title">{plan.title}</h3>
|
||||
<p className="wt-desc">{plan.description}</p>
|
||||
<div className="wt-meta">
|
||||
<span className="wt-meta-item">
|
||||
<ListChecks size={13} />
|
||||
{plan.total_steps} 个分析步骤
|
||||
</span>
|
||||
)}
|
||||
{plan.estimated_time_seconds != null && (
|
||||
<span className="wt-meta-item">
|
||||
<Clock size={13} />
|
||||
预计 {plan.estimated_time_seconds < 60 ? `${plan.estimated_time_seconds}秒` : `${Math.ceil(plan.estimated_time_seconds / 60)}分钟`}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* EPV Warning */}
|
||||
{plan.epv_warning && (
|
||||
<div className="epv-warning-banner">
|
||||
<span className="epv-icon">⚠️</span>
|
||||
<div className="wt-banner wt-banner-warn">
|
||||
<AlertTriangle size={15} />
|
||||
<span>{plan.epv_warning}</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Guardrail Banner */}
|
||||
{plan.planned_trace?.fallbackTool && (
|
||||
<div className="guardrail-banner">
|
||||
<span className="guardrail-icon">🛡️</span>
|
||||
<div className="wt-banner wt-banner-guard">
|
||||
<Shield size={15} />
|
||||
<span>
|
||||
主方法:{plan.planned_trace.primaryTool}
|
||||
→ 若{plan.planned_trace.switchCondition}则自动降级为 {plan.planned_trace.fallbackTool}
|
||||
主方法 <b>{plan.planned_trace.primaryTool}</b>
|
||||
{' → '}若 {plan.planned_trace.switchCondition} 则自动降级为 <b>{plan.planned_trace.fallbackTool}</b>
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Progress */}
|
||||
{isExecuting && (
|
||||
<div className="timeline-progress">
|
||||
<div className="progress-bar">
|
||||
<div
|
||||
className="progress-fill"
|
||||
style={{ width: `${progress}%` }}
|
||||
/>
|
||||
<div className="wt-progress-bar-wrap">
|
||||
<div className="wt-progress-track">
|
||||
<div className="wt-progress-fill" style={{ width: `${pct}%` }} />
|
||||
</div>
|
||||
<span className="progress-text">
|
||||
{completedSteps}/{plan.total_steps} 完成
|
||||
</span>
|
||||
<span className="wt-progress-label">{done}/{plan.total_steps}</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="timeline-steps">
|
||||
{plan.steps.map((step, index) => (
|
||||
<StepItem
|
||||
{/* Steps */}
|
||||
<div className="wt-steps">
|
||||
{plan.steps.map((step, i) => (
|
||||
<StepCard
|
||||
key={step.step_number}
|
||||
step={step}
|
||||
result={getStepResult(step.step_number)}
|
||||
isLast={index === plan.steps.length - 1}
|
||||
result={getResult(step.step_number)}
|
||||
isLast={i === plan.steps.length - 1}
|
||||
isCurrent={currentStep === step.step_number}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* Footer */}
|
||||
{!isExecuting && stepResults.length === 0 && (
|
||||
<div className="timeline-footer">
|
||||
<span className="ready-hint">✨ 分析计划已就绪,点击「开始分析」执行</span>
|
||||
<div className="wt-footer">
|
||||
<BarChart3 size={14} />
|
||||
<span>分析计划已就绪,点击「开始分析」执行</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
@@ -1,15 +1,14 @@
|
||||
/**
|
||||
* SSA 分析相关的自定义 Hook
|
||||
*
|
||||
* 遵循规范:
|
||||
* - 使用 apiClient(带认证的 axios 实例)
|
||||
* - 使用 getAccessToken 处理文件上传
|
||||
* SSA 分析相关的自定义 Hook (Updated for Unified Record Architecture)
|
||||
*
|
||||
* uploadData / generatePlan 仍由 SSAChatPane 使用。
|
||||
* executeAnalysis / exportReport 保留但已切换到 record-based 存储。
|
||||
*/
|
||||
import { useCallback, useState } from 'react';
|
||||
import apiClient from '@/common/api/axios';
|
||||
import { getAccessToken } from '@/framework/auth/api';
|
||||
import { useSSAStore } from '../stores/ssaStore';
|
||||
import type { AnalysisPlan, ExecutionResult, SSAMessage, TraceStep } from '../types';
|
||||
import type { AnalysisPlan, ExecutionResult, SSAMessage } from '../types';
|
||||
import {
|
||||
Document,
|
||||
Packer,
|
||||
@@ -60,18 +59,13 @@ interface UseAnalysisReturn {
|
||||
export function useAnalysis(): UseAnalysisReturn {
|
||||
const {
|
||||
currentSession,
|
||||
currentPlan,
|
||||
setCurrentPlan,
|
||||
setExecutionResult,
|
||||
setTraceSteps,
|
||||
updateTraceStep,
|
||||
addMessage,
|
||||
setLoading,
|
||||
setExecuting,
|
||||
isExecuting,
|
||||
setError,
|
||||
addAnalysisRecord,
|
||||
updateAnalysisRecord,
|
||||
addRecord,
|
||||
updateRecord,
|
||||
} = useSSAStore();
|
||||
|
||||
const [isUploading, setIsUploading] = useState(false);
|
||||
@@ -85,7 +79,6 @@ export function useAnalysis(): UseAnalysisReturn {
|
||||
formData.append('file', file);
|
||||
|
||||
try {
|
||||
// 文件上传使用 fetch + 手动添加认证头(不设置 Content-Type)
|
||||
const token = getAccessToken();
|
||||
const headers: HeadersInit = {};
|
||||
if (token) {
|
||||
@@ -98,10 +91,7 @@ export function useAnalysis(): UseAnalysisReturn {
|
||||
body: formData,
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error('上传失败');
|
||||
}
|
||||
|
||||
if (!response.ok) throw new Error('上传失败');
|
||||
const result = await response.json();
|
||||
setUploadProgress(100);
|
||||
return result;
|
||||
@@ -115,10 +105,7 @@ export function useAnalysis(): UseAnalysisReturn {
|
||||
|
||||
const generatePlan = useCallback(
|
||||
async (query: string): Promise<AnalysisPlan> => {
|
||||
if (!currentSession) {
|
||||
throw new Error('请先上传数据');
|
||||
}
|
||||
|
||||
if (!currentSession) throw new Error('请先上传数据');
|
||||
setLoading(true);
|
||||
|
||||
try {
|
||||
@@ -134,31 +121,38 @@ export function useAnalysis(): UseAnalysisReturn {
|
||||
`${API_BASE}/sessions/${currentSession.id}/plan`,
|
||||
{ query }
|
||||
);
|
||||
|
||||
const plan: AnalysisPlan = response.data;
|
||||
|
||||
// 创建分析记录(支持多任务)
|
||||
const recordId = addAnalysisRecord(query, plan);
|
||||
|
||||
// 消息中携带 recordId,便于点击时定位
|
||||
|
||||
const recordId = addRecord(query, {
|
||||
workflow_id: plan.id || `legacy_${Date.now()}`,
|
||||
title: plan.title || plan.toolName || '统计分析',
|
||||
total_steps: 1,
|
||||
steps: [{
|
||||
step_number: 1,
|
||||
tool_code: plan.toolCode || 'unknown',
|
||||
tool_name: plan.toolName || '统计分析',
|
||||
description: plan.description || '',
|
||||
params: plan.parameters || {},
|
||||
}],
|
||||
} as any);
|
||||
|
||||
const planMessage: SSAMessage = {
|
||||
id: crypto.randomUUID(),
|
||||
role: 'assistant',
|
||||
content: `已生成分析方案:${plan.toolName}\n\n${plan.description}`,
|
||||
artifactType: 'sap',
|
||||
recordId, // 关联到分析记录
|
||||
recordId,
|
||||
createdAt: new Date().toISOString(),
|
||||
};
|
||||
addMessage(planMessage);
|
||||
|
||||
const confirmMessage: SSAMessage = {
|
||||
addMessage({
|
||||
id: crypto.randomUUID(),
|
||||
role: 'assistant',
|
||||
content: '请确认数据映射并执行分析。',
|
||||
artifactType: 'confirm',
|
||||
createdAt: new Date().toISOString(),
|
||||
};
|
||||
addMessage(confirmMessage);
|
||||
});
|
||||
|
||||
return plan;
|
||||
} catch (error) {
|
||||
@@ -168,411 +162,132 @@ export function useAnalysis(): UseAnalysisReturn {
|
||||
setLoading(false);
|
||||
}
|
||||
},
|
||||
[currentSession, addMessage, setCurrentPlan, setLoading, setError, addAnalysisRecord]
|
||||
[currentSession, addMessage, setLoading, setError, addRecord]
|
||||
);
|
||||
|
||||
const executePlan = useCallback(
|
||||
async (_planId: string): Promise<ExecutionResult> => {
|
||||
if (!currentSession) {
|
||||
throw new Error('请先上传数据');
|
||||
}
|
||||
if (!currentSession) throw new Error('请先上传数据');
|
||||
|
||||
// 获取当前 plan(从 store)
|
||||
const plan = useSSAStore.getState().currentPlan;
|
||||
if (!plan) {
|
||||
throw new Error('请先生成分析计划');
|
||||
}
|
||||
const record = (() => {
|
||||
const s = useSSAStore.getState();
|
||||
return s.currentRecordId
|
||||
? s.analysisHistory.find((r) => r.id === s.currentRecordId) ?? null
|
||||
: null;
|
||||
})();
|
||||
const planStep = record?.plan?.steps?.[0];
|
||||
if (!planStep) throw new Error('请先生成分析计划');
|
||||
|
||||
setExecuting(true);
|
||||
setExecutionResult(null);
|
||||
|
||||
const initialSteps: TraceStep[] = [
|
||||
{ index: 0, name: '参数验证', status: 'pending', message: '等待执行' },
|
||||
{ index: 1, name: '护栏检查', status: 'pending', message: '等待执行' },
|
||||
{ index: 2, name: '统计计算', status: 'pending', message: '等待执行' },
|
||||
{ index: 3, name: '可视化生成', status: 'pending', message: '等待执行' },
|
||||
{ index: 4, name: '结果格式化', status: 'pending', message: '等待执行' },
|
||||
];
|
||||
setTraceSteps(initialSteps);
|
||||
const rid = record!.id;
|
||||
updateRecord(rid, { status: 'executing', steps: [], progress: 0 });
|
||||
|
||||
try {
|
||||
updateTraceStep(0, { status: 'running' });
|
||||
|
||||
// 发送完整的 plan 对象(转换为后端格式)
|
||||
const response = await apiClient.post(
|
||||
`${API_BASE}/sessions/${currentSession.id}/execute`,
|
||||
{
|
||||
{
|
||||
plan: {
|
||||
tool_code: plan.toolCode,
|
||||
tool_name: plan.toolName,
|
||||
params: plan.parameters,
|
||||
guardrails: plan.guardrails
|
||||
}
|
||||
tool_code: planStep.tool_code,
|
||||
tool_name: planStep.tool_name,
|
||||
params: planStep.params,
|
||||
},
|
||||
}
|
||||
);
|
||||
|
||||
const result: ExecutionResult = response.data;
|
||||
|
||||
initialSteps.forEach((_, i) => {
|
||||
updateTraceStep(i, { status: 'success' });
|
||||
updateRecord(rid, {
|
||||
status: 'completed',
|
||||
progress: 100,
|
||||
steps: [{
|
||||
step_number: 1,
|
||||
tool_code: planStep.tool_code,
|
||||
tool_name: planStep.tool_name,
|
||||
status: 'success',
|
||||
result: result as any,
|
||||
started_at: new Date().toISOString(),
|
||||
completed_at: new Date().toISOString(),
|
||||
duration_ms: result.executionMs,
|
||||
logs: [],
|
||||
}],
|
||||
});
|
||||
|
||||
result.guardrailResults?.forEach((gr) => {
|
||||
if (gr.actionType === 'Switch' && gr.actionTaken) {
|
||||
updateTraceStep(1, {
|
||||
status: 'switched',
|
||||
actionType: 'Switch',
|
||||
switchTarget: gr.switchTarget,
|
||||
message: gr.message,
|
||||
});
|
||||
} else if (gr.actionType === 'Warn') {
|
||||
updateTraceStep(1, {
|
||||
actionType: 'Warn',
|
||||
message: gr.message,
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
setExecutionResult(result);
|
||||
|
||||
// 更新分析记录
|
||||
const recordId = useSSAStore.getState().currentRecordId;
|
||||
if (recordId) {
|
||||
updateAnalysisRecord(recordId, {
|
||||
executionResult: result,
|
||||
traceSteps: useSSAStore.getState().traceSteps,
|
||||
});
|
||||
}
|
||||
|
||||
const resultMessage: SSAMessage = {
|
||||
addMessage({
|
||||
id: crypto.randomUUID(),
|
||||
role: 'assistant',
|
||||
content: result.interpretation || '分析完成,请查看右侧结果面板。',
|
||||
artifactType: 'result',
|
||||
recordId: recordId || undefined, // 关联到分析记录
|
||||
recordId: rid,
|
||||
createdAt: new Date().toISOString(),
|
||||
};
|
||||
addMessage(resultMessage);
|
||||
});
|
||||
|
||||
return result;
|
||||
} catch (error: any) {
|
||||
initialSteps.forEach((step, i) => {
|
||||
if (step.status === 'running' || step.status === 'pending') {
|
||||
updateTraceStep(i, { status: 'failed' });
|
||||
}
|
||||
});
|
||||
|
||||
// 提取 R 服务返回的具体错误信息
|
||||
updateRecord(rid, { status: 'error' });
|
||||
const errorData = error.response?.data;
|
||||
const errorMessage = errorData?.user_hint || errorData?.error ||
|
||||
const errorMessage = errorData?.user_hint || errorData?.error ||
|
||||
(error instanceof Error ? error.message : '执行出错');
|
||||
|
||||
setError(errorMessage);
|
||||
throw new Error(errorMessage);
|
||||
} finally {
|
||||
setExecuting(false);
|
||||
}
|
||||
},
|
||||
[
|
||||
currentSession,
|
||||
addMessage,
|
||||
setExecutionResult,
|
||||
setTraceSteps,
|
||||
updateTraceStep,
|
||||
setExecuting,
|
||||
setError,
|
||||
]
|
||||
[currentSession, addMessage, setExecuting, setError, updateRecord]
|
||||
);
|
||||
|
||||
const executeAnalysis = useCallback(async (): Promise<ExecutionResult> => {
|
||||
if (!currentPlan) {
|
||||
throw new Error('请先生成分析计划');
|
||||
}
|
||||
return executePlan(currentPlan.id);
|
||||
}, [currentPlan, executePlan]);
|
||||
return executePlan('current');
|
||||
}, [executePlan]);
|
||||
|
||||
const downloadCode = useCallback(async (): Promise<DownloadResult> => {
|
||||
if (!currentSession) {
|
||||
throw new Error('请先上传数据');
|
||||
}
|
||||
|
||||
if (!currentSession) throw new Error('请先上传数据');
|
||||
const response = await apiClient.get(
|
||||
`${API_BASE}/sessions/${currentSession.id}/download-code`,
|
||||
{ responseType: 'blob' }
|
||||
);
|
||||
|
||||
const contentDisposition = response.headers['content-disposition'];
|
||||
let filename = `analysis_${currentSession.id}.R`;
|
||||
|
||||
if (contentDisposition) {
|
||||
const filenameMatch = contentDisposition.match(/filename[^;=\n]*=((['"]).*?\2|[^;\n]*)/);
|
||||
if (filenameMatch) {
|
||||
let extractedName = filenameMatch[1].replace(/['"]/g, '');
|
||||
try {
|
||||
extractedName = decodeURIComponent(extractedName);
|
||||
} catch {
|
||||
// 解码失败,使用原始值
|
||||
}
|
||||
if (extractedName) {
|
||||
filename = extractedName;
|
||||
}
|
||||
try { extractedName = decodeURIComponent(extractedName); } catch { /* ok */ }
|
||||
if (extractedName) filename = extractedName;
|
||||
}
|
||||
}
|
||||
|
||||
return { blob: response.data, filename };
|
||||
}, [currentSession]);
|
||||
|
||||
const exportReport = useCallback(async () => {
|
||||
const result = useSSAStore.getState().executionResult;
|
||||
const plan = useSSAStore.getState().currentPlan;
|
||||
const session = useSSAStore.getState().currentSession;
|
||||
const mountedFile = useSSAStore.getState().mountedFile;
|
||||
const { isWorkflowMode, workflowSteps, workflowPlan, conclusionReport } = useSSAStore.getState();
|
||||
|
||||
if (isWorkflowMode && workflowSteps.some(s => s.status === 'success')) {
|
||||
return exportWorkflowReport(workflowSteps, workflowPlan, conclusionReport, session, mountedFile);
|
||||
}
|
||||
|
||||
if (!result) {
|
||||
const state = useSSAStore.getState();
|
||||
const record = state.currentRecordId
|
||||
? state.analysisHistory.find((r) => r.id === state.currentRecordId) ?? null
|
||||
: null;
|
||||
if (!record) {
|
||||
setError('暂无分析结果可导出');
|
||||
return;
|
||||
}
|
||||
|
||||
const now = new Date();
|
||||
const dateStr = now.toLocaleString('zh-CN');
|
||||
const pValue = result.results?.pValue ?? (result.results as any)?.p_value;
|
||||
const pValueStr = pValue !== undefined
|
||||
? (pValue < 0.001 ? '< 0.001' : pValue.toFixed(4))
|
||||
: '-';
|
||||
|
||||
const groupVar = String(plan?.parameters?.groupVar || plan?.parameters?.group_var || '-');
|
||||
const valueVar = String(plan?.parameters?.valueVar || plan?.parameters?.value_var || '-');
|
||||
const dataFileName = mountedFile?.name || session?.title || '数据文件';
|
||||
const rowCount = mountedFile?.rowCount || session?.dataSchema?.rowCount || 0;
|
||||
|
||||
const createTableRow = (cells: string[], isHeader = false) => {
|
||||
return new TableRow({
|
||||
children: cells.map(text => new TableCell({
|
||||
children: [new Paragraph({
|
||||
children: [new TextRun({ text, bold: isHeader })],
|
||||
})],
|
||||
width: { size: 100 / cells.length, type: WidthType.PERCENTAGE },
|
||||
})),
|
||||
});
|
||||
};
|
||||
|
||||
const tableBorders = {
|
||||
top: { style: BorderStyle.SINGLE, size: 1 },
|
||||
bottom: { style: BorderStyle.SINGLE, size: 1 },
|
||||
left: { style: BorderStyle.SINGLE, size: 1 },
|
||||
right: { style: BorderStyle.SINGLE, size: 1 },
|
||||
insideHorizontal: { style: BorderStyle.SINGLE, size: 1 },
|
||||
insideVertical: { style: BorderStyle.SINGLE, size: 1 },
|
||||
};
|
||||
|
||||
const sections: (Paragraph | Table)[] = [];
|
||||
let sectionNum = 1;
|
||||
|
||||
sections.push(
|
||||
new Paragraph({
|
||||
text: '统计分析报告',
|
||||
heading: HeadingLevel.TITLE,
|
||||
alignment: AlignmentType.CENTER,
|
||||
}),
|
||||
new Paragraph({ text: '' }),
|
||||
new Paragraph({ children: [
|
||||
new TextRun({ text: '研究课题:', bold: true }),
|
||||
new TextRun(session?.title || plan?.title || '未命名分析'),
|
||||
]}),
|
||||
new Paragraph({ children: [
|
||||
new TextRun({ text: '生成时间:', bold: true }),
|
||||
new TextRun(dateStr),
|
||||
]}),
|
||||
new Paragraph({ text: '' }),
|
||||
const steps = record.steps.filter(
|
||||
(s) => (s.status === 'success' || s.status === 'warning') && s.result
|
||||
);
|
||||
|
||||
sections.push(
|
||||
new Paragraph({
|
||||
text: `${sectionNum++}. 数据描述`,
|
||||
heading: HeadingLevel.HEADING_1,
|
||||
}),
|
||||
new Table({
|
||||
width: { size: 100, type: WidthType.PERCENTAGE },
|
||||
borders: tableBorders,
|
||||
rows: [
|
||||
createTableRow(['项目', '内容'], true),
|
||||
createTableRow(['数据文件', dataFileName]),
|
||||
createTableRow(['样本量', `${rowCount} 行`]),
|
||||
createTableRow(['分组变量 (X)', groupVar]),
|
||||
createTableRow(['分析变量 (Y)', valueVar]),
|
||||
],
|
||||
}),
|
||||
new Paragraph({ text: '' }),
|
||||
);
|
||||
|
||||
sections.push(
|
||||
new Paragraph({
|
||||
text: `${sectionNum++}. 分析方法`,
|
||||
heading: HeadingLevel.HEADING_1,
|
||||
}),
|
||||
new Paragraph({
|
||||
text: `本研究采用 ${result.results?.method || plan?.toolName || '统计检验'} 方法,` +
|
||||
`比较 ${groupVar} 分组下 ${valueVar} 的差异。`,
|
||||
}),
|
||||
new Paragraph({ text: '' }),
|
||||
);
|
||||
|
||||
if (result.guardrailResults?.length) {
|
||||
sections.push(
|
||||
new Paragraph({
|
||||
text: `${sectionNum++}. 前提条件检验`,
|
||||
heading: HeadingLevel.HEADING_1,
|
||||
}),
|
||||
new Table({
|
||||
width: { size: 100, type: WidthType.PERCENTAGE },
|
||||
borders: tableBorders,
|
||||
rows: [
|
||||
createTableRow(['检查项', '结果', '说明'], true),
|
||||
...result.guardrailResults.map((gr: { checkName: string; passed: boolean; actionType: string; message: string }) => createTableRow([
|
||||
gr.checkName,
|
||||
gr.passed ? '通过' : gr.actionType === 'Switch' ? '降级' : '未通过',
|
||||
gr.message,
|
||||
])),
|
||||
],
|
||||
}),
|
||||
new Paragraph({ text: '' }),
|
||||
);
|
||||
if (steps.length === 0) {
|
||||
setError('暂无分析结果可导出');
|
||||
return;
|
||||
}
|
||||
|
||||
const resultAny = result.results as any;
|
||||
const groupStats = resultAny?.groupStats || resultAny?.group_stats;
|
||||
if (groupStats?.length) {
|
||||
sections.push(
|
||||
new Paragraph({
|
||||
text: `${sectionNum++}. 描述性统计`,
|
||||
heading: HeadingLevel.HEADING_1,
|
||||
}),
|
||||
new Table({
|
||||
width: { size: 100, type: WidthType.PERCENTAGE },
|
||||
borders: tableBorders,
|
||||
rows: [
|
||||
createTableRow(['分组', '样本量 (n)', '均值 (Mean)', '标准差 (SD)'], true),
|
||||
...groupStats.map((gs: any) => createTableRow([
|
||||
gs.group,
|
||||
String(gs.n),
|
||||
gs.mean?.toFixed(4) || '-',
|
||||
gs.sd?.toFixed(4) || '-',
|
||||
])),
|
||||
],
|
||||
}),
|
||||
new Paragraph({ text: '' }),
|
||||
);
|
||||
}
|
||||
const session = state.currentSession;
|
||||
const mountedFile = state.mountedFile;
|
||||
const conclusion = record.conclusionReport;
|
||||
|
||||
sections.push(
|
||||
new Paragraph({
|
||||
text: `${sectionNum++}. 统计检验结果`,
|
||||
heading: HeadingLevel.HEADING_1,
|
||||
}),
|
||||
new Table({
|
||||
width: { size: 100, type: WidthType.PERCENTAGE },
|
||||
borders: tableBorders,
|
||||
rows: [
|
||||
createTableRow(['指标', '值'], true),
|
||||
createTableRow(['统计方法', resultAny?.method || '-']),
|
||||
createTableRow(['统计量 (t/F/χ²)', resultAny?.statistic?.toFixed(4) || '-']),
|
||||
createTableRow(['自由度 (df)', resultAny?.df?.toFixed(2) || '-']),
|
||||
createTableRow(['P 值', pValueStr]),
|
||||
...(resultAny?.effectSize ? [createTableRow(['效应量', resultAny.effectSize.toFixed(3)])] : []),
|
||||
...(resultAny?.confInt ? [createTableRow(['95% 置信区间', `[${resultAny.confInt[0]?.toFixed(4)}, ${resultAny.confInt[1]?.toFixed(4)}]`])] : []),
|
||||
],
|
||||
}),
|
||||
new Paragraph({ text: '' }),
|
||||
);
|
||||
|
||||
const plotData = result.plots?.[0];
|
||||
if (plotData) {
|
||||
const imageBase64 = typeof plotData === 'string' ? plotData : plotData.imageBase64;
|
||||
if (imageBase64) {
|
||||
try {
|
||||
const base64Data = imageBase64.replace(/^data:image\/\w+;base64,/, '');
|
||||
const imageBuffer = Uint8Array.from(atob(base64Data), c => c.charCodeAt(0));
|
||||
|
||||
sections.push(
|
||||
new Paragraph({
|
||||
text: `${sectionNum++}. 可视化结果`,
|
||||
heading: HeadingLevel.HEADING_1,
|
||||
}),
|
||||
new Paragraph({
|
||||
children: [
|
||||
new ImageRun({
|
||||
data: imageBuffer,
|
||||
transformation: { width: 450, height: 300 },
|
||||
type: 'png',
|
||||
}),
|
||||
],
|
||||
alignment: AlignmentType.CENTER,
|
||||
}),
|
||||
new Paragraph({
|
||||
text: `图 1. ${valueVar} 在 ${groupVar} 分组下的分布`,
|
||||
alignment: AlignmentType.CENTER,
|
||||
}),
|
||||
new Paragraph({ text: '' }),
|
||||
);
|
||||
} catch (e) {
|
||||
console.warn('图片导出失败', e);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
sections.push(
|
||||
new Paragraph({
|
||||
text: `${sectionNum++}. 结论`,
|
||||
heading: HeadingLevel.HEADING_1,
|
||||
}),
|
||||
new Paragraph({
|
||||
text: result.interpretation ||
|
||||
(pValue !== undefined && pValue < 0.05
|
||||
? `${groupVar} 分组间的 ${valueVar} 差异具有统计学意义 (P = ${pValueStr})。`
|
||||
: `${groupVar} 分组间的 ${valueVar} 差异无统计学意义 (P = ${pValueStr})。`),
|
||||
}),
|
||||
new Paragraph({ text: '' }),
|
||||
new Paragraph({ text: '' }),
|
||||
new Paragraph({
|
||||
children: [
|
||||
new TextRun({ text: '本报告由智能统计分析系统自动生成', italics: true, color: '666666' }),
|
||||
],
|
||||
}),
|
||||
new Paragraph({
|
||||
children: [
|
||||
new TextRun({ text: `执行耗时: ${result.executionMs}ms`, italics: true, color: '666666' }),
|
||||
],
|
||||
}),
|
||||
);
|
||||
|
||||
const doc = new Document({
|
||||
sections: [{
|
||||
children: sections,
|
||||
}],
|
||||
});
|
||||
|
||||
const dateTimeStr = `${now.getFullYear()}${String(now.getMonth() + 1).padStart(2, '0')}${String(now.getDate()).padStart(2, '0')}_${String(now.getHours()).padStart(2, '0')}${String(now.getMinutes()).padStart(2, '0')}`;
|
||||
const safeFileName = dataFileName.replace(/\.(csv|xlsx|xls)$/i, '').replace(/[^a-zA-Z0-9\u4e00-\u9fa5_-]/g, '_');
|
||||
|
||||
const blob = await Packer.toBlob(doc);
|
||||
const url = URL.createObjectURL(blob);
|
||||
const a = document.createElement('a');
|
||||
a.href = url;
|
||||
a.download = `统计分析报告_${safeFileName}_${dateTimeStr}.docx`;
|
||||
a.click();
|
||||
URL.revokeObjectURL(url);
|
||||
await exportWorkflowReport(steps, record.plan, conclusion, session, mountedFile);
|
||||
}, [setError]);
|
||||
|
||||
const exportWorkflowReport = async (
|
||||
steps: any[],
|
||||
wfPlan: any,
|
||||
conclusion: any,
|
||||
session: any,
|
||||
steps: any[],
|
||||
wfPlan: any,
|
||||
conclusion: any,
|
||||
session: any,
|
||||
mountedFile: any
|
||||
) => {
|
||||
const now = new Date();
|
||||
@@ -603,7 +318,7 @@ export function useAnalysis(): UseAnalysisReturn {
|
||||
let sectionNum = 1;
|
||||
|
||||
sections.push(
|
||||
new Paragraph({ text: '多步骤统计分析报告', heading: HeadingLevel.TITLE, alignment: AlignmentType.CENTER }),
|
||||
new Paragraph({ text: '统计分析报告', heading: HeadingLevel.TITLE, alignment: AlignmentType.CENTER }),
|
||||
new Paragraph({ text: '' }),
|
||||
new Paragraph({ children: [
|
||||
new TextRun({ text: '研究课题:', bold: true }),
|
||||
@@ -632,8 +347,7 @@ export function useAnalysis(): UseAnalysisReturn {
|
||||
);
|
||||
}
|
||||
|
||||
const successSteps = steps.filter(s => s.status === 'success' && s.result);
|
||||
for (const step of successSteps) {
|
||||
for (const step of steps) {
|
||||
const r = step.result as any;
|
||||
sections.push(
|
||||
new Paragraph({ text: `${sectionNum++}. 步骤 ${step.step_number}: ${step.tool_name}`, heading: HeadingLevel.HEADING_1 }),
|
||||
@@ -654,7 +368,6 @@ export function useAnalysis(): UseAnalysisReturn {
|
||||
new Paragraph({ text: '' }),
|
||||
);
|
||||
}
|
||||
|
||||
if (r?.variables && typeof r.variables === 'object') {
|
||||
const classifyExportVar = (v: any): 'numeric' | 'categorical' | 'unknown' => {
|
||||
if (!v) return 'unknown';
|
||||
@@ -669,11 +382,9 @@ export function useAnalysis(): UseAnalysisReturn {
|
||||
}
|
||||
return 'unknown';
|
||||
};
|
||||
|
||||
const varEntries = Object.entries(r.variables);
|
||||
const numericVars = varEntries.filter(([, v]) => classifyExportVar(v) === 'numeric');
|
||||
const catVars = varEntries.filter(([, v]) => classifyExportVar(v) === 'categorical');
|
||||
|
||||
if (numericVars.length > 0) {
|
||||
const numRows: TableRow[] = [createTableRow(['变量', 'N', '均值 ± 标准差', '中位数', 'Q1', 'Q3', '最小值', '最大值'], true)];
|
||||
for (const [varName, rawVs] of numericVars) {
|
||||
@@ -703,7 +414,6 @@ export function useAnalysis(): UseAnalysisReturn {
|
||||
new Paragraph({ text: '' }),
|
||||
);
|
||||
}
|
||||
|
||||
if (catVars.length > 0) {
|
||||
sections.push(new Paragraph({ text: '分类变量统计', heading: HeadingLevel.HEADING_2 }));
|
||||
for (const [varName, rawVs] of catVars) {
|
||||
@@ -747,13 +457,12 @@ export function useAnalysis(): UseAnalysisReturn {
|
||||
if (r?.statistic !== undefined) statsRows.push(createTableRow(['统计量', Number(r.statistic).toFixed(4)]));
|
||||
if (r?.p_value !== undefined) statsRows.push(createTableRow(['P 值', r.p_value_fmt || (r.p_value < 0.001 ? '< 0.001' : Number(r.p_value).toFixed(4))]));
|
||||
if (r?.effect_size !== undefined) {
|
||||
const esStr = typeof r.effect_size === 'object'
|
||||
const esStr = typeof r.effect_size === 'object'
|
||||
? Object.entries(r.effect_size).map(([k, v]) => `${k}=${Number(v).toFixed(3)}`).join(', ')
|
||||
: Number(r.effect_size).toFixed(3);
|
||||
statsRows.push(createTableRow(['效应量', esStr]));
|
||||
}
|
||||
if (r?.conf_int) statsRows.push(createTableRow(['95% CI', `[${r.conf_int.map((v: number) => v.toFixed(4)).join(', ')}]`]));
|
||||
|
||||
sections.push(new Table({ width: { size: 100, type: WidthType.PERCENTAGE }, borders: tableBorders, rows: statsRows }));
|
||||
sections.push(new Paragraph({ text: '' }));
|
||||
}
|
||||
@@ -812,7 +521,7 @@ export function useAnalysis(): UseAnalysisReturn {
|
||||
}),
|
||||
new Paragraph({ text: '' }),
|
||||
);
|
||||
} catch (e) { /* skip */ }
|
||||
} catch { /* skip */ }
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -825,11 +534,10 @@ export function useAnalysis(): UseAnalysisReturn {
|
||||
new Paragraph({ text: '' }),
|
||||
);
|
||||
}
|
||||
|
||||
if (conclusion?.recommendations?.length > 0) {
|
||||
sections.push(
|
||||
new Paragraph({ text: `${sectionNum++}. 建议`, heading: HeadingLevel.HEADING_1 }),
|
||||
...conclusion.recommendations.map((r: string) => new Paragraph({ text: `• ${r}` })),
|
||||
...conclusion.recommendations.map((rec: string) => new Paragraph({ text: `• ${rec}` })),
|
||||
new Paragraph({ text: '' }),
|
||||
);
|
||||
}
|
||||
@@ -840,7 +548,7 @@ export function useAnalysis(): UseAnalysisReturn {
|
||||
new TextRun({ text: '本报告由智能统计分析系统自动生成', italics: true, color: '666666' }),
|
||||
]}),
|
||||
new Paragraph({ children: [
|
||||
new TextRun({ text: `总执行耗时: ${steps.reduce((s, st) => s + (st.duration_ms || 0), 0)}ms`, italics: true, color: '666666' }),
|
||||
new TextRun({ text: `总执行耗时: ${steps.reduce((s: number, st: any) => s + (st.duration_ms || 0), 0)}ms`, italics: true, color: '666666' }),
|
||||
]}),
|
||||
);
|
||||
|
||||
@@ -852,7 +560,7 @@ export function useAnalysis(): UseAnalysisReturn {
|
||||
const url = URL.createObjectURL(blob);
|
||||
const a = document.createElement('a');
|
||||
a.href = url;
|
||||
a.download = `多步骤分析报告_${safeFileName}_${dateTimeStr}.docx`;
|
||||
a.download = `统计分析报告_${safeFileName}_${dateTimeStr}.docx`;
|
||||
a.click();
|
||||
URL.revokeObjectURL(url);
|
||||
};
|
||||
|
||||
@@ -1,18 +1,19 @@
|
||||
/**
|
||||
* 多步骤工作流 Hook
|
||||
*
|
||||
* Phase 2A: 处理数据画像、工作流规划、SSE 执行等
|
||||
* 多步骤工作流 Hook (Unified Record Architecture)
|
||||
*
|
||||
* 所有分析状态通过 addRecord / updateRecord 写入 AnalysisRecord,
|
||||
* 不再操作全局单例字段。
|
||||
*/
|
||||
import { useCallback, useRef } from 'react';
|
||||
import apiClient from '@/common/api/axios';
|
||||
import { getAccessToken } from '@/framework/auth/api';
|
||||
import { useSSAStore } from '../stores/ssaStore';
|
||||
import type {
|
||||
DataProfile,
|
||||
WorkflowPlan,
|
||||
import type { AnalysisRecord } from '../stores/ssaStore';
|
||||
import type {
|
||||
DataProfile,
|
||||
WorkflowPlan,
|
||||
WorkflowStepResult,
|
||||
SSEMessage,
|
||||
SSAMessage,
|
||||
IntentResult,
|
||||
ClarificationCardData,
|
||||
} from '../types';
|
||||
@@ -44,14 +45,8 @@ export function useWorkflow(): UseWorkflowReturn {
|
||||
setDataProfile,
|
||||
setDataProfileLoading,
|
||||
dataProfileLoading,
|
||||
setWorkflowPlan,
|
||||
setWorkflowPlanLoading,
|
||||
workflowPlanLoading,
|
||||
setWorkflowSteps,
|
||||
updateWorkflowStep,
|
||||
setWorkflowProgress,
|
||||
setConclusionReport,
|
||||
setIsWorkflowMode,
|
||||
setActivePane,
|
||||
setWorkspaceOpen,
|
||||
addMessage,
|
||||
@@ -59,30 +54,30 @@ export function useWorkflow(): UseWorkflowReturn {
|
||||
isExecuting,
|
||||
setError,
|
||||
addToast,
|
||||
addRecord,
|
||||
updateRecord,
|
||||
} = useSSAStore();
|
||||
|
||||
const abortControllerRef = useRef<AbortController | null>(null);
|
||||
const eventSourceRef = useRef<EventSource | null>(null);
|
||||
const currentRecordIdRef = useRef<string | null>(null);
|
||||
|
||||
// ========== Data Profile ==========
|
||||
|
||||
const generateDataProfile = useCallback(async (sessionId: string): Promise<DataProfile> => {
|
||||
setDataProfileLoading(true);
|
||||
setError(null);
|
||||
|
||||
try {
|
||||
const response = await apiClient.post(`${API_BASE}/workflow/profile`, { sessionId });
|
||||
const profile: DataProfile = response.data.profile;
|
||||
|
||||
setDataProfile(profile);
|
||||
|
||||
const profileMessage: SSAMessage = {
|
||||
addMessage({
|
||||
id: crypto.randomUUID(),
|
||||
role: 'assistant',
|
||||
content: `数据质量核查完成:${profile.quality_grade}级 (${profile.quality_score}分)`,
|
||||
artifactType: 'sap',
|
||||
artifactType: 'profile',
|
||||
createdAt: new Date().toISOString(),
|
||||
};
|
||||
addMessage(profileMessage);
|
||||
|
||||
});
|
||||
return profile;
|
||||
} catch (error: any) {
|
||||
const errorMsg = error.response?.data?.message || error.message || '数据画像生成失败';
|
||||
@@ -94,51 +89,43 @@ export function useWorkflow(): UseWorkflowReturn {
|
||||
}
|
||||
}, [setDataProfile, setDataProfileLoading, setError, addMessage, addToast]);
|
||||
|
||||
// ========== Workflow Plan ==========
|
||||
|
||||
const generateWorkflowPlan = useCallback(async (
|
||||
sessionId: string,
|
||||
sessionId: string,
|
||||
query: string
|
||||
): Promise<WorkflowPlan> => {
|
||||
setWorkflowPlanLoading(true);
|
||||
setError(null);
|
||||
setIsWorkflowMode(true);
|
||||
|
||||
try {
|
||||
const userMessage: SSAMessage = {
|
||||
id: crypto.randomUUID(),
|
||||
role: 'user',
|
||||
content: query,
|
||||
createdAt: new Date().toISOString(),
|
||||
};
|
||||
addMessage(userMessage);
|
||||
|
||||
const response = await apiClient.post(`${API_BASE}/workflow/plan`, {
|
||||
sessionId,
|
||||
userQuery: query
|
||||
const response = await apiClient.post(`${API_BASE}/workflow/plan`, {
|
||||
sessionId,
|
||||
userQuery: query,
|
||||
});
|
||||
const plan: WorkflowPlan = response.data.plan;
|
||||
|
||||
setWorkflowPlan(plan);
|
||||
|
||||
const recordId = addRecord(query, plan);
|
||||
currentRecordIdRef.current = recordId;
|
||||
|
||||
setActivePane('sap');
|
||||
setWorkspaceOpen(true);
|
||||
|
||||
const planMessage: SSAMessage = {
|
||||
addMessage({
|
||||
id: crypto.randomUUID(),
|
||||
role: 'assistant',
|
||||
content: `已生成分析方案:${plan.title}\n共 ${plan.total_steps} 个分析步骤`,
|
||||
artifactType: 'sap',
|
||||
recordId,
|
||||
createdAt: new Date().toISOString(),
|
||||
};
|
||||
addMessage(planMessage);
|
||||
|
||||
const confirmMessage: SSAMessage = {
|
||||
});
|
||||
addMessage({
|
||||
id: crypto.randomUUID(),
|
||||
role: 'assistant',
|
||||
content: '请确认分析计划并开始执行。',
|
||||
artifactType: 'confirm',
|
||||
createdAt: new Date().toISOString(),
|
||||
};
|
||||
addMessage(confirmMessage);
|
||||
|
||||
});
|
||||
return plan;
|
||||
} catch (error: any) {
|
||||
const errorMsg = error.response?.data?.message || error.message || '工作流规划失败';
|
||||
@@ -148,27 +135,16 @@ export function useWorkflow(): UseWorkflowReturn {
|
||||
} finally {
|
||||
setWorkflowPlanLoading(false);
|
||||
}
|
||||
}, [
|
||||
setWorkflowPlan,
|
||||
setWorkflowPlanLoading,
|
||||
setIsWorkflowMode,
|
||||
setActivePane,
|
||||
setWorkspaceOpen,
|
||||
addMessage,
|
||||
setError,
|
||||
addToast
|
||||
]);
|
||||
}, [setWorkflowPlanLoading, setActivePane, setWorkspaceOpen, addMessage, setError, addToast, addRecord]);
|
||||
|
||||
// ========== Intent Parsing ==========
|
||||
|
||||
/**
|
||||
* Phase Q: 解析用户意图(不直接生成计划)
|
||||
*/
|
||||
const parseIntent = useCallback(async (
|
||||
sessionId: string,
|
||||
query: string
|
||||
): Promise<IntentResponse> => {
|
||||
setWorkflowPlanLoading(true);
|
||||
setError(null);
|
||||
|
||||
try {
|
||||
const response = await apiClient.post(`${API_BASE}/workflow/intent`, {
|
||||
sessionId,
|
||||
@@ -184,9 +160,8 @@ export function useWorkflow(): UseWorkflowReturn {
|
||||
}
|
||||
}, [setWorkflowPlanLoading, setError]);
|
||||
|
||||
/**
|
||||
* Phase Q: 处理用户追问回答
|
||||
*/
|
||||
// ========== Clarification ==========
|
||||
|
||||
const handleClarify = useCallback(async (
|
||||
sessionId: string,
|
||||
userQuery: string,
|
||||
@@ -194,31 +169,28 @@ export function useWorkflow(): UseWorkflowReturn {
|
||||
): Promise<IntentResponse> => {
|
||||
setWorkflowPlanLoading(true);
|
||||
setError(null);
|
||||
|
||||
try {
|
||||
const response = await apiClient.post(`${API_BASE}/workflow/clarify`, {
|
||||
sessionId,
|
||||
userQuery,
|
||||
selections,
|
||||
});
|
||||
|
||||
const data = response.data as IntentResponse;
|
||||
|
||||
if (data.plan) {
|
||||
setWorkflowPlan(data.plan);
|
||||
const recordId = addRecord(userQuery, data.plan);
|
||||
currentRecordIdRef.current = recordId;
|
||||
setActivePane('sap');
|
||||
setWorkspaceOpen(true);
|
||||
setIsWorkflowMode(true);
|
||||
|
||||
addMessage({
|
||||
id: crypto.randomUUID(),
|
||||
role: 'assistant',
|
||||
content: `已生成分析方案:${data.plan.title}\n共 ${data.plan.total_steps} 个分析步骤`,
|
||||
artifactType: 'sap',
|
||||
recordId,
|
||||
createdAt: new Date().toISOString(),
|
||||
});
|
||||
}
|
||||
|
||||
return data;
|
||||
} catch (error: any) {
|
||||
const errorMsg = error.response?.data?.message || error.message || '处理追问失败';
|
||||
@@ -227,70 +199,64 @@ export function useWorkflow(): UseWorkflowReturn {
|
||||
} finally {
|
||||
setWorkflowPlanLoading(false);
|
||||
}
|
||||
}, [setWorkflowPlanLoading, setError, setWorkflowPlan, setActivePane, setWorkspaceOpen, setIsWorkflowMode, addMessage]);
|
||||
}, [setWorkflowPlanLoading, setError, addRecord, setActivePane, setWorkspaceOpen, addMessage]);
|
||||
|
||||
// ========== Workflow Execution (SSE) ==========
|
||||
|
||||
const executeWorkflow = useCallback(async (
|
||||
_sessionId: string,
|
||||
_sessionId: string,
|
||||
workflowId: string
|
||||
): Promise<void> => {
|
||||
const rid = currentRecordIdRef.current || useSSAStore.getState().currentRecordId;
|
||||
if (!rid) throw new Error('No active record');
|
||||
|
||||
setExecuting(true);
|
||||
setActivePane('execution');
|
||||
setWorkflowSteps([]);
|
||||
setWorkflowProgress(0);
|
||||
setConclusionReport(null);
|
||||
updateRecord(rid, { status: 'executing', steps: [], progress: 0, conclusionReport: null });
|
||||
setError(null);
|
||||
|
||||
const token = getAccessToken();
|
||||
|
||||
|
||||
return new Promise((resolve, reject) => {
|
||||
const streamUrl = `${API_BASE}/workflow/${workflowId}/stream`;
|
||||
|
||||
abortControllerRef.current = new AbortController();
|
||||
|
||||
|
||||
fetch(streamUrl, {
|
||||
method: 'GET',
|
||||
headers: {
|
||||
'Authorization': `Bearer ${token}`,
|
||||
'Accept': 'text/event-stream',
|
||||
Authorization: `Bearer ${token}`,
|
||||
Accept: 'text/event-stream',
|
||||
},
|
||||
signal: abortControllerRef.current.signal,
|
||||
}).then(async (response) => {
|
||||
if (!response.ok) {
|
||||
throw new Error(`HTTP error! status: ${response.status}`);
|
||||
}
|
||||
})
|
||||
.then(async (response) => {
|
||||
if (!response.ok) throw new Error(`HTTP error! status: ${response.status}`);
|
||||
const reader = response.body?.getReader();
|
||||
if (!reader) throw new Error('No response body');
|
||||
|
||||
const reader = response.body?.getReader();
|
||||
if (!reader) {
|
||||
throw new Error('No response body');
|
||||
}
|
||||
const decoder = new TextDecoder();
|
||||
let buffer = '';
|
||||
|
||||
const decoder = new TextDecoder();
|
||||
let buffer = '';
|
||||
const getSteps = (): WorkflowStepResult[] => {
|
||||
const rec = useSSAStore.getState().analysisHistory.find((r) => r.id === rid);
|
||||
return rec?.steps ?? [];
|
||||
};
|
||||
|
||||
const processLine = (line: string) => {
|
||||
if (line.startsWith('data:')) {
|
||||
const jsonStr = line.slice(5).trim();
|
||||
if (jsonStr) {
|
||||
try {
|
||||
const message: SSEMessage = JSON.parse(jsonStr);
|
||||
handleSSEMessage(message);
|
||||
} catch (e) {
|
||||
console.warn('Failed to parse SSE message:', jsonStr);
|
||||
}
|
||||
}
|
||||
}
|
||||
};
|
||||
const patchSteps = (steps: WorkflowStepResult[], extra?: Partial<AnalysisRecord>) => {
|
||||
updateRecord(rid, { steps, ...extra });
|
||||
};
|
||||
|
||||
const handleSSEMessage = (message: SSEMessage) => {
|
||||
// 兼容后端的驼峰命名和顶层字段
|
||||
const toolCode = message.toolCode || message.data?.tool_code || '';
|
||||
const toolName = message.toolName || message.data?.tool_name || '';
|
||||
const stepNumber = message.step;
|
||||
|
||||
switch (message.type) {
|
||||
case 'step_start':
|
||||
if (stepNumber !== undefined) {
|
||||
const stepResult: WorkflowStepResult = {
|
||||
const handleSSEMessage = (message: SSEMessage) => {
|
||||
const toolCode = message.toolCode || message.data?.tool_code || '';
|
||||
const toolName = message.toolName || message.data?.tool_name || '';
|
||||
const stepNumber = message.step;
|
||||
|
||||
switch (message.type) {
|
||||
case 'step_start': {
|
||||
if (stepNumber === undefined) break;
|
||||
const cur = getSteps();
|
||||
if (cur.some((s) => s.step_number === stepNumber)) break;
|
||||
const newStep: WorkflowStepResult = {
|
||||
step_number: stepNumber,
|
||||
tool_code: toolCode,
|
||||
tool_name: toolName,
|
||||
@@ -298,143 +264,197 @@ export function useWorkflow(): UseWorkflowReturn {
|
||||
started_at: new Date().toISOString(),
|
||||
logs: message.message ? [message.message] : [],
|
||||
};
|
||||
const currentSteps = useSSAStore.getState().workflowSteps;
|
||||
// 避免重复添加
|
||||
if (!currentSteps.some(s => s.step_number === stepNumber)) {
|
||||
setWorkflowSteps([...currentSteps, stepResult]);
|
||||
}
|
||||
patchSteps([...cur, newStep]);
|
||||
break;
|
||||
}
|
||||
break;
|
||||
|
||||
case 'step_progress':
|
||||
if (stepNumber !== undefined && message.message) {
|
||||
updateWorkflowStep(stepNumber, {
|
||||
logs: (useSSAStore.getState().workflowSteps
|
||||
.find(s => s.step_number === stepNumber)?.logs || [])
|
||||
.concat(message.message),
|
||||
});
|
||||
case 'step_progress': {
|
||||
if (stepNumber === undefined || !message.message) break;
|
||||
const cur = getSteps();
|
||||
patchSteps(
|
||||
cur.map((s) =>
|
||||
s.step_number === stepNumber
|
||||
? { ...s, logs: [...(s.logs || []), message.message!] }
|
||||
: s
|
||||
)
|
||||
);
|
||||
break;
|
||||
}
|
||||
break;
|
||||
|
||||
case 'step_complete':
|
||||
if (stepNumber !== undefined) {
|
||||
case 'step_complete': {
|
||||
if (stepNumber === undefined) break;
|
||||
const result = message.result || message.data?.result;
|
||||
const durationMs = message.duration_ms || message.durationMs || message.data?.duration_ms;
|
||||
const reportBlocks = message.reportBlocks
|
||||
|| (result as any)?.report_blocks
|
||||
|| message.data?.reportBlocks;
|
||||
|
||||
updateWorkflowStep(stepNumber, {
|
||||
status: message.status || message.data?.status || 'success',
|
||||
completed_at: new Date().toISOString(),
|
||||
duration_ms: durationMs,
|
||||
result: result,
|
||||
reportBlocks: reportBlocks || undefined,
|
||||
});
|
||||
|
||||
const reportBlocks = message.reportBlocks || (result as any)?.report_blocks || message.data?.reportBlocks;
|
||||
const totalSteps = message.total_steps || message.totalSteps || 2;
|
||||
const progress = (stepNumber / totalSteps) * 100;
|
||||
setWorkflowProgress(progress);
|
||||
}
|
||||
break;
|
||||
|
||||
case 'step_error':
|
||||
if (stepNumber !== undefined) {
|
||||
updateWorkflowStep(stepNumber, {
|
||||
status: 'failed',
|
||||
completed_at: new Date().toISOString(),
|
||||
error: message.error || message.message,
|
||||
const cur = getSteps();
|
||||
patchSteps(
|
||||
cur.map((s) =>
|
||||
s.step_number === stepNumber
|
||||
? {
|
||||
...s,
|
||||
status: message.status || message.data?.status || 'success',
|
||||
completed_at: new Date().toISOString(),
|
||||
duration_ms: durationMs,
|
||||
result,
|
||||
reportBlocks: reportBlocks || undefined,
|
||||
}
|
||||
: s
|
||||
),
|
||||
{ progress }
|
||||
);
|
||||
break;
|
||||
}
|
||||
|
||||
case 'step_error': {
|
||||
if (stepNumber === undefined) break;
|
||||
const rawErr = message.error || message.message;
|
||||
const errStr =
|
||||
typeof rawErr === 'object'
|
||||
? (rawErr as any)?.userHint || (rawErr as any)?.message || JSON.stringify(rawErr)
|
||||
: String(rawErr || '执行失败');
|
||||
const cur = getSteps();
|
||||
patchSteps(
|
||||
cur.map((s) =>
|
||||
s.step_number === stepNumber
|
||||
? { ...s, status: 'failed', completed_at: new Date().toISOString(), error: errStr }
|
||||
: s
|
||||
)
|
||||
);
|
||||
break;
|
||||
}
|
||||
|
||||
case 'workflow_complete': {
|
||||
setExecuting(false);
|
||||
const rawStatus = (message as any).status ?? (message as any).data?.status;
|
||||
const isError = rawStatus === 'error' || rawStatus === 'failed';
|
||||
const finalSteps = getSteps();
|
||||
const hasAnySuccess = finalSteps.some(
|
||||
(s) => s.status === 'success' || s.status === 'warning'
|
||||
);
|
||||
|
||||
if (isError && !hasAnySuccess) {
|
||||
updateRecord(rid, { status: 'error', progress: 0 });
|
||||
const firstErr = finalSteps.find((s) => s.status === 'failed')?.error;
|
||||
let errText = '执行过程中发生错误';
|
||||
if (firstErr) {
|
||||
errText =
|
||||
typeof firstErr === 'object'
|
||||
? String((firstErr as any)?.userHint || (firstErr as any)?.message || errText)
|
||||
: String(firstErr);
|
||||
}
|
||||
addMessage({
|
||||
id: crypto.randomUUID(),
|
||||
role: 'assistant',
|
||||
content: `分析执行失败:${errText}`,
|
||||
artifactType: 'execution',
|
||||
recordId: rid,
|
||||
createdAt: new Date().toISOString(),
|
||||
});
|
||||
addToast('工作流执行失败', 'error');
|
||||
resolve();
|
||||
break;
|
||||
}
|
||||
|
||||
const conclusion = message.conclusion || null;
|
||||
updateRecord(rid, {
|
||||
status: 'completed',
|
||||
progress: 100,
|
||||
conclusionReport: conclusion,
|
||||
});
|
||||
|
||||
if (conclusion) {
|
||||
setActivePane('result');
|
||||
addMessage({
|
||||
id: crypto.randomUUID(),
|
||||
role: 'assistant',
|
||||
content: `分析完成!${conclusion.executive_summary?.slice(0, 100) || '查看右侧结果面板获取详细信息'}`,
|
||||
artifactType: 'result',
|
||||
recordId: rid,
|
||||
createdAt: new Date().toISOString(),
|
||||
});
|
||||
} else if (hasAnySuccess) {
|
||||
addMessage({
|
||||
id: crypto.randomUUID(),
|
||||
role: 'assistant',
|
||||
content: '分析执行完成!',
|
||||
artifactType: 'result',
|
||||
recordId: rid,
|
||||
createdAt: new Date().toISOString(),
|
||||
});
|
||||
} else {
|
||||
addMessage({
|
||||
id: crypto.randomUUID(),
|
||||
role: 'assistant',
|
||||
content: '分析执行完成,但未生成结论报告。',
|
||||
createdAt: new Date().toISOString(),
|
||||
});
|
||||
}
|
||||
addToast('工作流执行完成', 'success');
|
||||
resolve();
|
||||
break;
|
||||
}
|
||||
break;
|
||||
|
||||
case 'workflow_complete':
|
||||
setWorkflowProgress(100);
|
||||
setExecuting(false);
|
||||
|
||||
if (message.conclusion) {
|
||||
setConclusionReport(message.conclusion);
|
||||
setActivePane('result');
|
||||
|
||||
const completeMessage: SSAMessage = {
|
||||
id: crypto.randomUUID(),
|
||||
role: 'assistant',
|
||||
content: `分析完成!${message.conclusion.executive_summary?.slice(0, 100) || '查看右侧结果面板获取详细信息'}`,
|
||||
artifactType: 'result',
|
||||
createdAt: new Date().toISOString(),
|
||||
};
|
||||
addMessage(completeMessage);
|
||||
} else {
|
||||
// 即使没有 conclusion,也标记为完成
|
||||
const completeMessage: SSAMessage = {
|
||||
id: crypto.randomUUID(),
|
||||
role: 'assistant',
|
||||
content: '分析执行完成!',
|
||||
artifactType: 'result',
|
||||
createdAt: new Date().toISOString(),
|
||||
};
|
||||
addMessage(completeMessage);
|
||||
case 'workflow_error': {
|
||||
const rawWfErr = message.error || '工作流执行失败';
|
||||
const wfErrStr =
|
||||
typeof rawWfErr === 'object'
|
||||
? (rawWfErr as any)?.userHint || (rawWfErr as any)?.message || JSON.stringify(rawWfErr)
|
||||
: String(rawWfErr);
|
||||
updateRecord(rid, { status: 'error' });
|
||||
setError(wfErrStr);
|
||||
addToast(wfErrStr, 'error');
|
||||
setExecuting(false);
|
||||
reject(new Error(wfErrStr));
|
||||
break;
|
||||
}
|
||||
|
||||
addToast('工作流执行完成', 'success');
|
||||
resolve();
|
||||
break;
|
||||
|
||||
case 'workflow_error':
|
||||
const errorMsg = message.error || '工作流执行失败';
|
||||
setError(errorMsg);
|
||||
addToast(errorMsg, 'error');
|
||||
setExecuting(false);
|
||||
reject(new Error(errorMsg));
|
||||
break;
|
||||
|
||||
case 'connected':
|
||||
// 连接确认消息,忽略
|
||||
break;
|
||||
case 'connected':
|
||||
break;
|
||||
}
|
||||
};
|
||||
|
||||
const processLine = (line: string) => {
|
||||
if (line.startsWith('data:')) {
|
||||
const jsonStr = line.slice(5).trim();
|
||||
if (jsonStr) {
|
||||
try {
|
||||
handleSSEMessage(JSON.parse(jsonStr));
|
||||
} catch (e) {
|
||||
console.warn('Failed to parse SSE message:', jsonStr);
|
||||
}
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
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) processLine(line);
|
||||
}
|
||||
};
|
||||
|
||||
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) {
|
||||
processLine(line);
|
||||
if (buffer) processLine(buffer);
|
||||
})
|
||||
.catch((error) => {
|
||||
if (error.name === 'AbortError') {
|
||||
addToast('工作流已取消', 'info');
|
||||
resolve();
|
||||
} else {
|
||||
const errorMsg = error.message || '工作流执行失败';
|
||||
setError(errorMsg);
|
||||
addToast(errorMsg, 'error');
|
||||
reject(error);
|
||||
}
|
||||
}
|
||||
|
||||
if (buffer) {
|
||||
processLine(buffer);
|
||||
}
|
||||
|
||||
}).catch((error) => {
|
||||
if (error.name === 'AbortError') {
|
||||
addToast('工作流已取消', 'info');
|
||||
resolve();
|
||||
} else {
|
||||
const errorMsg = error.message || '工作流执行失败';
|
||||
setError(errorMsg);
|
||||
addToast(errorMsg, 'error');
|
||||
reject(error);
|
||||
}
|
||||
setExecuting(false);
|
||||
});
|
||||
setExecuting(false);
|
||||
});
|
||||
});
|
||||
}, [
|
||||
setExecuting,
|
||||
setActivePane,
|
||||
setWorkflowSteps,
|
||||
setWorkflowProgress,
|
||||
setConclusionReport,
|
||||
updateWorkflowStep,
|
||||
addMessage,
|
||||
setError,
|
||||
addToast
|
||||
]);
|
||||
}, [setExecuting, setActivePane, addMessage, setError, addToast, updateRecord]);
|
||||
|
||||
// ========== Cancel ==========
|
||||
|
||||
const cancelWorkflow = useCallback(() => {
|
||||
if (abortControllerRef.current) {
|
||||
|
||||
@@ -1,17 +1,15 @@
|
||||
/**
|
||||
* SSA 状态管理 - Zustand Store
|
||||
*
|
||||
* V11 版本 - 完全还原原型图设计
|
||||
* 支持多任务模式:同一会话中可进行多次分析
|
||||
* SSA 状态管理 - Zustand Store (Unified Record Architecture)
|
||||
*
|
||||
* 核心思想: 一次分析 = 一个 Record = N 个 Steps
|
||||
* 所有分析状态统一存储在 AnalysisRecord 中,
|
||||
* 通过 currentRecordId 派生当前记录,消除全局单例。
|
||||
*/
|
||||
import { create } from 'zustand';
|
||||
import type {
|
||||
SSAMode,
|
||||
SSASession,
|
||||
SSAMessage,
|
||||
AnalysisPlan,
|
||||
ExecutionResult,
|
||||
TraceStep,
|
||||
DataProfile,
|
||||
WorkflowPlan,
|
||||
WorkflowStepResult,
|
||||
@@ -32,23 +30,25 @@ interface Toast {
|
||||
type: 'info' | 'success' | 'error';
|
||||
}
|
||||
|
||||
/** 分析记录 - 支持多任务 */
|
||||
export type RecordStatus = 'planning' | 'executing' | 'completed' | 'error';
|
||||
|
||||
/** 统一分析记录 — 无论单步 / 多步,全部走此模型 */
|
||||
export interface AnalysisRecord {
|
||||
id: string;
|
||||
query: string;
|
||||
createdAt: string;
|
||||
plan: AnalysisPlan;
|
||||
executionResult: ExecutionResult | null;
|
||||
traceSteps: TraceStep[];
|
||||
status: RecordStatus;
|
||||
|
||||
plan: WorkflowPlan | null;
|
||||
steps: WorkflowStepResult[];
|
||||
progress: number;
|
||||
conclusionReport: ConclusionReport | null;
|
||||
}
|
||||
|
||||
interface SSAState {
|
||||
mode: SSAMode;
|
||||
currentSession: SSASession | null;
|
||||
messages: SSAMessage[];
|
||||
currentPlan: AnalysisPlan | null;
|
||||
executionResult: ExecutionResult | null;
|
||||
traceSteps: TraceStep[];
|
||||
isLoading: boolean;
|
||||
isExecuting: boolean;
|
||||
error: string | null;
|
||||
@@ -56,34 +56,24 @@ interface SSAState {
|
||||
activePane: ArtifactPane;
|
||||
mountedFile: MountedFile | null;
|
||||
codeModalVisible: boolean;
|
||||
|
||||
|
||||
sidebarExpanded: boolean;
|
||||
workspaceOpen: boolean;
|
||||
toasts: Toast[];
|
||||
|
||||
// 多任务支持
|
||||
analysisHistory: AnalysisRecord[];
|
||||
currentRecordId: string | null;
|
||||
|
||||
// Phase 2A: 多步骤工作流状态
|
||||
dataProfile: DataProfile | null;
|
||||
dataProfileLoading: boolean;
|
||||
dataProfileModalVisible: boolean;
|
||||
workflowPlan: WorkflowPlan | null;
|
||||
workflowPlanLoading: boolean;
|
||||
workflowSteps: WorkflowStepResult[];
|
||||
workflowProgress: number; // 0-100
|
||||
conclusionReport: ConclusionReport | null;
|
||||
isWorkflowMode: boolean; // 是否使用多步骤工作流模式
|
||||
|
||||
// ---- actions ----
|
||||
setMode: (mode: SSAMode) => void;
|
||||
setCurrentSession: (session: SSASession | null) => void;
|
||||
addMessage: (message: SSAMessage) => void;
|
||||
setMessages: (messages: SSAMessage[]) => void;
|
||||
setCurrentPlan: (plan: AnalysisPlan | null) => void;
|
||||
setExecutionResult: (result: ExecutionResult | null) => void;
|
||||
setTraceSteps: (steps: TraceStep[]) => void;
|
||||
updateTraceStep: (index: number, step: Partial<TraceStep>) => void;
|
||||
setLoading: (loading: boolean) => void;
|
||||
setExecuting: (executing: boolean) => void;
|
||||
setError: (error: string | null) => void;
|
||||
@@ -92,40 +82,29 @@ interface SSAState {
|
||||
setActivePane: (pane: ArtifactPane) => void;
|
||||
setMountedFile: (file: MountedFile | null) => void;
|
||||
setCodeModalVisible: (visible: boolean) => void;
|
||||
|
||||
|
||||
setSidebarExpanded: (expanded: boolean) => void;
|
||||
setWorkspaceOpen: (open: boolean) => void;
|
||||
addToast: (message: string, type?: 'info' | 'success' | 'error') => void;
|
||||
removeToast: (id: string) => void;
|
||||
hydrateFromHistory: (session: SSASession) => void;
|
||||
|
||||
// 多任务操作
|
||||
addAnalysisRecord: (query: string, plan: AnalysisPlan) => string;
|
||||
updateAnalysisRecord: (id: string, update: Partial<Omit<AnalysisRecord, 'id'>>) => void;
|
||||
selectAnalysisRecord: (id: string) => void;
|
||||
getCurrentRecord: () => AnalysisRecord | null;
|
||||
// Record operations (unified)
|
||||
addRecord: (query: string, plan: WorkflowPlan) => string;
|
||||
updateRecord: (id: string, patch: Partial<Omit<AnalysisRecord, 'id'>>) => void;
|
||||
selectRecord: (id: string) => void;
|
||||
|
||||
// Phase 2A: 多步骤工作流操作
|
||||
// Data profile
|
||||
setDataProfile: (profile: DataProfile | null) => void;
|
||||
setDataProfileLoading: (loading: boolean) => void;
|
||||
setDataProfileModalVisible: (visible: boolean) => void;
|
||||
setWorkflowPlan: (plan: WorkflowPlan | null) => void;
|
||||
setWorkflowPlanLoading: (loading: boolean) => void;
|
||||
setWorkflowSteps: (steps: WorkflowStepResult[]) => void;
|
||||
updateWorkflowStep: (stepNumber: number, update: Partial<WorkflowStepResult>) => void;
|
||||
setWorkflowProgress: (progress: number) => void;
|
||||
setConclusionReport: (report: ConclusionReport | null) => void;
|
||||
setIsWorkflowMode: (isWorkflow: boolean) => void;
|
||||
resetWorkflow: () => void;
|
||||
}
|
||||
|
||||
const initialState = {
|
||||
mode: 'analysis' as SSAMode,
|
||||
currentSession: null,
|
||||
messages: [],
|
||||
currentPlan: null,
|
||||
executionResult: null,
|
||||
traceSteps: [],
|
||||
isLoading: false,
|
||||
isExecuting: false,
|
||||
error: null,
|
||||
@@ -137,59 +116,29 @@ const initialState = {
|
||||
toasts: [] as Toast[],
|
||||
analysisHistory: [] as AnalysisRecord[],
|
||||
currentRecordId: null as string | null,
|
||||
// Phase 2A: 多步骤工作流初始状态
|
||||
dataProfile: null as DataProfile | null,
|
||||
dataProfileLoading: false,
|
||||
dataProfileModalVisible: false,
|
||||
workflowPlan: null as WorkflowPlan | null,
|
||||
workflowPlanLoading: false,
|
||||
workflowSteps: [] as WorkflowStepResult[],
|
||||
workflowProgress: 0,
|
||||
conclusionReport: null as ConclusionReport | null,
|
||||
isWorkflowMode: false,
|
||||
};
|
||||
|
||||
export const useSSAStore = create<SSAState>((set) => ({
|
||||
...initialState,
|
||||
|
||||
setMode: (mode) => set({ mode }),
|
||||
|
||||
setCurrentSession: (session) => set({ currentSession: session }),
|
||||
|
||||
addMessage: (message) =>
|
||||
set((state) => ({ messages: [...state.messages, message] })),
|
||||
|
||||
setMessages: (messages) => set({ messages }),
|
||||
|
||||
setCurrentPlan: (plan) => set({ currentPlan: plan }),
|
||||
|
||||
setExecutionResult: (result) => set({ executionResult: result }),
|
||||
|
||||
setTraceSteps: (steps) => set({ traceSteps: steps }),
|
||||
|
||||
updateTraceStep: (index, step) =>
|
||||
set((state) => ({
|
||||
traceSteps: state.traceSteps.map((s, i) =>
|
||||
i === index ? { ...s, ...step } : s
|
||||
),
|
||||
})),
|
||||
|
||||
setLoading: (loading) => set({ isLoading: loading }),
|
||||
|
||||
setExecuting: (executing) => set({ isExecuting: executing }),
|
||||
|
||||
setError: (error) => set({ error }),
|
||||
|
||||
reset: () => set(initialState),
|
||||
|
||||
setActivePane: (pane) => set({ activePane: pane }),
|
||||
|
||||
setMountedFile: (file) => set({ mountedFile: file }),
|
||||
|
||||
setCodeModalVisible: (visible) => set({ codeModalVisible: visible }),
|
||||
|
||||
setSidebarExpanded: (expanded) => set({ sidebarExpanded: expanded }),
|
||||
|
||||
setWorkspaceOpen: (open) => set({ workspaceOpen: open }),
|
||||
|
||||
addToast: (message, type = 'info') =>
|
||||
@@ -206,142 +155,72 @@ export const useSSAStore = create<SSAState>((set) => ({
|
||||
})),
|
||||
|
||||
hydrateFromHistory: (session) => {
|
||||
if (session.executionResult) {
|
||||
set({
|
||||
activePane: 'result',
|
||||
executionResult: session.executionResult,
|
||||
currentSession: session,
|
||||
workspaceOpen: true,
|
||||
});
|
||||
} else if (session.currentPlan) {
|
||||
set({
|
||||
activePane: 'sap',
|
||||
currentPlan: session.currentPlan,
|
||||
currentSession: session,
|
||||
workspaceOpen: true,
|
||||
});
|
||||
} else {
|
||||
set({
|
||||
activePane: 'empty',
|
||||
currentSession: session,
|
||||
workspaceOpen: false,
|
||||
});
|
||||
}
|
||||
|
||||
set({
|
||||
currentSession: session,
|
||||
workspaceOpen: false,
|
||||
activePane: 'empty',
|
||||
});
|
||||
if (session.dataSchema) {
|
||||
set({
|
||||
mountedFile: {
|
||||
name: session.title || 'data.csv',
|
||||
size: 0,
|
||||
rowCount: session.dataSchema.rowCount,
|
||||
}
|
||||
},
|
||||
});
|
||||
}
|
||||
},
|
||||
|
||||
// 添加新的分析记录
|
||||
addAnalysisRecord: (query, plan) => {
|
||||
const recordId = plan.id || `record_${Date.now()}`;
|
||||
// ==================== Record operations ====================
|
||||
|
||||
addRecord: (query, plan) => {
|
||||
const recordId = plan.workflow_id || `record_${Date.now()}`;
|
||||
const newRecord: AnalysisRecord = {
|
||||
id: recordId,
|
||||
query,
|
||||
createdAt: new Date().toISOString(),
|
||||
status: 'planning',
|
||||
plan,
|
||||
executionResult: null,
|
||||
traceSteps: [],
|
||||
steps: [],
|
||||
progress: 0,
|
||||
conclusionReport: null,
|
||||
};
|
||||
|
||||
set((state) => ({
|
||||
analysisHistory: [...state.analysisHistory, newRecord],
|
||||
currentRecordId: recordId,
|
||||
currentPlan: plan,
|
||||
executionResult: null,
|
||||
traceSteps: [],
|
||||
}));
|
||||
|
||||
return recordId;
|
||||
},
|
||||
|
||||
// 更新分析记录(如执行结果)
|
||||
updateAnalysisRecord: (id, update) => {
|
||||
set((state) => {
|
||||
const updatedHistory = state.analysisHistory.map((record) =>
|
||||
record.id === id ? { ...record, ...update } : record
|
||||
);
|
||||
|
||||
// 如果更新的是当前记录,同步更新当前状态
|
||||
const isCurrentRecord = state.currentRecordId === id;
|
||||
return {
|
||||
analysisHistory: updatedHistory,
|
||||
...(isCurrentRecord && update.executionResult !== undefined
|
||||
? { executionResult: update.executionResult }
|
||||
: {}),
|
||||
...(isCurrentRecord && update.traceSteps !== undefined
|
||||
? { traceSteps: update.traceSteps }
|
||||
: {}),
|
||||
};
|
||||
});
|
||||
},
|
||||
|
||||
// 选择/切换到某个分析记录
|
||||
selectAnalysisRecord: (id) => {
|
||||
set((state) => {
|
||||
const record = state.analysisHistory.find((r) => r.id === id);
|
||||
if (!record) return state;
|
||||
|
||||
return {
|
||||
currentRecordId: id,
|
||||
currentPlan: record.plan,
|
||||
executionResult: record.executionResult,
|
||||
traceSteps: record.traceSteps,
|
||||
activePane: record.executionResult ? 'result' : 'sap',
|
||||
workspaceOpen: true,
|
||||
};
|
||||
});
|
||||
},
|
||||
|
||||
// 获取当前记录(使用 get 方法避免循环引用)
|
||||
getCurrentRecord: (): AnalysisRecord | null => {
|
||||
return null; // 此方法在组件中通过直接访问 state 实现
|
||||
},
|
||||
|
||||
// Phase 2A: 多步骤工作流操作
|
||||
setDataProfile: (profile) => set({ dataProfile: profile }),
|
||||
|
||||
setDataProfileLoading: (loading) => set({ dataProfileLoading: loading }),
|
||||
|
||||
setDataProfileModalVisible: (visible) => set({ dataProfileModalVisible: visible }),
|
||||
|
||||
setWorkflowPlan: (plan) => set({ workflowPlan: plan }),
|
||||
|
||||
setWorkflowPlanLoading: (loading) => set({ workflowPlanLoading: loading }),
|
||||
|
||||
setWorkflowSteps: (steps) => set({ workflowSteps: steps }),
|
||||
|
||||
updateWorkflowStep: (stepNumber, update) =>
|
||||
updateRecord: (id, patch) => {
|
||||
set((state) => ({
|
||||
workflowSteps: state.workflowSteps.map((s) =>
|
||||
s.step_number === stepNumber ? { ...s, ...update } : s
|
||||
analysisHistory: state.analysisHistory.map((r) =>
|
||||
r.id === id ? { ...r, ...patch } : r
|
||||
),
|
||||
})),
|
||||
}));
|
||||
},
|
||||
|
||||
setWorkflowProgress: (progress) => set({ workflowProgress: progress }),
|
||||
|
||||
setConclusionReport: (report) => set({ conclusionReport: report }),
|
||||
|
||||
setIsWorkflowMode: (isWorkflow) => set({ isWorkflowMode: isWorkflow }),
|
||||
|
||||
resetWorkflow: () =>
|
||||
selectRecord: (id) => {
|
||||
set({
|
||||
dataProfile: null,
|
||||
dataProfileLoading: false,
|
||||
workflowPlan: null,
|
||||
workflowPlanLoading: false,
|
||||
workflowSteps: [],
|
||||
workflowProgress: 0,
|
||||
conclusionReport: null,
|
||||
isWorkflowMode: false,
|
||||
}),
|
||||
currentRecordId: id,
|
||||
workspaceOpen: true,
|
||||
});
|
||||
},
|
||||
|
||||
// Data profile
|
||||
setDataProfile: (profile) => set({ dataProfile: profile }),
|
||||
setDataProfileLoading: (loading) => set({ dataProfileLoading: loading }),
|
||||
setDataProfileModalVisible: (visible) => set({ dataProfileModalVisible: visible }),
|
||||
setWorkflowPlanLoading: (loading) => set({ workflowPlanLoading: loading }),
|
||||
}));
|
||||
|
||||
// ==================== Derived selectors ====================
|
||||
|
||||
/** Get the currently selected record (call inside component / callback) */
|
||||
export function getCurrentRecord(): AnalysisRecord | null {
|
||||
const { analysisHistory, currentRecordId } = useSSAStore.getState();
|
||||
if (!currentRecordId) return null;
|
||||
return analysisHistory.find((r) => r.id === currentRecordId) ?? null;
|
||||
}
|
||||
|
||||
export default useSSAStore;
|
||||
|
||||
@@ -561,6 +561,19 @@
|
||||
font-size: 14px;
|
||||
font-weight: 700;
|
||||
color: #1e40af;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
}
|
||||
|
||||
.sap-card-badge {
|
||||
font-size: 10px;
|
||||
font-weight: 600;
|
||||
color: #059669;
|
||||
background: #ecfdf5;
|
||||
border: 1px solid #a7f3d0;
|
||||
border-radius: 4px;
|
||||
padding: 1px 6px;
|
||||
}
|
||||
|
||||
.sap-card-hint {
|
||||
@@ -2723,238 +2736,213 @@
|
||||
Phase 2A: 工作流时间线样式
|
||||
============================================ */
|
||||
|
||||
.workflow-timeline {
|
||||
padding: 20px;
|
||||
/* ============================================
|
||||
WorkflowTimeline v2 — wt-* namespace
|
||||
============================================ */
|
||||
|
||||
.wt-root {
|
||||
padding: 24px 20px;
|
||||
}
|
||||
|
||||
.workflow-timeline .timeline-header {
|
||||
/* --- Header --- */
|
||||
.wt-header {
|
||||
display: flex;
|
||||
gap: 14px;
|
||||
align-items: flex-start;
|
||||
margin-bottom: 20px;
|
||||
padding-bottom: 16px;
|
||||
padding-bottom: 18px;
|
||||
border-bottom: 1px solid #e2e8f0;
|
||||
}
|
||||
|
||||
.workflow-timeline .header-info {
|
||||
margin-bottom: 12px;
|
||||
.wt-header-icon {
|
||||
width: 40px;
|
||||
height: 40px;
|
||||
border-radius: 10px;
|
||||
background: linear-gradient(135deg, #6366f1 0%, #8b5cf6 100%);
|
||||
color: white;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.workflow-timeline .timeline-title {
|
||||
.wt-header-body {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
}
|
||||
.wt-title {
|
||||
margin: 0;
|
||||
font-size: 16px;
|
||||
font-weight: 600;
|
||||
color: #1e293b;
|
||||
line-height: 1.4;
|
||||
font-size: 17px;
|
||||
font-weight: 700;
|
||||
color: #0f172a;
|
||||
line-height: 1.35;
|
||||
}
|
||||
|
||||
.workflow-timeline .timeline-description {
|
||||
margin: 8px 0 0 0;
|
||||
.wt-desc {
|
||||
margin: 6px 0 0;
|
||||
font-size: 13px;
|
||||
color: #64748b;
|
||||
line-height: 1.6;
|
||||
word-break: break-word;
|
||||
overflow-wrap: break-word;
|
||||
}
|
||||
|
||||
.workflow-timeline .header-meta {
|
||||
.wt-meta {
|
||||
display: flex;
|
||||
gap: 16px;
|
||||
margin-top: 0;
|
||||
gap: 14px;
|
||||
margin-top: 10px;
|
||||
}
|
||||
|
||||
.workflow-timeline .step-count,
|
||||
.workflow-timeline .estimated-time {
|
||||
.wt-meta-item {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 5px;
|
||||
font-size: 12px;
|
||||
color: #94a3b8;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.workflow-timeline .timeline-progress {
|
||||
/* --- Banners --- */
|
||||
.wt-banner {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
padding: 10px 14px;
|
||||
margin-bottom: 14px;
|
||||
font-size: 12.5px;
|
||||
border-radius: 8px;
|
||||
line-height: 1.5;
|
||||
}
|
||||
.wt-banner b {
|
||||
font-weight: 600;
|
||||
}
|
||||
.wt-banner-warn {
|
||||
color: #92400e;
|
||||
background: #fffbeb;
|
||||
border: 1px solid #fde68a;
|
||||
}
|
||||
.wt-banner-guard {
|
||||
color: #1e40af;
|
||||
background: #eff6ff;
|
||||
border: 1px solid #bfdbfe;
|
||||
}
|
||||
|
||||
/* --- Progress --- */
|
||||
.wt-progress-bar-wrap {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
margin-bottom: 20px;
|
||||
padding: 12px 16px;
|
||||
background: #f8fafc;
|
||||
border-radius: 8px;
|
||||
margin-bottom: 18px;
|
||||
}
|
||||
|
||||
.workflow-timeline .progress-bar {
|
||||
.wt-progress-track {
|
||||
flex: 1;
|
||||
height: 6px;
|
||||
height: 5px;
|
||||
background: #e2e8f0;
|
||||
border-radius: 3px;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.workflow-timeline .progress-fill {
|
||||
.wt-progress-fill {
|
||||
height: 100%;
|
||||
background: linear-gradient(90deg, #3b82f6 0%, #2563eb 100%);
|
||||
background: linear-gradient(90deg, #6366f1, #3b82f6);
|
||||
border-radius: 3px;
|
||||
transition: width 0.3s ease;
|
||||
transition: width .4s ease;
|
||||
}
|
||||
|
||||
.workflow-timeline .progress-text {
|
||||
.wt-progress-label {
|
||||
font-size: 12px;
|
||||
font-weight: 500;
|
||||
font-weight: 600;
|
||||
color: #64748b;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.workflow-timeline .timeline-steps {
|
||||
/* --- Steps container --- */
|
||||
.wt-steps {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.workflow-timeline .timeline-step {
|
||||
/* --- Step row (rail + card) --- */
|
||||
.wt-step-row {
|
||||
display: flex;
|
||||
gap: 16px;
|
||||
padding-bottom: 20px;
|
||||
gap: 14px;
|
||||
min-height: 48px;
|
||||
}
|
||||
.wt-step-row.wt-current .wt-card {
|
||||
box-shadow: 0 0 0 2px #bfdbfe;
|
||||
}
|
||||
|
||||
.workflow-timeline .timeline-step.current {
|
||||
background: #eff6ff;
|
||||
margin: 0 -20px;
|
||||
padding: 16px 20px;
|
||||
border-radius: 8px;
|
||||
}
|
||||
|
||||
.workflow-timeline .step-connector {
|
||||
/* Rail */
|
||||
.wt-rail {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
width: 24px;
|
||||
width: 28px;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.workflow-timeline .step-dot {
|
||||
width: 24px;
|
||||
height: 24px;
|
||||
.wt-dot {
|
||||
width: 28px;
|
||||
height: 28px;
|
||||
border-radius: 50%;
|
||||
border: 2px solid;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
font-size: 12px;
|
||||
font-weight: 600;
|
||||
font-weight: 700;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.workflow-timeline .step-dot.pulse {
|
||||
.wt-dot.running {
|
||||
animation: pulse 1.5s ease-in-out infinite;
|
||||
}
|
||||
|
||||
.workflow-timeline .step-line {
|
||||
.wt-line {
|
||||
flex: 1;
|
||||
width: 2px;
|
||||
width: 0;
|
||||
min-height: 16px;
|
||||
border-left: 2px dashed #e2e8f0;
|
||||
margin-top: 4px;
|
||||
margin: 4px 0;
|
||||
}
|
||||
|
||||
.workflow-timeline .step-content {
|
||||
/* Card */
|
||||
.wt-card {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
background: white;
|
||||
border: 1px solid #e2e8f0;
|
||||
border-left: 3px solid #e2e8f0;
|
||||
border-radius: 10px;
|
||||
padding: 14px 16px;
|
||||
margin-bottom: 12px;
|
||||
transition: box-shadow .2s, border-color .2s;
|
||||
}
|
||||
|
||||
.workflow-timeline .step-header {
|
||||
.wt-card:hover {
|
||||
box-shadow: 0 2px 8px rgba(0,0,0,.05);
|
||||
}
|
||||
.wt-card-head {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 8px;
|
||||
}
|
||||
.wt-card-title-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
margin-bottom: 6px;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.workflow-timeline .step-number {
|
||||
font-size: 12px;
|
||||
font-weight: 500;
|
||||
color: #64748b;
|
||||
.wt-step-badge {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
padding: 2px 8px;
|
||||
font-size: 11px;
|
||||
font-weight: 600;
|
||||
color: #6366f1;
|
||||
background: #eef2ff;
|
||||
border-radius: 4px;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.workflow-timeline .tool-icon {
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
.workflow-timeline .tool-name {
|
||||
.wt-tool-name {
|
||||
font-size: 14px;
|
||||
font-weight: 600;
|
||||
color: #1e293b;
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
|
||||
.workflow-timeline .step-duration {
|
||||
font-size: 11px;
|
||||
color: #94a3b8;
|
||||
margin-left: auto;
|
||||
}
|
||||
|
||||
.workflow-timeline .step-description {
|
||||
font-size: 13px;
|
||||
color: #64748b;
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
|
||||
.workflow-timeline .step-params {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 6px;
|
||||
}
|
||||
|
||||
.workflow-timeline .param-tag {
|
||||
padding: 2px 8px;
|
||||
background: #f1f5f9;
|
||||
border-radius: 4px;
|
||||
font-size: 11px;
|
||||
color: #64748b;
|
||||
font-family: 'SF Mono', Monaco, monospace;
|
||||
}
|
||||
|
||||
.workflow-timeline .step-result-preview {
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
margin-top: 8px;
|
||||
}
|
||||
|
||||
.workflow-timeline .result-badge {
|
||||
padding: 2px 8px;
|
||||
background: #f1f5f9;
|
||||
border-radius: 4px;
|
||||
font-size: 11px;
|
||||
color: #64748b;
|
||||
font-family: 'SF Mono', Monaco, monospace;
|
||||
}
|
||||
|
||||
.workflow-timeline .significant-badge {
|
||||
padding: 2px 8px;
|
||||
background: #ecfdf5;
|
||||
border-radius: 4px;
|
||||
font-size: 11px;
|
||||
color: #059669;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.workflow-timeline .step-error {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
gap: 6px;
|
||||
margin-top: 8px;
|
||||
padding: 8px;
|
||||
background: #fef2f2;
|
||||
border-radius: 6px;
|
||||
}
|
||||
|
||||
.workflow-timeline .error-icon {
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.workflow-timeline .error-message {
|
||||
font-size: 12px;
|
||||
color: #dc2626;
|
||||
}
|
||||
|
||||
.workflow-timeline .timeline-footer {
|
||||
padding-top: 16px;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.workflow-timeline .sensitivity-badge {
|
||||
display: inline-block;
|
||||
.wt-sensitivity {
|
||||
display: inline-flex;
|
||||
padding: 1px 8px;
|
||||
font-size: 11px;
|
||||
font-weight: 600;
|
||||
@@ -2962,12 +2950,28 @@
|
||||
background: #fffbeb;
|
||||
border: 1px solid #fcd34d;
|
||||
border-radius: 10px;
|
||||
margin-left: 6px;
|
||||
white-space: nowrap;
|
||||
}
|
||||
.wt-duration {
|
||||
font-size: 11px;
|
||||
color: #94a3b8;
|
||||
font-family: 'SF Mono', Monaco, monospace;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.workflow-timeline .step-guardrail {
|
||||
margin-top: 4px;
|
||||
padding: 4px 8px;
|
||||
/* Card body */
|
||||
.wt-card-desc {
|
||||
margin: 8px 0 0;
|
||||
font-size: 12.5px;
|
||||
color: #64748b;
|
||||
line-height: 1.5;
|
||||
}
|
||||
.wt-guardrail-inline {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
margin-top: 8px;
|
||||
padding: 5px 10px;
|
||||
font-size: 12px;
|
||||
color: #1d4ed8;
|
||||
background: #eff6ff;
|
||||
@@ -2975,45 +2979,83 @@
|
||||
border-radius: 4px;
|
||||
}
|
||||
|
||||
.workflow-timeline .epv-warning-banner {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
padding: 10px 14px;
|
||||
margin-bottom: 12px;
|
||||
font-size: 13px;
|
||||
color: #92400e;
|
||||
background: #fffbeb;
|
||||
border: 1px solid #fcd34d;
|
||||
/* Params grid */
|
||||
.wt-params-grid {
|
||||
display: grid;
|
||||
grid-template-columns: auto 1fr;
|
||||
gap: 4px 10px;
|
||||
margin-top: 10px;
|
||||
padding: 10px 12px;
|
||||
background: #f8fafc;
|
||||
border: 1px solid #f1f5f9;
|
||||
border-radius: 8px;
|
||||
}
|
||||
|
||||
.workflow-timeline .epv-warning-banner .epv-icon {
|
||||
font-size: 16px;
|
||||
flex-shrink: 0;
|
||||
.wt-param-item {
|
||||
display: contents;
|
||||
}
|
||||
|
||||
.workflow-timeline .guardrail-banner {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
padding: 10px 14px;
|
||||
margin-bottom: 12px;
|
||||
font-size: 13px;
|
||||
color: #1e40af;
|
||||
background: #eff6ff;
|
||||
border: 1px solid #93c5fd;
|
||||
border-radius: 8px;
|
||||
.wt-param-label {
|
||||
font-size: 11px;
|
||||
font-weight: 600;
|
||||
color: #475569;
|
||||
white-space: nowrap;
|
||||
line-height: 1.8;
|
||||
}
|
||||
|
||||
.workflow-timeline .guardrail-banner .guardrail-icon {
|
||||
font-size: 16px;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.workflow-timeline .ready-hint {
|
||||
font-size: 13px;
|
||||
.wt-param-val {
|
||||
font-size: 11.5px;
|
||||
color: #64748b;
|
||||
font-family: 'SF Mono', Monaco, monospace;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
line-height: 1.8;
|
||||
}
|
||||
|
||||
/* Result / Error */
|
||||
.wt-result-row {
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
margin-top: 10px;
|
||||
}
|
||||
.wt-p-badge {
|
||||
padding: 3px 10px;
|
||||
background: #f1f5f9;
|
||||
border-radius: 6px;
|
||||
font-size: 12px;
|
||||
color: #475569;
|
||||
font-family: 'SF Mono', Monaco, monospace;
|
||||
font-weight: 500;
|
||||
}
|
||||
.wt-sig-badge {
|
||||
padding: 3px 10px;
|
||||
background: #dcfce7;
|
||||
border-radius: 6px;
|
||||
font-size: 12px;
|
||||
color: #15803d;
|
||||
font-weight: 600;
|
||||
}
|
||||
.wt-error-row {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
gap: 8px;
|
||||
margin-top: 10px;
|
||||
padding: 8px 12px;
|
||||
background: #fef2f2;
|
||||
border-radius: 6px;
|
||||
font-size: 12px;
|
||||
color: #dc2626;
|
||||
line-height: 1.5;
|
||||
}
|
||||
|
||||
/* Footer */
|
||||
.wt-footer {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 8px;
|
||||
padding: 16px 0 4px;
|
||||
font-size: 13px;
|
||||
color: #94a3b8;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
/* ============================================
|
||||
|
||||
@@ -83,7 +83,7 @@ export interface AnalysisPlan {
|
||||
dependentVar?: string;
|
||||
}
|
||||
|
||||
export type ArtifactType = 'sap' | 'confirm' | 'execution' | 'result';
|
||||
export type ArtifactType = 'sap' | 'confirm' | 'execution' | 'result' | 'profile';
|
||||
|
||||
export interface SSAMessage {
|
||||
id: string;
|
||||
|
||||
Reference in New Issue
Block a user