feat(ssa): Complete Phase I-IV intelligent dialogue and tool system development

Phase I - Session Blackboard + READ Layer:
- SessionBlackboardService with Postgres-Only cache
- DataProfileService for data overview generation
- PicoInferenceService for LLM-driven PICO extraction
- Frontend DataContextCard and VariableDictionaryPanel
- E2E tests: 31/31 passed

Phase II - Conversation Layer LLM + Intent Router:
- ConversationService with SSE streaming
- IntentRouterService (rule-first + LLM fallback, 6 intents)
- SystemPromptService with 6-segment dynamic assembly
- TokenTruncationService for context management
- ChatHandlerService as unified chat entry
- Frontend SSAChatPane and useSSAChat hook
- E2E tests: 38/38 passed

Phase III - Method Consultation + AskUser Standardization:
- ToolRegistryService with Repository Pattern
- MethodConsultService with DecisionTable + LLM enhancement
- AskUserService with global interrupt handling
- Frontend AskUserCard component
- E2E tests: 13/13 passed

Phase IV - Dialogue-Driven Analysis + QPER Integration:
- ToolOrchestratorService (plan/execute/report)
- analysis_plan SSE event for WorkflowPlan transmission
- Dual-channel confirmation (ask_user card + workspace button)
- PICO as optional hint for LLM parsing
- E2E tests: 25/25 passed

R Statistics Service:
- 5 new R tools: anova_one, baseline_table, fisher, linear_reg, wilcoxon
- Enhanced guardrails and block helpers
- Comprehensive test suite (run_all_tools_test.js)

Documentation:
- Updated system status document (v5.9)
- Updated SSA module status and development plan (v1.8)

Total E2E: 107/107 passed (Phase I: 31, Phase II: 38, Phase III: 13, Phase IV: 25)

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
2026-02-22 18:53:39 +08:00
parent bf10dec4c8
commit 3446909ff7
68 changed files with 11583 additions and 412 deletions

View File

@@ -0,0 +1,218 @@
/**
* AskUserCard — 统一交互卡片组件 (Phase III)
*
* H3: 统一 AskUser 领域模型,取代旧 ClarificationCard 概念。
* H1: 所有类型底部均有"跳过此问题"逃生门。
*
* 支持 4 种 inputType:
* - single_select: 单选按钮
* - multi_select: 多选复选框
* - free_text: 自由文本输入
* - confirm: 确认/取消
*/
import React, { useState, useCallback } from 'react';
import { HelpCircle, SkipForward, Check, X } from 'lucide-react';
export interface AskUserOption {
label: string;
value: string;
description?: string;
}
export interface AskUserEventData {
type: 'ask_user';
questionId: string;
question: string;
context?: string;
inputType: 'single_select' | 'multi_select' | 'free_text' | 'confirm';
options?: AskUserOption[];
defaultValue?: string;
metadata?: Record<string, any>;
}
export interface AskUserResponseData {
questionId: string;
action: 'select' | 'skip' | 'free_text';
selectedValues?: string[];
freeText?: string;
}
interface AskUserCardProps {
event: AskUserEventData;
onRespond: (response: AskUserResponseData) => void;
onSkip: (questionId: string) => void;
disabled?: boolean;
}
export const AskUserCard: React.FC<AskUserCardProps> = ({
event,
onRespond,
onSkip,
disabled = false,
}) => {
const [selectedValues, setSelectedValues] = useState<string[]>([]);
const [freeText, setFreeText] = useState(event.defaultValue || '');
const handleSingleSelect = useCallback(
(value: string) => {
if (disabled) return;
onRespond({
questionId: event.questionId,
action: 'select',
selectedValues: [value],
});
},
[disabled, event.questionId, onRespond],
);
const handleMultiToggle = useCallback(
(value: string) => {
setSelectedValues(prev =>
prev.includes(value)
? prev.filter(v => v !== value)
: [...prev, value],
);
},
[],
);
const handleMultiSubmit = useCallback(() => {
if (disabled || selectedValues.length === 0) return;
onRespond({
questionId: event.questionId,
action: 'select',
selectedValues,
});
}, [disabled, event.questionId, onRespond, selectedValues]);
const handleFreeTextSubmit = useCallback(() => {
if (disabled || !freeText.trim()) return;
onRespond({
questionId: event.questionId,
action: 'free_text',
freeText: freeText.trim(),
});
}, [disabled, event.questionId, freeText, onRespond]);
const handleConfirm = useCallback(
(value: string) => {
if (disabled) return;
onRespond({
questionId: event.questionId,
action: 'select',
selectedValues: [value],
});
},
[disabled, event.questionId, onRespond],
);
const handleSkip = useCallback(() => {
onSkip(event.questionId);
}, [event.questionId, onSkip]);
return (
<div className="ask-user-card">
{/* Question header */}
<div className="ask-user-question">
<HelpCircle size={14} className="text-blue-500 flex-shrink-0" />
<span>{event.question}</span>
</div>
{/* Context */}
{event.context && (
<div className="ask-user-context">{event.context}</div>
)}
{/* Input area by type */}
<div className="ask-user-options">
{event.inputType === 'single_select' && event.options?.map((opt, i) => (
<button
key={i}
className="ask-user-option-btn"
onClick={() => handleSingleSelect(opt.value)}
disabled={disabled}
title={opt.description}
>
<span className="option-label">{opt.label}</span>
{opt.description && (
<span className="option-desc">{opt.description}</span>
)}
</button>
))}
{event.inputType === 'multi_select' && (
<>
{event.options?.map((opt, i) => (
<label key={i} className="ask-user-checkbox-label">
<input
type="checkbox"
checked={selectedValues.includes(opt.value)}
onChange={() => handleMultiToggle(opt.value)}
disabled={disabled}
/>
<span className="option-label">{opt.label}</span>
{opt.description && (
<span className="option-desc">{opt.description}</span>
)}
</label>
))}
<button
className="ask-user-submit-btn"
onClick={handleMultiSubmit}
disabled={disabled || selectedValues.length === 0}
>
<Check size={14} />
</button>
</>
)}
{event.inputType === 'free_text' && (
<div className="ask-user-free-text">
<textarea
className="ask-user-textarea"
value={freeText}
onChange={e => setFreeText(e.target.value)}
placeholder="请输入..."
rows={3}
disabled={disabled}
/>
<button
className="ask-user-submit-btn"
onClick={handleFreeTextSubmit}
disabled={disabled || !freeText.trim()}
>
<Check size={14} />
</button>
</div>
)}
{event.inputType === 'confirm' && event.options?.map((opt, i) => (
<button
key={i}
className={`ask-user-option-btn ${opt.value === 'confirm' ? 'confirm-primary' : ''}`}
onClick={() => handleConfirm(opt.value)}
disabled={disabled}
title={opt.description}
>
{opt.value === 'confirm' ? <Check size={14} /> : <X size={14} />}
<span className="option-label">{opt.label}</span>
</button>
))}
</div>
{/* H1: Skip escape hatch */}
<div className="ask-user-skip">
<button
className="ask-user-skip-btn"
onClick={handleSkip}
disabled={disabled}
>
<SkipForward size={12} />
</button>
</div>
</div>
);
};
export default AskUserCard;

View File

@@ -0,0 +1,140 @@
/**
* Phase I — 五段式数据概览卡片
*
* 在对话区展示 get_data_overview 返回的五段式结构化报告,
* 每段折叠/展开,关键数字高亮。
*/
import React, { useState } from 'react';
import {
Database,
AlertCircle,
Tag,
TrendingUp,
BarChart3,
ChevronDown,
ChevronRight,
} from 'lucide-react';
import type { FiveSectionReport } from '../types';
interface DataContextCardProps {
report: FiveSectionReport;
compact?: boolean;
}
interface SectionConfig {
key: keyof FiveSectionReport;
icon: React.ReactNode;
accent: string;
}
const sectionConfigs: SectionConfig[] = [
{ key: 'basicInfo', icon: <Database size={16} />, accent: '#3b82f6' },
{ key: 'missingData', icon: <AlertCircle size={16} />, accent: '#f59e0b' },
{ key: 'dataTypes', icon: <Tag size={16} />, accent: '#8b5cf6' },
{ key: 'outliers', icon: <TrendingUp size={16} />, accent: '#ef4444' },
{ key: 'normality', icon: <BarChart3 size={16} />, accent: '#10b981' },
];
export const DataContextCard: React.FC<DataContextCardProps> = ({ report, compact = false }) => {
const [expandedSections, setExpandedSections] = useState<Set<string>>(
new Set(compact ? [] : ['basicInfo', 'missingData', 'dataTypes', 'outliers', 'normality'])
);
const toggleSection = (key: string) => {
setExpandedSections((prev) => {
const next = new Set(prev);
if (next.has(key)) next.delete(key);
else next.add(key);
return next;
});
};
return (
<div className="data-context-card">
<div className="dcc-header">
<Database size={18} className="dcc-icon" />
<span className="dcc-title"></span>
</div>
<div className="dcc-body">
{sectionConfigs.map(({ key, icon, accent }) => {
const section = report[key];
if (!section) return null;
const isExpanded = expandedSections.has(key);
return (
<div key={key} className="dcc-section" style={{ borderLeftColor: accent }}>
<div
className="dcc-section-header"
onClick={() => toggleSection(key)}
>
<span className="dcc-section-icon" style={{ color: accent }}>
{icon}
</span>
<span className="dcc-section-title">{section.title}</span>
<span className="dcc-section-toggle">
{isExpanded ? <ChevronDown size={14} /> : <ChevronRight size={14} />}
</span>
</div>
{isExpanded && (
<div className="dcc-section-content">
<p>{section.content}</p>
{renderSectionExtras(key, section)}
</div>
)}
</div>
);
})}
</div>
</div>
);
};
function renderSectionExtras(key: string, section: any) {
if (key === 'missingData' && section.varsWithMissing?.length > 0) {
return (
<div className="dcc-var-tags">
{section.varsWithMissing.map((v: string) => (
<span key={v} className="dcc-tag dcc-tag-warning">{v}</span>
))}
</div>
);
}
if (key === 'dataTypes' && section.needsConfirmation) {
return (
<div className="dcc-note">
<AlertCircle size={12} />
<span></span>
</div>
);
}
if (key === 'outliers' && section.varsWithOutliers?.length > 0) {
return (
<div className="dcc-var-tags">
{section.varsWithOutliers.map((v: string) => (
<span key={v} className="dcc-tag dcc-tag-danger">{v}</span>
))}
</div>
);
}
if (key === 'normality') {
return (
<div className="dcc-var-tags">
{section.nonNormalVars?.map((v: string) => (
<span key={v} className="dcc-tag dcc-tag-danger">{v}</span>
))}
{section.normalVars?.map((v: string) => (
<span key={v} className="dcc-tag dcc-tag-success">{v}</span>
))}
</div>
);
}
return null;
}
export default DataContextCard;

View File

@@ -2,12 +2,12 @@
* DynamicReport — Block-based 动态报告渲染组件
*
* 消费后端/R 端返回的 ReportBlock[] 数组,按序渲染:
* markdown → 富文本段落
* table → 三线表
* markdown → 富文本段落(支持 **bold**
* table → 三线表(支持 rowspan、P值标星、横向滚动、基线表增强
* image → base64 图片
* key_value → 键值对网格
*/
import React, { useState } from 'react';
import React, { useState, useMemo } from 'react';
import { ImageOff, Loader2 } from 'lucide-react';
import type { ReportBlock } from '../types';
@@ -63,38 +63,69 @@ const KVBlock: React.FC<{ block: ReportBlock }> = ({ block }) => {
);
};
/* ─── table ─── */
/* ─── table (增强版rowspan + P值标星 + 横向滚动 + 基线表) ─── */
const TableBlock: React.FC<{ block: ReportBlock; index: number }> = ({ block, index }) => {
const headers = block.headers ?? [];
const rows = block.rows ?? [];
if (headers.length === 0 && rows.length === 0) return null;
const isBaselineTable = block.metadata?.is_baseline_table === true;
const isWideTable = headers.length > 6;
const processedRows = useMemo(() => computeRowSpans(rows), [rows]);
const pColIndex = useMemo(() => {
const idx = headers.findIndex(h =>
typeof h === 'string' && /^p[.\s_-]?val/i.test(h)
);
if (idx !== -1) return idx;
return headers.findIndex(h =>
typeof h === 'string' && h.toLowerCase().includes('p 值')
);
}, [headers]);
return (
<div className="result-table-section">
<div className={`result-table-section ${isBaselineTable ? 'baseline-table' : ''}`}>
{block.title && (
<h4 className="table-label">
Table {index + 1}. {block.title}
</h4>
)}
<div className="sci-table-wrapper">
<table className="sci-table">
<div className={`sci-table-wrapper ${isWideTable ? 'scrollable' : ''}`}>
<table className={`sci-table ${isBaselineTable ? 'sci-table--baseline' : ''}`}>
{headers.length > 0 && (
<thead>
<tr>
{headers.map((h, i) => (
<th key={i}>{h}</th>
<th key={i} className={i === pColIndex ? 'col-p-value' : ''}>
{h}
</th>
))}
</tr>
</thead>
)}
<tbody>
{rows.map((row, ri) => (
<tr key={ri}>
{row.map((cell, ci) => (
<td key={ci} className={isPValueCell(cell) ? 'p-value' : ''}>
{formatTableCell(cell)}
</td>
))}
{processedRows.map((row, ri) => (
<tr key={ri} className={row.isCategory ? 'row-category-header' : ''}>
{row.cells.map((cell, ci) => {
if (cell.hidden) return null;
const cellStr = formatTableCell(cell.value);
const isSignificantP = ci === pColIndex && isSignificantPValue(cellStr);
return (
<td
key={ci}
rowSpan={cell.rowSpan > 1 ? cell.rowSpan : undefined}
className={[
isPValueCell(cell.value) ? 'p-value' : '',
isSignificantP ? 'p-significant' : '',
ci === 0 && cell.rowSpan > 1 ? 'td-rowspan' : '',
].filter(Boolean).join(' ')}
>
{cellStr}
{isSignificantP && <span className="p-star">*</span>}
</td>
);
})}
</tr>
))}
</tbody>
@@ -110,7 +141,7 @@ const ImageBlock: React.FC<{ block: ReportBlock; index: number }> = ({ block, in
const [hasError, setHasError] = useState(false);
const [isLoading, setIsLoading] = useState(true);
const src = React.useMemo(() => {
const src = useMemo(() => {
if (!block.data) return '';
if (block.data.startsWith('data:')) return block.data;
return `data:image/png;base64,${block.data}`;
@@ -148,7 +179,7 @@ const ImageBlock: React.FC<{ block: ReportBlock; index: number }> = ({ block, in
);
};
/* ─── markdown ─── */
/* ─── markdown (支持 **bold** 语法) ─── */
const MarkdownBlock: React.FC<{ block: ReportBlock }> = ({ block }) => {
if (!block.content) return null;
@@ -156,15 +187,72 @@ const MarkdownBlock: React.FC<{ block: ReportBlock }> = ({ block }) => {
<div className="dr-markdown-section">
{block.title && <h4 className="dr-section-title">{block.title}</h4>}
<div className="dr-markdown-content">
{block.content.split('\n').map((line, i) => (
<p key={i}>{line}</p>
))}
{block.content.split('\n').map((line, i) => {
if (!line.trim()) return <br key={i} />;
return (
<p key={i} dangerouslySetInnerHTML={{ __html: renderInlineMarkdown(line) }} />
);
})}
</div>
</div>
);
};
/* ─── helpers ─── */
interface ProcessedCell {
value: string | number;
rowSpan: number;
hidden: boolean;
}
interface ProcessedRow {
cells: ProcessedCell[];
isCategory: boolean;
}
/**
* 计算第一列的 rowspan分类变量合并行
* 连续相同的首列值会被合并
*/
function computeRowSpans(rows: (string | number)[][]): ProcessedRow[] {
if (rows.length === 0) return [];
const processed: ProcessedRow[] = rows.map(row => ({
cells: row.map(cell => ({ value: cell, rowSpan: 1, hidden: false })),
isCategory: false,
}));
let i = 0;
while (i < processed.length) {
const firstCellVal = formatTableCell(processed[i].cells[0]?.value);
if (!firstCellVal || firstCellVal === '-' || firstCellVal === '') {
i++;
continue;
}
let spanCount = 1;
while (
i + spanCount < processed.length &&
formatTableCell(processed[i + spanCount].cells[0]?.value) === firstCellVal
) {
spanCount++;
}
if (spanCount > 1) {
processed[i].cells[0].rowSpan = spanCount;
processed[i].isCategory = true;
for (let j = 1; j < spanCount; j++) {
processed[i + j].cells[0].hidden = true;
}
}
i += spanCount;
}
return processed;
}
const isPValueCell = (cell: string | number): boolean => {
if (typeof cell === 'string') {
return cell.includes('<') || cell.includes('*');
@@ -172,6 +260,16 @@ const isPValueCell = (cell: string | number): boolean => {
return false;
};
const isSignificantPValue = (cellStr: string): boolean => {
if (cellStr.includes('< 0.001') || cellStr.includes('<0.001')) return true;
const match = cellStr.match(/^(\d+\.?\d*)/);
if (match) {
const val = parseFloat(match[1]);
return !isNaN(val) && val < 0.05;
}
return false;
};
const formatTableCell = (cell: string | number): string => {
if (cell === null || cell === undefined) return '-';
if (typeof cell === 'number') {
@@ -180,4 +278,11 @@ const formatTableCell = (cell: string | number): string => {
return String(cell);
};
/**
* 简易行内 Markdown 渲染(**bold** + ⚠️ 保留)
*/
function renderInlineMarkdown(text: string): string {
return text.replace(/\*\*(.+?)\*\*/g, '<strong>$1</strong>');
}
export default DynamicReport;

View File

@@ -1,18 +1,22 @@
/**
* SSAChatPane - V11 对话区
*
* 100% 还原 V11 原型图
* - 顶部 Header标题 + 返回按钮 + 状态指示
* - 居中对话列表
* - 底部悬浮输入框
* SSAChatPane - V11 对话区Phase II 升级)
*
* Phase II 改造:
* - handleSend 走统一 /chat SSE API替换 parseIntent → generateWorkflowPlan
* - 流式消息渲染(替换 TypeWriter 逐字效果)
* - ThinkingBlock 深度思考折叠
* - 意图标签intent badge
* - H3streaming 期间锁定输入
*
* 保留不变Header、文件上传、DataProfileCard、SAP/Result 卡片
*/
import React, { useState, useRef, useEffect, useCallback } from 'react';
import {
Bot,
User,
Paperclip,
ArrowUp,
FileSpreadsheet,
import {
Bot,
User,
Paperclip,
ArrowUp,
FileSpreadsheet,
X,
ArrowLeft,
FileSignature,
@@ -20,17 +24,23 @@ import {
Loader2,
AlertCircle,
CheckCircle,
BarChart2
BarChart2,
Square,
} 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 { useSSAChat } from '../hooks/useSSAChat';
import type { ChatMessage, ChatIntentType } from '../hooks/useSSAChat';
import type { SSAMessage } from '../types';
import { TypeWriter } from './TypeWriter';
import { DataProfileCard } from './DataProfileCard';
import { ClarificationCard } from './ClarificationCard';
import { AskUserCard } from './AskUserCard';
import type { AskUserResponseData } from './AskUserCard';
import { ThinkingBlock } from '@/shared/components/Chat';
import type { ClarificationCardData, IntentResult } from '../types';
export const SSAChatPane: React.FC = () => {
@@ -55,7 +65,21 @@ export const SSAChatPane: React.FC = () => {
} = useSSAStore();
const { uploadData, generatePlan, isUploading, uploadProgress } = useAnalysis();
const { generateDataProfile, generateWorkflowPlan, parseIntent, handleClarify, isProfileLoading, isPlanLoading } = useWorkflow();
const { generateDataProfile, handleClarify, executeWorkflow, isProfileLoading, isPlanLoading } = useWorkflow();
const {
chatMessages,
isGenerating,
currentIntent,
pendingQuestion,
pendingPlanConfirm,
sendChatMessage,
respondToQuestion,
skipQuestion,
loadHistory,
abort: abortChat,
clearMessages,
} = useSSAChat();
const [inputValue, setInputValue] = useState('');
const [uploadStatus, setUploadStatus] = useState<'idle' | 'uploading' | 'parsing' | 'success' | 'error'>('idle');
const [pendingClarification, setPendingClarification] = useState<{
@@ -67,6 +91,24 @@ export const SSAChatPane: React.FC = () => {
const chatEndRef = useRef<HTMLDivElement>(null);
const messagesContainerRef = useRef<HTMLDivElement>(null);
// Phase IV: plan_confirmed → 自动触发 executeWorkflow
useEffect(() => {
if (pendingPlanConfirm?.workflowId && currentSession?.id) {
executeWorkflow(currentSession.id, pendingPlanConfirm.workflowId).catch((err: any) => {
addToast(err?.message || '执行失败', 'error');
});
}
}, [pendingPlanConfirm, currentSession?.id, executeWorkflow, addToast]);
// Phase II: session 切换时加载对话历史
useEffect(() => {
if (currentSession?.id) {
loadHistory(currentSession.id);
} else {
clearMessages();
}
}, [currentSession?.id, loadHistory, clearMessages]);
// 自动滚动到底部,确保最新内容可见
const scrollToBottom = useCallback(() => {
if (messagesContainerRef.current) {
@@ -80,7 +122,7 @@ export const SSAChatPane: React.FC = () => {
useEffect(() => {
const timer = setTimeout(scrollToBottom, 100);
return () => clearTimeout(timer);
}, [messages, scrollToBottom]);
}, [messages, chatMessages, isGenerating, scrollToBottom]);
const handleBack = () => {
navigate(-1);
@@ -149,45 +191,32 @@ export const SSAChatPane: React.FC = () => {
const handleSend = async () => {
if (!inputValue.trim()) return;
if (isGenerating) return;
const query = inputValue;
const query = inputValue.trim();
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: 'assistant',
content: `我大致理解了你的意图(${intentResp.intent.reasoning}),但为了生成更精确的分析方案,想确认几个细节:`,
createdAt: new Date().toISOString(),
});
setPendingClarification({
cards: intentResp.clarificationCards,
originalQuery: query,
intent: intentResp.intent,
});
return;
}
// 置信度足够 → 直接生成工作流计划(不再重复添加用户消息)
await generateWorkflowPlan(currentSession.id, query);
} else {
await generatePlan(query);
if (currentSession?.id) {
// Phase II: 统一走 /chat SSE API
// useSSAChat 内部处理 user message 添加 + assistant placeholder + 流式接收
try {
await sendChatMessage(currentSession.id, query);
} catch (err: any) {
addToast(err?.message || '对话失败', 'error');
}
} else {
// 没有 session未上传数据走老的 generatePlan 流程
addMessage({
id: crypto.randomUUID(),
role: 'user',
content: query,
createdAt: new Date().toISOString(),
});
try {
await generatePlan(query);
} catch (err: any) {
addToast(err?.message || '生成计划失败', 'error');
}
} catch (err: any) {
addToast(err?.message || '生成计划失败', 'error');
}
};
@@ -264,11 +293,13 @@ export const SSAChatPane: React.FC = () => {
{currentSession?.title || '新的统计分析'}
</span>
</div>
<EngineStatus
isExecuting={isExecuting}
<EngineStatus
isExecuting={isExecuting}
isLoading={isLoading || isPlanLoading}
isUploading={uploadStatus === 'uploading' || uploadStatus === 'parsing'}
isProfileLoading={isProfileLoading || dataProfileLoading}
isStreaming={isGenerating}
streamIntent={currentIntent}
/>
</header>
@@ -299,13 +330,13 @@ export const SSAChatPane: React.FC = () => {
</div>
)}
{/* 动态消息 */}
{/* 旧消息store 中的 SSAMessage保留向后兼容 */}
{messages.map((msg: SSAMessage, idx: number) => {
const isLastAiMessage = msg.role === 'assistant' && idx === messages.length - 1;
const showTypewriter = isLastAiMessage && !msg.artifactType;
return (
<div
<div
key={msg.id || idx}
className={`message ${msg.role === 'user' ? 'message-user' : 'message-ai'} slide-up`}
style={{ animationDelay: `${idx * 0.1}s` }}
@@ -320,7 +351,6 @@ export const SSAChatPane: React.FC = () => {
msg.content
)}
{/* 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';
@@ -350,6 +380,49 @@ export const SSAChatPane: React.FC = () => {
);
})}
{/* Phase II: 流式对话消息(来自 useSSAChat */}
{chatMessages.map((msg: ChatMessage) => (
<div
key={msg.id}
className={`message ${msg.role === 'user' ? 'message-user' : 'message-ai'} slide-up`}
>
<div className={`message-avatar ${msg.role === 'user' ? 'user-avatar' : 'ai-avatar'}`}>
{msg.role === 'user' ? <User size={12} /> : <Bot size={12} />}
</div>
<div className={`message-bubble ${msg.role === 'user' ? 'user-bubble' : 'ai-bubble'}`}>
{/* 意图标签 */}
{msg.role === 'assistant' && msg.intent && (
<IntentBadge intent={msg.intent} />
)}
{/* 深度思考折叠 */}
{msg.role === 'assistant' && msg.thinking && (
<ThinkingBlock
content={msg.thinking}
isThinking={msg.status === 'generating'}
defaultExpanded={false}
/>
)}
{/* 消息内容 */}
{msg.status === 'generating' && !msg.content ? (
<div className="thinking-dots">
<span className="dot" />
<span className="dot" />
<span className="dot" />
</div>
) : msg.status === 'error' ? (
<div className="chat-error-msg">
<AlertCircle size={14} className="text-red-500" />
<span>{msg.content}</span>
</div>
) : (
<div className="chat-msg-content">{msg.content}</div>
)}
</div>
</div>
))}
{/* 数据画像生成中指示器 */}
{dataProfileLoading && (
<div className="message message-ai slide-up">
@@ -365,8 +438,8 @@ export const SSAChatPane: React.FC = () => {
</div>
)}
{/* AI 正在思考指示器 */}
{(isLoading || isPlanLoading) && (
{/* AI 正在思考指示器仅老流程使用Phase II 由 chatMessages 中的 generating 状态处理) */}
{(isLoading || isPlanLoading) && !isGenerating && (
<div className="message message-ai slide-up">
<div className="message-avatar ai-avatar">
<Bot size={12} />
@@ -381,14 +454,7 @@ export const SSAChatPane: React.FC = () => {
</div>
)}
{/*
Phase 2A 新流程:
1. 上传数据 → 显示数据质量报告(已在上方处理)
2. 用户输入分析问题 → AI 回复消息中包含 SAP 卡片(通过 msg.artifactType === 'sap'
旧的"数据挂载成功消息"已移除,避免在没有用户输入时就显示 SAP 卡片
*/}
{/* Phase Q: 追问卡片 */}
{/* Phase Q: 追问卡片(保留向后兼容) */}
{pendingClarification && (
<div className="message-row assistant">
<div className="avatar-col"><Bot size={18} /></div>
@@ -396,7 +462,22 @@ export const SSAChatPane: React.FC = () => {
<ClarificationCard
cards={pendingClarification.cards}
onSelect={handleClarificationSelect}
disabled={isLoading}
disabled={isLoading || isGenerating}
/>
</div>
</div>
)}
{/* Phase III/IV: AskUser 交互卡片H3 统一模型) */}
{pendingQuestion && currentSession?.id && (
<div className="message-row assistant">
<div className="avatar-col"><Bot size={18} /></div>
<div className="msg-content">
<AskUserCard
event={pendingQuestion}
onRespond={(response: AskUserResponseData) => respondToQuestion(currentSession.id, response)}
onSkip={(questionId: string) => skipQuestion(currentSession.id, questionId)}
disabled={isGenerating || isExecuting}
/>
</div>
</div>
@@ -489,31 +570,41 @@ export const SSAChatPane: React.FC = () => {
onChange={handleFileSelect}
style={{ display: 'none' }}
/>
<button
<button
className={`upload-btn ${mountedFile ? 'disabled' : ''}`}
onClick={() => fileInputRef.current?.click()}
disabled={!!mountedFile || isUploading}
disabled={!!mountedFile || isUploading || isGenerating}
>
<Paperclip size={18} />
</button>
<textarea
className="message-input"
value={inputValue}
onChange={(e) => setInputValue(e.target.value)}
onKeyDown={handleKeyDown}
placeholder="发送消息,或点击回形针 📎 上传数据触发分析..."
placeholder={isGenerating ? 'AI 正在回复...' : '发送消息,或点击回形针 📎 上传数据触发分析...'}
rows={1}
disabled={isLoading}
disabled={isLoading || isGenerating}
/>
<button
className="send-btn"
onClick={handleSend}
disabled={!inputValue.trim() || isLoading}
>
<ArrowUp size={14} />
</button>
{isGenerating ? (
<button
className="send-btn abort-btn"
onClick={abortChat}
title="中断回复"
>
<Square size={14} />
</button>
) : (
<button
className="send-btn"
onClick={handleSend}
disabled={!inputValue.trim() || isLoading}
>
<ArrowUp size={14} />
</button>
)}
</div>
</div>
<div className="input-hint">
@@ -530,15 +621,32 @@ interface EngineStatusProps {
isLoading: boolean;
isUploading: boolean;
isProfileLoading?: boolean;
isStreaming?: boolean;
streamIntent?: ChatIntentType | null;
}
const EngineStatus: React.FC<EngineStatusProps> = ({
isExecuting,
isLoading,
const INTENT_LABELS: Record<ChatIntentType, string> = {
chat: '对话',
explore: '数据探索',
consult: '方法咨询',
analyze: '统计分析',
discuss: '结果讨论',
feedback: '结果改进',
};
const EngineStatus: React.FC<EngineStatusProps> = ({
isExecuting,
isLoading,
isUploading,
isProfileLoading
isProfileLoading,
isStreaming,
streamIntent,
}) => {
const getStatus = () => {
if (isStreaming) {
const label = streamIntent ? INTENT_LABELS[streamIntent] : 'AI';
return { text: `${label} 生成中...`, className: 'status-streaming' };
}
if (isExecuting) {
return { text: 'R Engine Running...', className: 'status-running' };
}
@@ -564,4 +672,16 @@ const EngineStatus: React.FC<EngineStatusProps> = ({
);
};
/**
* 意图标签组件
*/
const IntentBadge: React.FC<{ intent: ChatIntentType }> = ({ intent }) => {
const label = INTENT_LABELS[intent] || intent;
return (
<span className={`intent-badge intent-${intent}`}>
{label}
</span>
);
};
export default SSAChatPane;

View File

@@ -0,0 +1,183 @@
/**
* Phase I — 单变量详情面板
*
* 展示 get_variable_detail 返回的详细分析:
* - 数值型: 描述统计 + 直方图 + 正态性检验 + Q-Q 图
* - 分类型: 各水平频率分布条形图
*/
import React, { useEffect } from 'react';
import { X, BarChart3, Activity, TrendingUp, AlertTriangle } from 'lucide-react';
import { useSSAStore } from '../stores/ssaStore';
import type { VariableDetailData } from '../types';
export const VariableDetailPanel: React.FC = () => {
const { dataContext, setSelectedVariable, setVariableDetail, setVariableDetailLoading } = useSSAStore();
const { selectedVariable, variableDetail, variableDetailLoading } = dataContext;
useEffect(() => {
if (!selectedVariable) {
setVariableDetail(null);
return;
}
// TODO: 批次 4+ 联调时替换为真实 API 调用
// 此处先用 mock 数据占位,避免阻塞前端开发
setVariableDetailLoading(true);
const timer = setTimeout(() => setVariableDetailLoading(false), 300);
return () => clearTimeout(timer);
}, [selectedVariable, setVariableDetail, setVariableDetailLoading]);
if (!selectedVariable) return null;
return (
<div className="vdep-panel">
<div className="vdep-header">
<div className="vdep-header-left">
<Activity size={16} />
<span className="vdep-title">{selectedVariable}</span>
{variableDetail && (
<span className="vdep-type-badge">{variableDetail.type}</span>
)}
</div>
<button className="vdep-close" onClick={() => setSelectedVariable(null)}>
<X size={16} />
</button>
</div>
{variableDetailLoading ? (
<div className="vdep-loading">...</div>
) : variableDetail ? (
<div className="vdep-body">
<BasicStats detail={variableDetail} />
{variableDetail.type === 'numeric' && <NumericCharts detail={variableDetail} />}
{variableDetail.type === 'categorical' && <CategoricalChart detail={variableDetail} />}
</div>
) : (
<div className="vdep-empty">
<p>"查看详情"</p>
</div>
)}
</div>
);
};
const BasicStats: React.FC<{ detail: VariableDetailData }> = ({ detail }) => (
<div className="vdep-stats-grid">
<StatItem label="总行数" value={detail.totalCount} />
<StatItem label="缺失" value={`${detail.missingCount} (${detail.missingRate}%)`} />
<StatItem label="唯一值" value={detail.uniqueCount} />
{detail.descriptive?.mean !== undefined && (
<>
<StatItem label="均值" value={fmt(detail.descriptive.mean)} />
<StatItem label="标准差" value={fmt(detail.descriptive.std)} />
<StatItem label="中位数" value={fmt(detail.descriptive.median)} />
<StatItem label="最小值" value={fmt(detail.descriptive.min)} />
<StatItem label="最大值" value={fmt(detail.descriptive.max)} />
</>
)}
</div>
);
const NumericCharts: React.FC<{ detail: VariableDetailData }> = ({ detail }) => (
<div className="vdep-charts">
{/* 正态性检验结果 */}
{detail.normalityTest && (
<div className={`vdep-normality ${detail.normalityTest.isNormal ? 'vdep-normal-pass' : 'vdep-normal-fail'}`}>
<span className="vdep-normality-icon">
{detail.normalityTest.isNormal ? <TrendingUp size={14} /> : <AlertTriangle size={14} />}
</span>
<span>
{detail.normalityTest.method === 'shapiro_wilk' ? 'Shapiro-Wilk' : 'K-S'} 检验:
W = {detail.normalityTest.statistic}, p = {detail.normalityTest.pValue}
{detail.normalityTest.isNormal ? ' → 符合正态分布' : ' → 非正态分布'}
</span>
</div>
)}
{/* 异常值 */}
{detail.outliers && detail.outliers.count > 0 && (
<div className="vdep-outlier-note">
<AlertTriangle size={14} />
<span>
{detail.outliers.count} ({detail.outliers.rate}%)
[{fmt(detail.outliers.lowerBound)}, {fmt(detail.outliers.upperBound)}]
</span>
</div>
)}
{/* 直方图 (纯 CSS 实现,不依赖 Recharts) */}
{detail.histogram && (
<div className="vdep-histogram">
<div className="vdep-chart-title"><BarChart3 size={14} /> </div>
<HistogramBars histogram={detail.histogram} />
</div>
)}
{/* Q-Q 图 — 占位,后续可集成 @ant-design/charts */}
{detail.qqPlot && (
<div className="vdep-qq-placeholder">
<div className="vdep-chart-title"><Activity size={14} /> Q-Q </div>
<div className="vdep-qq-info">
{detail.qqPlot.observed.length}
@ant-design/charts
</div>
</div>
)}
</div>
);
const CategoricalChart: React.FC<{ detail: VariableDetailData }> = ({ detail }) => {
if (!detail.distribution || detail.distribution.length === 0) return null;
const maxCount = Math.max(...detail.distribution.map((d) => d.count));
return (
<div className="vdep-categorical">
<div className="vdep-chart-title"><BarChart3 size={14} /> </div>
{detail.distribution.map((d) => (
<div key={d.value} className="vdep-cat-bar-row">
<span className="vdep-cat-label" title={d.value}>{d.value}</span>
<div className="vdep-cat-bar-track">
<div
className="vdep-cat-bar-fill"
style={{ width: `${(d.count / maxCount) * 100}%` }}
/>
</div>
<span className="vdep-cat-count">{d.count} ({d.percentage}%)</span>
</div>
))}
</div>
);
};
const HistogramBars: React.FC<{ histogram: { counts: number[]; edges: number[] } }> = ({
histogram,
}) => {
const maxCount = Math.max(...histogram.counts, 1);
return (
<div className="vdep-hist-bars">
{histogram.counts.map((count, i) => (
<div key={i} className="vdep-hist-col" title={`[${fmt(histogram.edges[i])}, ${fmt(histogram.edges[i + 1])}): ${count}`}>
<div
className="vdep-hist-bar"
style={{ height: `${(count / maxCount) * 100}%` }}
/>
</div>
))}
</div>
);
};
const StatItem: React.FC<{ label: string; value: string | number }> = ({ label, value }) => (
<div className="vdep-stat-item">
<span className="vdep-stat-label">{label}</span>
<span className="vdep-stat-value">{value}</span>
</div>
);
function fmt(v: number | null | undefined): string {
if (v === null || v === undefined) return '-';
return Number.isInteger(v) ? v.toString() : v.toFixed(4);
}
export default VariableDetailPanel;

View File

@@ -0,0 +1,181 @@
/**
* Phase I — 变量字典面板
*
* 表格展示所有变量的推断类型 / 用户确认类型 / PICO 角色。
* 支持下拉框修改类型 + 标签输入。
* 点击行名可展开 VariableDetailPanel。
*/
import React, { useMemo, useState } from 'react';
import { Check, Edit2, Search } from 'lucide-react';
import { useSSAStore } from '../stores/ssaStore';
import type { ColumnType, VariableDictEntry } from '../types';
const typeOptions: { value: ColumnType; label: string; color: string }[] = [
{ value: 'numeric', label: '数值型', color: '#3b82f6' },
{ value: 'categorical', label: '分类型', color: '#8b5cf6' },
{ value: 'datetime', label: '日期型', color: '#f59e0b' },
{ value: 'text', label: '文本型', color: '#6b7280' },
];
function typeColor(t: ColumnType): string {
return typeOptions.find((o) => o.value === t)?.color ?? '#6b7280';
}
export const VariableDictionaryPanel: React.FC = () => {
const { dataContext, setSelectedVariable, updateVariableEntry } = useSSAStore();
const { variableDictionary, selectedVariable } = dataContext;
const [filterText, setFilterText] = useState('');
const filtered = useMemo(() => {
if (!filterText) return variableDictionary;
const lower = filterText.toLowerCase();
return variableDictionary.filter(
(v) =>
v.name.toLowerCase().includes(lower) ||
(v.label && v.label.toLowerCase().includes(lower))
);
}, [variableDictionary, filterText]);
if (variableDictionary.length === 0) {
return (
<div className="vdp-empty">
<p></p>
</div>
);
}
const handleTypeChange = (varName: string, newType: ColumnType) => {
updateVariableEntry(varName, {
confirmedType: newType,
confirmStatus: 'user_confirmed',
});
};
return (
<div className="vdp-container">
<div className="vdp-header">
<span className="vdp-title"></span>
<span className="vdp-count">{variableDictionary.length} </span>
<div className="vdp-search">
<Search size={14} />
<input
type="text"
placeholder="搜索变量..."
value={filterText}
onChange={(e) => setFilterText(e.target.value)}
/>
</div>
</div>
<div className="vdp-table-wrap">
<table className="vdp-table">
<thead>
<tr>
<th></th>
<th></th>
<th></th>
<th></th>
<th></th>
</tr>
</thead>
<tbody>
{filtered.map((v) => (
<VariableRow
key={v.name}
entry={v}
isSelected={v.name === selectedVariable}
onSelect={() => setSelectedVariable(v.name === selectedVariable ? null : v.name)}
onTypeChange={(t) => handleTypeChange(v.name, t)}
onLabelChange={(label) => updateVariableEntry(v.name, { label })}
/>
))}
</tbody>
</table>
</div>
</div>
);
};
interface VariableRowProps {
entry: VariableDictEntry;
isSelected: boolean;
onSelect: () => void;
onTypeChange: (t: ColumnType) => void;
onLabelChange: (label: string) => void;
}
const VariableRow: React.FC<VariableRowProps> = ({
entry,
isSelected,
onSelect,
onTypeChange,
onLabelChange,
}) => {
const [editingLabel, setEditingLabel] = useState(false);
const [labelDraft, setLabelDraft] = useState(entry.label ?? '');
const effectiveType = entry.confirmedType ?? entry.inferredType;
const commitLabel = () => {
setEditingLabel(false);
if (labelDraft !== (entry.label ?? '')) {
onLabelChange(labelDraft);
}
};
return (
<tr className={`vdp-row ${isSelected ? 'vdp-row-selected' : ''} ${entry.isIdLike ? 'vdp-row-dim' : ''}`}>
<td className="vdp-cell-name" onClick={onSelect} title="点击查看详情">
<span className="vdp-var-name">{entry.name}</span>
{entry.picoRole && (
<span className="vdp-pico-badge">{entry.picoRole}</span>
)}
</td>
<td>
<span className="vdp-type-tag" style={{ color: typeColor(entry.inferredType) }}>
{typeOptions.find((o) => o.value === entry.inferredType)?.label}
</span>
</td>
<td>
<select
className="vdp-type-select"
value={effectiveType}
onChange={(e) => onTypeChange(e.target.value as ColumnType)}
>
{typeOptions.map((o) => (
<option key={o.value} value={o.value}>{o.label}</option>
))}
</select>
</td>
<td className="vdp-cell-label">
{editingLabel ? (
<input
className="vdp-label-input"
value={labelDraft}
onChange={(e) => setLabelDraft(e.target.value)}
onBlur={commitLabel}
onKeyDown={(e) => e.key === 'Enter' && commitLabel()}
autoFocus
/>
) : (
<span className="vdp-label-text" onClick={() => setEditingLabel(true)}>
{entry.label || <span className="vdp-placeholder"><Edit2 size={12} /> </span>}
</span>
)}
</td>
<td>
{entry.confirmStatus === 'user_confirmed' ? (
<span className="vdp-status-confirmed"><Check size={12} /> </span>
) : (
<span className="vdp-status-inferred">AI </span>
)}
</td>
</tr>
);
};
export default VariableDictionaryPanel;

View File

@@ -18,3 +18,8 @@ export { DataProfileModal } from './DataProfileModal';
export { WorkflowTimeline } from './WorkflowTimeline';
export { StepProgressCard } from './StepProgressCard';
export { ConclusionReport } from './ConclusionReport';
// Phase I: Session Blackboard + READ 层组件
export { DataContextCard } from './DataContextCard';
export { VariableDictionaryPanel } from './VariableDictionaryPanel';
export { VariableDetailPanel } from './VariableDetailPanel';

View File

@@ -1,3 +1,5 @@
export { useAnalysis } from './useAnalysis';
export { useArtifactParser, parseArtifactMarkers } from './useArtifactParser';
export { useWorkflow } from './useWorkflow';
export { useSSAChat } from './useSSAChat';
export type { ChatMessage, ChatIntentType, IntentMeta, UseSSAChatReturn } from './useSSAChat';

View File

@@ -0,0 +1,362 @@
/**
* useSSAChat — Phase II 统一对话 Hook
*
* 基于 POST /api/v1/ssa/sessions/:id/chat SSE 端点,
* 提供流式对话能力,替换原有 parseIntent -> generateWorkflowPlan 流程。
*
* 能力:
* - SSE 流式消息接收OpenAI Compatible 格式)
* - 意图分类元数据解析intent_classified 事件)
* - H3isGenerating 输入锁
* - 对话历史加载GET /sessions/:id/chat/history
* - 深度思考内容reasoning_content
* - 中断请求
*/
import { useState, useCallback, useRef } from 'react';
import { getAccessToken } from '@/framework/auth/api';
import { useSSAStore } from '../stores/ssaStore';
import type { AskUserEventData, AskUserResponseData } from '../components/AskUserCard';
import type { WorkflowPlan } from '../types';
// ────────────────────────────────────────────
// Types
// ────────────────────────────────────────────
export type ChatIntentType = 'chat' | 'explore' | 'consult' | 'analyze' | 'discuss' | 'feedback';
export interface ChatMessage {
id: string;
role: 'user' | 'assistant';
content: string;
thinking?: string;
intent?: ChatIntentType;
status?: 'complete' | 'generating' | 'error';
createdAt: string;
}
export interface IntentMeta {
intent: ChatIntentType;
confidence: number;
source: 'rules' | 'llm' | 'default' | 'guard';
guardTriggered: boolean;
guardMessage?: string;
}
interface OpenAIChunk {
id?: string;
choices?: Array<{
delta: {
content?: string;
reasoning_content?: string;
};
finish_reason: string | null;
}>;
}
export interface UseSSAChatReturn {
chatMessages: ChatMessage[];
isGenerating: boolean;
currentIntent: ChatIntentType | null;
intentMeta: IntentMeta | null;
thinkingContent: string;
streamingContent: string;
error: string | null;
pendingQuestion: AskUserEventData | null;
pendingPlanConfirm: { workflowId: string } | null;
sendChatMessage: (sessionId: string, content: string, metadata?: Record<string, any>) => Promise<void>;
respondToQuestion: (sessionId: string, response: AskUserResponseData) => Promise<void>;
skipQuestion: (sessionId: string, questionId: string) => Promise<void>;
loadHistory: (sessionId: string) => Promise<void>;
abort: () => void;
clearMessages: () => void;
}
// ────────────────────────────────────────────
// Hook
// ────────────────────────────────────────────
export function useSSAChat(): UseSSAChatReturn {
const [chatMessages, setChatMessages] = useState<ChatMessage[]>([]);
const [isGenerating, setIsGenerating] = useState(false);
const [currentIntent, setCurrentIntent] = useState<ChatIntentType | null>(null);
const [pendingPlanConfirm, setPendingPlanConfirm] = useState<{ workflowId: string } | null>(null);
const [intentMeta, setIntentMeta] = useState<IntentMeta | null>(null);
const [thinkingContent, setThinkingContent] = useState('');
const [streamingContent, setStreamingContent] = useState('');
const [error, setError] = useState<string | null>(null);
const [pendingQuestion, setPendingQuestion] = useState<AskUserEventData | null>(null);
const abortRef = useRef<AbortController | null>(null);
const abort = useCallback(() => {
if (abortRef.current) {
abortRef.current.abort();
abortRef.current = null;
}
setIsGenerating(false);
}, []);
const clearMessages = useCallback(() => {
setChatMessages([]);
setCurrentIntent(null);
setIntentMeta(null);
setThinkingContent('');
setStreamingContent('');
setError(null);
setPendingQuestion(null);
setPendingPlanConfirm(null);
}, []);
/**
* 加载对话历史
*/
const loadHistory = useCallback(async (sessionId: string) => {
try {
const token = getAccessToken();
const resp = await fetch(`/api/v1/ssa/sessions/${sessionId}/chat/history`, {
headers: { Authorization: `Bearer ${token}` },
});
if (!resp.ok) return;
const data = await resp.json();
if (data.messages?.length > 0) {
const loaded: ChatMessage[] = data.messages
.filter((m: any) => m.status !== 'generating')
.map((m: any) => ({
id: m.id,
role: m.role,
content: m.content || '',
thinking: m.thinkingContent,
intent: m.intent,
status: m.status || 'complete',
createdAt: m.createdAt,
}));
setChatMessages(loaded);
}
} catch (err) {
console.warn('[useSSAChat] Failed to load history:', err);
}
}, []);
/**
* 发送消息并接收 SSE 流式响应
*/
const sendChatMessage = useCallback(async (sessionId: string, content: string, metadata?: Record<string, any>) => {
setError(null);
setIsGenerating(true);
setThinkingContent('');
setStreamingContent('');
setCurrentIntent(null);
setIntentMeta(null);
setPendingQuestion(null);
const userMsg: ChatMessage = {
id: crypto.randomUUID(),
role: 'user',
content,
status: 'complete',
createdAt: new Date().toISOString(),
};
const assistantMsgId = crypto.randomUUID();
const assistantPlaceholder: ChatMessage = {
id: assistantMsgId,
role: 'assistant',
content: '',
status: 'generating',
createdAt: new Date().toISOString(),
};
setChatMessages(prev => [...prev, userMsg, assistantPlaceholder]);
abortRef.current = new AbortController();
let fullContent = '';
let fullThinking = '';
try {
const token = getAccessToken();
const response = await fetch(`/api/v1/ssa/sessions/${sessionId}/chat`, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
Accept: 'text/event-stream',
Authorization: `Bearer ${token}`,
},
body: JSON.stringify({ content, ...(metadata ? { metadata } : {}) }),
signal: abortRef.current.signal,
});
if (!response.ok) {
throw new Error(`HTTP ${response.status}: ${response.statusText}`);
}
const reader = response.body?.getReader();
if (!reader) throw new Error('无法获取响应流');
const decoder = new TextDecoder();
let buffer = '';
while (true) {
const { done, value } = await reader.read();
if (done) break;
buffer += decoder.decode(value, { stream: true });
const lines = buffer.split('\n');
buffer = lines.pop() || '';
for (const line of lines) {
if (!line.trim() || line.startsWith(': ')) continue;
if (!line.startsWith('data: ')) continue;
const data = line.slice(6).trim();
if (data === '[DONE]') continue;
try {
const parsed = JSON.parse(data);
// 意图分类元数据
if (parsed.type === 'intent_classified') {
const meta: IntentMeta = {
intent: parsed.intent,
confidence: parsed.confidence,
source: parsed.source,
guardTriggered: parsed.guardTriggered || false,
guardMessage: parsed.guardMessage,
};
setCurrentIntent(meta.intent);
setIntentMeta(meta);
setChatMessages(prev => prev.map(m =>
m.id === assistantMsgId ? { ...m, intent: meta.intent } : m
));
continue;
}
// ask_user 事件Phase III
if (parsed.type === 'ask_user') {
setPendingQuestion(parsed as AskUserEventData);
continue;
}
// analysis_plan 事件Phase IV: 对话驱动分析)
if (parsed.type === 'analysis_plan' && parsed.plan) {
const plan = parsed.plan as WorkflowPlan;
const { addRecord, setActivePane, setWorkspaceOpen } = useSSAStore.getState();
addRecord(content, plan);
setActivePane('sap');
setWorkspaceOpen(true);
continue;
}
// plan_confirmed 事件Phase IV: 用户确认方案后触发执行)
if (parsed.type === 'plan_confirmed' && parsed.workflowId) {
setPendingPlanConfirm({ workflowId: parsed.workflowId });
setPendingQuestion(null);
continue;
}
// 错误事件
if (parsed.type === 'error') {
const errMsg = parsed.message || '处理消息时发生错误';
setError(errMsg);
setChatMessages(prev => prev.map(m =>
m.id === assistantMsgId ? { ...m, content: errMsg, status: 'error' } : m
));
continue;
}
// OpenAI Compatible 流式 chunk
const chunk = parsed as OpenAIChunk;
const delta = chunk.choices?.[0]?.delta;
if (delta) {
if (delta.reasoning_content) {
fullThinking += delta.reasoning_content;
setThinkingContent(fullThinking);
}
if (delta.content) {
fullContent += delta.content;
setStreamingContent(fullContent);
setChatMessages(prev => prev.map(m =>
m.id === assistantMsgId
? { ...m, content: fullContent, thinking: fullThinking || undefined }
: m
));
}
}
} catch {
// 解析失败,跳过
}
}
}
// 流结束,标记完成
setChatMessages(prev => prev.map(m =>
m.id === assistantMsgId
? { ...m, content: fullContent, thinking: fullThinking || undefined, status: 'complete' }
: m
));
} catch (err: any) {
if (err.name === 'AbortError') {
setChatMessages(prev => prev.map(m =>
m.id === assistantMsgId
? { ...m, content: fullContent || '(已中断)', status: 'complete' }
: m
));
} else {
const errMsg = err.message || '请求失败';
setError(errMsg);
setChatMessages(prev => prev.map(m =>
m.id === assistantMsgId ? { ...m, content: errMsg, status: 'error' } : m
));
}
} finally {
setIsGenerating(false);
setStreamingContent('');
setThinkingContent('');
abortRef.current = null;
}
}, []);
/**
* 响应 ask_user 卡片Phase III
*/
const respondToQuestion = useCallback(async (sessionId: string, response: AskUserResponseData) => {
setPendingQuestion(null);
const displayText = response.action === 'select'
? `选择了: ${response.selectedValues?.join(', ')}`
: response.freeText || '(已回复)';
await sendChatMessage(sessionId, displayText, { askUserResponse: response });
}, [sendChatMessage]);
/**
* H1: 跳过 ask_user 卡片
*/
const skipQuestion = useCallback(async (sessionId: string, questionId: string) => {
setPendingQuestion(null);
const skipResponse: AskUserResponseData = {
questionId,
action: 'skip',
};
await sendChatMessage(sessionId, '跳过了此问题', { askUserResponse: skipResponse });
}, [sendChatMessage]);
return {
chatMessages,
isGenerating,
currentIntent,
intentMeta,
thinkingContent,
streamingContent,
error,
pendingQuestion,
pendingPlanConfirm,
sendChatMessage,
respondToQuestion,
skipQuestion,
loadHistory,
abort,
clearMessages,
};
}
export default useSSAChat;

View File

@@ -0,0 +1,120 @@
{
"sessionBlackboard": {
"sessionId": "mock-session-001",
"createdAt": "2026-02-22T10:00:00.000Z",
"updatedAt": "2026-02-22T10:00:05.000Z",
"dataOverview": {
"profile": {
"columns": [
{ "name": "sex", "type": "categorical", "missingCount": 0, "missingRate": 0, "uniqueCount": 2, "totalCount": 311, "topValues": [{"value": "1", "count": 155, "percentage": 49.8}, {"value": "2", "count": 156, "percentage": 50.2}], "totalLevels": 2, "isIdLike": false },
{ "name": "smoke", "type": "categorical", "missingCount": 5, "missingRate": 1.6, "uniqueCount": 2, "totalCount": 311, "topValues": [{"value": "2", "count": 210, "percentage": 67.5}, {"value": "1", "count": 96, "percentage": 30.9}], "totalLevels": 2, "isIdLike": false },
{ "name": "age", "type": "numeric", "missingCount": 3, "missingRate": 1.0, "uniqueCount": 42, "totalCount": 311, "mean": 28.5, "std": 7.2, "median": 27.0, "min": 18, "max": 62, "q1": 24, "q3": 31, "iqr": 7, "outlierCount": 8, "outlierRate": 2.6, "skewness": 1.52, "kurtosis": 3.1, "isIdLike": false },
{ "name": "bmi", "type": "numeric", "missingCount": 4, "missingRate": 1.3, "uniqueCount": 185, "totalCount": 311, "mean": 22.8, "std": 3.5, "median": 22.3, "min": 15.2, "max": 38.1, "q1": 20.3, "q3": 24.8, "iqr": 4.5, "outlierCount": 5, "outlierRate": 1.6, "skewness": 0.78, "kurtosis": 0.95, "isIdLike": false },
{ "name": "mouth_open", "type": "numeric", "missingCount": 0, "missingRate": 0, "uniqueCount": 35, "totalCount": 311, "mean": 3.8, "std": 0.5, "median": 3.8, "min": 2.1, "max": 5.5, "q1": 3.5, "q3": 4.2, "iqr": 0.7, "outlierCount": 3, "outlierRate": 1.0, "skewness": -0.12, "kurtosis": 0.45, "isIdLike": false },
{ "name": "bucal_relax", "type": "numeric", "missingCount": 2, "missingRate": 0.6, "uniqueCount": 38, "totalCount": 311, "mean": 4.3, "std": 0.6, "median": 4.3, "min": 2.5, "max": 6.2, "q1": 3.9, "q3": 4.7, "iqr": 0.8, "outlierCount": 4, "outlierRate": 1.3, "skewness": 0.21, "kurtosis": 0.18, "isIdLike": false },
{ "name": "toot_morph", "type": "categorical", "missingCount": 1, "missingRate": 0.3, "uniqueCount": 4, "totalCount": 311, "topValues": [{"value": "3", "count": 145, "percentage": 46.6}, {"value": "2", "count": 98, "percentage": 31.5}, {"value": "1", "count": 52, "percentage": 16.7}], "totalLevels": 4, "isIdLike": false },
{ "name": "root_number", "type": "categorical", "missingCount": 0, "missingRate": 0, "uniqueCount": 3, "totalCount": 311, "topValues": [{"value": "2", "count": 180, "percentage": 57.9}, {"value": "1", "count": 95, "percentage": 30.5}], "totalLevels": 3, "isIdLike": false },
{ "name": "root_curve", "type": "categorical", "missingCount": 0, "missingRate": 0, "uniqueCount": 3, "totalCount": 311, "topValues": [{"value": "2", "count": 155, "percentage": 49.8}, {"value": "1", "count": 120, "percentage": 38.6}], "totalLevels": 3, "isIdLike": false },
{ "name": "lenspace", "type": "numeric", "missingCount": 1, "missingRate": 0.3, "uniqueCount": 270, "totalCount": 311, "mean": 5.82, "std": 2.45, "median": 5.43, "min": 0.5, "max": 15.8, "q1": 4.1, "q3": 7.2, "iqr": 3.1, "outlierCount": 6, "outlierRate": 1.9, "skewness": 0.65, "kurtosis": 0.32, "isIdLike": false },
{ "name": "denseratio", "type": "numeric", "missingCount": 0, "missingRate": 0, "uniqueCount": 290, "totalCount": 311, "mean": 0.62, "std": 0.15, "median": 0.61, "min": 0.18, "max": 0.98, "q1": 0.52, "q3": 0.72, "iqr": 0.2, "outlierCount": 0, "outlierRate": 0, "skewness": 0.05, "kurtosis": -0.32, "isIdLike": false },
{ "name": "Pglevel", "type": "categorical", "missingCount": 0, "missingRate": 0, "uniqueCount": 3, "totalCount": 311, "topValues": [{"value": "1", "count": 140, "percentage": 45.0}, {"value": "2", "count": 105, "percentage": 33.8}], "totalLevels": 3, "isIdLike": false },
{ "name": "Pgverti", "type": "categorical", "missingCount": 0, "missingRate": 0, "uniqueCount": 3, "totalCount": 311, "topValues": [{"value": "1", "count": 148, "percentage": 47.6}, {"value": "2", "count": 112, "percentage": 36.0}], "totalLevels": 3, "isIdLike": false },
{ "name": "Winter", "type": "categorical", "missingCount": 0, "missingRate": 0, "uniqueCount": 4, "totalCount": 311, "topValues": [{"value": "1", "count": 130, "percentage": 41.8}, {"value": "2", "count": 95, "percentage": 30.5}], "totalLevels": 4, "isIdLike": false },
{ "name": "presyp", "type": "categorical", "missingCount": 0, "missingRate": 0, "uniqueCount": 3, "totalCount": 311, "topValues": [{"value": "1", "count": 160, "percentage": 51.4}, {"value": "2", "count": 100, "percentage": 32.2}], "totalLevels": 3, "isIdLike": false },
{ "name": "flap", "type": "categorical", "missingCount": 0, "missingRate": 0, "uniqueCount": 3, "totalCount": 311, "topValues": [{"value": "2", "count": 170, "percentage": 54.7}, {"value": "1", "count": 90, "percentage": 28.9}], "totalLevels": 3, "isIdLike": false },
{ "name": "operation", "type": "categorical", "missingCount": 0, "missingRate": 0, "uniqueCount": 3, "totalCount": 311, "topValues": [{"value": "2", "count": 140, "percentage": 45.0}, {"value": "1", "count": 120, "percentage": 38.6}], "totalLevels": 3, "isIdLike": false },
{ "name": "time", "type": "numeric", "missingCount": 0, "missingRate": 0, "uniqueCount": 250, "totalCount": 311, "mean": 18.5, "std": 12.3, "median": 15.5, "min": 2.0, "max": 85.0, "q1": 9.5, "q3": 24.0, "iqr": 14.5, "outlierCount": 0, "outlierRate": 0, "skewness": 1.85, "kurtosis": 4.2, "isIdLike": false },
{ "name": "surgage", "type": "categorical", "missingCount": 0, "missingRate": 0, "uniqueCount": 3, "totalCount": 311, "topValues": [{"value": "2", "count": 180, "percentage": 57.9}, {"value": "1", "count": 80, "percentage": 25.7}], "totalLevels": 3, "isIdLike": false },
{ "name": "Yqol", "type": "numeric", "missingCount": 0, "missingRate": 0, "uniqueCount": 5, "totalCount": 311, "mean": 0.45, "std": 0.72, "median": 0, "min": 0, "max": 3, "q1": 0, "q3": 1, "iqr": 1, "outlierCount": 0, "outlierRate": 0, "skewness": 1.35, "kurtosis": 1.1, "isIdLike": false },
{ "name": "times", "type": "numeric", "missingCount": 0, "missingRate": 0, "uniqueCount": 280, "totalCount": 311, "mean": 27.8, "std": 18.5, "median": 23.3, "min": 3.0, "max": 127.5, "q1": 14.2, "q3": 36.0, "iqr": 21.8, "outlierCount": 0, "outlierRate": 0, "skewness": 1.42, "kurtosis": 2.8, "isIdLike": false }
],
"summary": {
"totalRows": 311,
"totalColumns": 21,
"numericColumns": 8,
"categoricalColumns": 13,
"datetimeColumns": 0,
"textColumns": 0,
"overallMissingRate": 0.24,
"totalMissingCells": 16
}
},
"completeCaseCount": 296,
"normalityTests": [
{ "variable": "age", "method": "shapiro_wilk", "statistic": 0.912, "pValue": 0.001, "isNormal": false },
{ "variable": "bmi", "method": "shapiro_wilk", "statistic": 0.965, "pValue": 0.003, "isNormal": false },
{ "variable": "mouth_open", "method": "shapiro_wilk", "statistic": 0.988, "pValue": 0.12, "isNormal": true },
{ "variable": "bucal_relax", "method": "shapiro_wilk", "statistic": 0.971, "pValue": 0.008, "isNormal": false },
{ "variable": "lenspace", "method": "shapiro_wilk", "statistic": 0.945, "pValue": 0.001, "isNormal": false },
{ "variable": "denseratio", "method": "shapiro_wilk", "statistic": 0.992, "pValue": 0.35, "isNormal": true },
{ "variable": "time", "method": "shapiro_wilk", "statistic": 0.878, "pValue": 0.001, "isNormal": false },
{ "variable": "times", "method": "shapiro_wilk", "statistic": 0.885, "pValue": 0.001, "isNormal": false }
],
"generatedAt": "2026-02-22T10:00:03.000Z"
},
"variableDictionary": [
{ "name": "sex", "inferredType": "categorical", "confirmedType": null, "label": null, "picoRole": null, "isIdLike": false, "confirmStatus": "ai_inferred" },
{ "name": "smoke", "inferredType": "categorical", "confirmedType": null, "label": null, "picoRole": null, "isIdLike": false, "confirmStatus": "ai_inferred" },
{ "name": "age", "inferredType": "numeric", "confirmedType": null, "label": null, "picoRole": null, "isIdLike": false, "confirmStatus": "ai_inferred" },
{ "name": "bmi", "inferredType": "numeric", "confirmedType": null, "label": null, "picoRole": null, "isIdLike": false, "confirmStatus": "ai_inferred" },
{ "name": "mouth_open", "inferredType": "numeric", "confirmedType": null, "label": null, "picoRole": null, "isIdLike": false, "confirmStatus": "ai_inferred" },
{ "name": "bucal_relax", "inferredType": "numeric", "confirmedType": null, "label": null, "picoRole": null, "isIdLike": false, "confirmStatus": "ai_inferred" },
{ "name": "toot_morph", "inferredType": "categorical", "confirmedType": null, "label": null, "picoRole": null, "isIdLike": false, "confirmStatus": "ai_inferred" },
{ "name": "root_number", "inferredType": "categorical", "confirmedType": null, "label": null, "picoRole": null, "isIdLike": false, "confirmStatus": "ai_inferred" },
{ "name": "root_curve", "inferredType": "categorical", "confirmedType": null, "label": null, "picoRole": null, "isIdLike": false, "confirmStatus": "ai_inferred" },
{ "name": "lenspace", "inferredType": "numeric", "confirmedType": null, "label": null, "picoRole": null, "isIdLike": false, "confirmStatus": "ai_inferred" },
{ "name": "denseratio", "inferredType": "numeric", "confirmedType": null, "label": null, "picoRole": null, "isIdLike": false, "confirmStatus": "ai_inferred" },
{ "name": "Pglevel", "inferredType": "categorical", "confirmedType": null, "label": null, "picoRole": null, "isIdLike": false, "confirmStatus": "ai_inferred" },
{ "name": "Pgverti", "inferredType": "categorical", "confirmedType": null, "label": null, "picoRole": null, "isIdLike": false, "confirmStatus": "ai_inferred" },
{ "name": "Winter", "inferredType": "categorical", "confirmedType": null, "label": null, "picoRole": null, "isIdLike": false, "confirmStatus": "ai_inferred" },
{ "name": "presyp", "inferredType": "categorical", "confirmedType": null, "label": null, "picoRole": null, "isIdLike": false, "confirmStatus": "ai_inferred" },
{ "name": "flap", "inferredType": "categorical", "confirmedType": null, "label": null, "picoRole": null, "isIdLike": false, "confirmStatus": "ai_inferred" },
{ "name": "operation", "inferredType": "categorical", "confirmedType": null, "label": null, "picoRole": "I", "isIdLike": false, "confirmStatus": "ai_inferred" },
{ "name": "time", "inferredType": "numeric", "confirmedType": null, "label": null, "picoRole": "O", "isIdLike": false, "confirmStatus": "ai_inferred" },
{ "name": "surgage", "inferredType": "categorical", "confirmedType": null, "label": null, "picoRole": null, "isIdLike": false, "confirmStatus": "ai_inferred" },
{ "name": "Yqol", "inferredType": "numeric", "confirmedType": null, "label": null, "picoRole": "O", "isIdLike": false, "confirmStatus": "ai_inferred" },
{ "name": "times", "inferredType": "numeric", "confirmedType": null, "label": null, "picoRole": null, "isIdLike": false, "confirmStatus": "ai_inferred" }
],
"picoInference": {
"population": "311 例行口腔智齿拔除术的患者",
"intervention": "不同手术方式 (operation)",
"comparison": null,
"outcome": "手术时间 (time)、术后生活质量 (Yqol)",
"confidence": "medium",
"status": "ai_inferred"
},
"qperTrace": []
},
"fiveSectionReport": {
"basicInfo": {
"title": "数据基本特征检测",
"content": "数据集共有311个病例21个变量。"
},
"missingData": {
"title": "数据缺失状况检测",
"content": "所有变量中有4个变量存在缺失数据变量名如下smoke age bmi bucal_relax toot_morph。数据完整的病例共有296个。",
"varsWithMissing": ["smoke", "age", "bmi", "bucal_relax", "toot_morph", "lenspace"],
"completeCaseCount": 296
},
"dataTypes": {
"title": "数据类型检测",
"content": "所有变量均为数值型其中有13个变量为分类变量请确认变量类型是否正确这对统计分析方法的选择非常重要。分类变量的变量名如下sex smoke toot_morph root_number root_curve 等。",
"categoricalVars": ["sex", "smoke", "toot_morph", "root_number", "root_curve", "Pglevel", "Pgverti", "Winter", "presyp", "flap", "operation", "surgage", "Yqol"],
"numericVars": ["age", "bmi", "mouth_open", "bucal_relax", "lenspace", "denseratio", "time", "times"],
"needsConfirmation": true
},
"outliers": {
"title": "数据异常值检测",
"content": "所有变量中有6个变量存在异常值异常值判定是应用四分位数±1.5倍四分位间距在此之外判定为异常值。变量名如下age bmi mouth_open bucal_relax lenspace 等。异常值不一定是错误值,请查看原始数据并结合实际情况进行处理,不宜直接修改或删除。",
"method": "IQR (Q1-1.5*IQR, Q3+1.5*IQR)",
"varsWithOutliers": ["age", "bmi", "mouth_open", "bucal_relax", "lenspace", "denseratio"]
},
"normality": {
"title": "正态分布检测",
"content": "所有连续变量中有6个变量为非正态分布使用R函数shapiro.test进行判定当p<0.05认为非正态分布。变量名如下age bmi bucal_relax lenspace time 等。统计学家更推荐直方图P-P图、Q-Q图进行判定本判定的结果供您参考。",
"method": "Shapiro-Wilk (p < 0.05 判定为非正态)",
"nonNormalVars": ["age", "bmi", "bucal_relax", "lenspace", "time", "times"],
"normalVars": ["mouth_open", "denseratio"]
}
}
}

View File

@@ -14,6 +14,10 @@ import type {
WorkflowPlan,
WorkflowStepResult,
ConclusionReport,
DataContext,
VariableDictEntry,
FiveSectionReport,
VariableDetailData,
} from '../types';
type ArtifactPane = 'empty' | 'sap' | 'execution' | 'result';
@@ -99,6 +103,18 @@ interface SSAState {
setDataProfileLoading: (loading: boolean) => void;
setDataProfileModalVisible: (visible: boolean) => void;
setWorkflowPlanLoading: (loading: boolean) => void;
// Phase I: Data Context (Session Blackboard)
dataContext: DataContext;
setDataContextFromBlackboard: (payload: {
blackboard: any;
report: FiveSectionReport | null;
}) => void;
setDataContextLoading: (loading: boolean) => void;
updateVariableEntry: (varName: string, patch: Partial<VariableDictEntry>) => void;
setSelectedVariable: (varName: string | null) => void;
setVariableDetail: (detail: VariableDetailData | null) => void;
setVariableDetailLoading: (loading: boolean) => void;
}
const initialState = {
@@ -120,6 +136,16 @@ const initialState = {
dataProfileLoading: false,
dataProfileModalVisible: false,
workflowPlanLoading: false,
dataContext: {
dataOverview: null,
variableDictionary: [],
fiveSectionReport: null,
picoInference: null,
loading: false,
selectedVariable: null,
variableDetail: null,
variableDetailLoading: false,
} as DataContext,
};
export const useSSAStore = create<SSAState>((set) => ({
@@ -212,6 +238,57 @@ export const useSSAStore = create<SSAState>((set) => ({
setDataProfileLoading: (loading) => set({ dataProfileLoading: loading }),
setDataProfileModalVisible: (visible) => set({ dataProfileModalVisible: visible }),
setWorkflowPlanLoading: (loading) => set({ workflowPlanLoading: loading }),
// Phase I: Data Context
setDataContextFromBlackboard: ({ blackboard, report }) =>
set((state) => ({
dataContext: {
...state.dataContext,
dataOverview: blackboard.dataOverview ?? null,
variableDictionary: blackboard.variableDictionary ?? [],
fiveSectionReport: report ?? null,
picoInference: blackboard.picoInference ?? null,
loading: false,
},
})),
setDataContextLoading: (loading) =>
set((state) => ({
dataContext: { ...state.dataContext, loading },
})),
updateVariableEntry: (varName, patch) =>
set((state) => ({
dataContext: {
...state.dataContext,
variableDictionary: state.dataContext.variableDictionary.map((v) =>
v.name === varName ? { ...v, ...patch } : v
),
},
})),
setSelectedVariable: (varName) =>
set((state) => ({
dataContext: {
...state.dataContext,
selectedVariable: varName,
variableDetail: varName === null ? null : state.dataContext.variableDetail,
},
})),
setVariableDetail: (detail) =>
set((state) => ({
dataContext: {
...state.dataContext,
variableDetail: detail,
variableDetailLoading: false,
},
})),
setVariableDetailLoading: (loading) =>
set((state) => ({
dataContext: { ...state.dataContext, variableDetailLoading: loading },
})),
}));
// ==================== Derived selectors ====================

View File

@@ -1608,6 +1608,70 @@
font-weight: 700;
}
/* ─── 横向滚动(宽表 >6列 ─── */
.sci-table-wrapper.scrollable {
overflow-x: auto;
-webkit-overflow-scrolling: touch;
}
.sci-table-wrapper.scrollable .sci-table {
min-width: 700px;
}
/* ─── rowspan 合并行 ─── */
.sci-table .td-rowspan {
vertical-align: top;
font-weight: 600;
background-color: #f8fafc;
border-right: 1px solid #e2e8f0;
}
/* ─── P 值标星 ─── */
.sci-table .p-significant {
color: #dc2626;
font-weight: 700;
}
.sci-table .p-star {
color: #dc2626;
font-weight: 700;
margin-left: 2px;
}
.sci-table .col-p-value {
text-align: right;
min-width: 80px;
}
/* ─── 分类变量行标题 ─── */
.sci-table .row-category-header td:first-child {
font-weight: 600;
color: #1e293b;
}
/* ─── 基线特征表增强 ─── */
.result-table-section.baseline-table .sci-table th {
font-size: 13px;
white-space: nowrap;
}
.result-table-section.baseline-table .sci-table td {
font-size: 13px;
white-space: nowrap;
}
.result-table-section.baseline-table .sci-table-wrapper {
overflow-x: auto;
max-height: 600px;
overflow-y: auto;
}
.result-table-section.baseline-table .sci-table thead {
position: sticky;
top: 0;
z-index: 1;
}
.result-chart-section {
margin-top: 24px;
}

View File

@@ -289,3 +289,385 @@
width: 100%;
}
}
/* ============================================ */
/* Phase I: DataContextCard 五段式报告卡片 */
/* ============================================ */
.data-context-card {
background: #ffffff;
border: 1px solid #e2e8f0;
border-radius: 12px;
overflow: hidden;
margin-bottom: 12px;
box-shadow: 0 1px 3px rgba(0, 0, 0, 0.04);
}
.dcc-header {
display: flex;
align-items: center;
gap: 8px;
padding: 14px 16px;
background: linear-gradient(135deg, #f0f9ff, #eff6ff);
border-bottom: 1px solid #e2e8f0;
font-weight: 600;
font-size: 14px;
color: #1e293b;
}
.dcc-icon { color: #3b82f6; }
.dcc-body { padding: 4px 0; }
.dcc-section {
border-left: 3px solid transparent;
margin: 2px 12px;
border-radius: 4px;
}
.dcc-section-header {
display: flex;
align-items: center;
gap: 8px;
padding: 8px 10px;
cursor: pointer;
border-radius: 6px;
transition: background 0.15s;
}
.dcc-section-header:hover { background: #f8fafc; }
.dcc-section-title { flex: 1; font-size: 13px; font-weight: 500; color: #334155; }
.dcc-section-toggle { color: #94a3b8; }
.dcc-section-content {
padding: 2px 10px 10px 34px;
font-size: 13px;
color: #475569;
line-height: 1.6;
}
.dcc-section-content p { margin: 0; }
.dcc-var-tags {
display: flex;
flex-wrap: wrap;
gap: 4px;
margin-top: 6px;
}
.dcc-tag {
display: inline-block;
padding: 2px 8px;
border-radius: 4px;
font-size: 11px;
font-family: 'SF Mono', 'Fira Code', monospace;
}
.dcc-tag-warning { background: #fef3c7; color: #92400e; }
.dcc-tag-danger { background: #fee2e2; color: #991b1b; }
.dcc-tag-success { background: #dcfce7; color: #166534; }
.dcc-note {
display: flex;
align-items: center;
gap: 6px;
margin-top: 6px;
padding: 6px 10px;
background: #fffbeb;
border-radius: 6px;
font-size: 12px;
color: #92400e;
}
/* ============================================ */
/* Phase I: VariableDictionaryPanel 变量字典 */
/* ============================================ */
.vdp-container {
background: #ffffff;
border: 1px solid #e2e8f0;
border-radius: 12px;
overflow: hidden;
}
.vdp-header {
display: flex;
align-items: center;
gap: 10px;
padding: 12px 16px;
border-bottom: 1px solid #f1f5f9;
}
.vdp-title { font-weight: 600; font-size: 14px; color: #1e293b; }
.vdp-count { font-size: 12px; color: #94a3b8; }
.vdp-search {
margin-left: auto;
display: flex;
align-items: center;
gap: 4px;
padding: 4px 10px;
background: #f8fafc;
border: 1px solid #e2e8f0;
border-radius: 6px;
color: #94a3b8;
}
.vdp-search input {
border: none;
background: transparent;
outline: none;
font-size: 12px;
width: 120px;
color: #334155;
}
.vdp-empty {
padding: 32px;
text-align: center;
color: #94a3b8;
font-size: 13px;
}
.vdp-table-wrap { overflow-x: auto; max-height: 400px; overflow-y: auto; }
.vdp-table {
width: 100%;
border-collapse: collapse;
font-size: 13px;
}
.vdp-table thead th {
position: sticky;
top: 0;
background: #f8fafc;
padding: 8px 12px;
text-align: left;
font-weight: 500;
color: #64748b;
border-bottom: 1px solid #e2e8f0;
white-space: nowrap;
z-index: 1;
}
.vdp-table tbody td {
padding: 6px 12px;
border-bottom: 1px solid #f1f5f9;
vertical-align: middle;
}
.vdp-row { transition: background 0.12s; }
.vdp-row:hover { background: #f8fafc; }
.vdp-row-selected { background: #eff6ff !important; }
.vdp-row-dim { opacity: 0.5; }
.vdp-cell-name {
cursor: pointer;
display: flex;
align-items: center;
gap: 6px;
}
.vdp-var-name {
font-family: 'SF Mono', 'Fira Code', monospace;
font-weight: 500;
color: #1e293b;
}
.vdp-pico-badge {
display: inline-flex;
align-items: center;
justify-content: center;
width: 18px;
height: 18px;
border-radius: 4px;
background: #3b82f6;
color: #fff;
font-size: 10px;
font-weight: 700;
}
.vdp-type-tag { font-size: 12px; font-weight: 500; }
.vdp-type-select {
padding: 2px 6px;
border: 1px solid #e2e8f0;
border-radius: 4px;
font-size: 12px;
background: #fff;
color: #334155;
cursor: pointer;
}
.vdp-type-select:focus { border-color: #3b82f6; outline: none; }
.vdp-cell-label { min-width: 100px; }
.vdp-label-input {
width: 100%;
padding: 2px 6px;
border: 1px solid #3b82f6;
border-radius: 4px;
font-size: 12px;
outline: none;
}
.vdp-label-text { cursor: pointer; color: #475569; }
.vdp-placeholder {
display: inline-flex;
align-items: center;
gap: 4px;
color: #cbd5e1;
font-size: 12px;
}
.vdp-status-confirmed {
display: inline-flex;
align-items: center;
gap: 4px;
color: #059669;
font-size: 12px;
}
.vdp-status-inferred { color: #94a3b8; font-size: 12px; }
/* ============================================ */
/* Phase I: VariableDetailPanel 单变量详情 */
/* ============================================ */
.vdep-panel {
background: #ffffff;
border: 1px solid #e2e8f0;
border-radius: 12px;
overflow: hidden;
margin-top: 8px;
}
.vdep-header {
display: flex;
align-items: center;
justify-content: space-between;
padding: 10px 16px;
background: #f8fafc;
border-bottom: 1px solid #e2e8f0;
}
.vdep-header-left {
display: flex;
align-items: center;
gap: 8px;
color: #1e293b;
}
.vdep-title { font-weight: 600; font-size: 14px; font-family: 'SF Mono', monospace; }
.vdep-type-badge {
padding: 2px 8px;
border-radius: 4px;
background: #eff6ff;
color: #3b82f6;
font-size: 11px;
font-weight: 500;
}
.vdep-close {
border: none;
background: none;
cursor: pointer;
color: #94a3b8;
padding: 4px;
border-radius: 4px;
}
.vdep-close:hover { background: #f1f5f9; color: #475569; }
.vdep-loading, .vdep-empty {
padding: 32px;
text-align: center;
color: #94a3b8;
font-size: 13px;
}
.vdep-body { padding: 12px 16px; }
.vdep-stats-grid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(120px, 1fr));
gap: 8px;
margin-bottom: 12px;
}
.vdep-stat-item {
padding: 8px 10px;
background: #f8fafc;
border-radius: 6px;
}
.vdep-stat-label { display: block; font-size: 11px; color: #94a3b8; margin-bottom: 2px; }
.vdep-stat-value { display: block; font-size: 13px; color: #1e293b; font-weight: 500; }
.vdep-charts { display: flex; flex-direction: column; gap: 12px; }
.vdep-chart-title {
display: flex;
align-items: center;
gap: 6px;
font-size: 13px;
font-weight: 500;
color: #475569;
margin-bottom: 6px;
}
.vdep-normality {
display: flex;
align-items: center;
gap: 8px;
padding: 8px 12px;
border-radius: 6px;
font-size: 12px;
}
.vdep-normal-pass { background: #dcfce7; color: #166534; }
.vdep-normal-fail { background: #fee2e2; color: #991b1b; }
.vdep-outlier-note {
display: flex;
align-items: center;
gap: 6px;
padding: 6px 12px;
background: #fffbeb;
border-radius: 6px;
font-size: 12px;
color: #92400e;
}
.vdep-histogram { margin-top: 4px; }
.vdep-hist-bars {
display: flex;
align-items: flex-end;
height: 120px;
gap: 1px;
padding: 4px 0;
border-bottom: 1px solid #e2e8f0;
}
.vdep-hist-col {
flex: 1;
display: flex;
align-items: flex-end;
height: 100%;
}
.vdep-hist-bar {
width: 100%;
background: #3b82f6;
border-radius: 2px 2px 0 0;
min-height: 1px;
transition: height 0.3s ease;
}
.vdep-hist-col:hover .vdep-hist-bar { background: #2563eb; }
.vdep-qq-placeholder {
padding: 12px;
background: #f8fafc;
border-radius: 6px;
text-align: center;
}
.vdep-qq-info { font-size: 12px; color: #94a3b8; margin-top: 4px; }
.vdep-categorical { margin-top: 4px; }
.vdep-cat-bar-row {
display: flex;
align-items: center;
gap: 8px;
padding: 3px 0;
}
.vdep-cat-label {
width: 80px;
font-size: 12px;
color: #475569;
text-align: right;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.vdep-cat-bar-track {
flex: 1;
height: 16px;
background: #f1f5f9;
border-radius: 3px;
overflow: hidden;
}
.vdep-cat-bar-fill {
height: 100%;
background: #8b5cf6;
border-radius: 3px;
transition: width 0.3s ease;
}
.vdep-cat-count {
width: 80px;
font-size: 11px;
color: #94a3b8;
white-space: nowrap;
}

View File

@@ -331,6 +331,12 @@ export interface ReportBlock {
data?: string; // image (base64 data URI)
alt?: string; // image
items?: { key: string; value: string }[]; // key_value
metadata?: { // table 元数据ST_BASELINE_TABLE 等复合工具使用)
is_baseline_table?: boolean;
group_var?: string;
has_p_values?: boolean;
[key: string]: unknown;
};
}
// ============================================
@@ -394,3 +400,136 @@ export interface SSEMessage {
conclusion?: ConclusionReport;
error?: string;
}
// ============================================
// Phase I: Session Blackboard 前端类型
// ============================================
export type ColumnType = 'numeric' | 'categorical' | 'datetime' | 'text';
export type ConfirmStatus = 'ai_inferred' | 'user_confirmed';
export type PicoRole = 'P' | 'I' | 'C' | 'O';
export interface NormalityTestResult {
variable: string;
method: 'shapiro_wilk' | 'kolmogorov_smirnov';
statistic: number;
pValue: number;
isNormal: boolean;
}
export interface VariableDictEntry {
name: string;
inferredType: ColumnType;
confirmedType: ColumnType | null;
label: string | null;
picoRole: PicoRole | null;
isIdLike: boolean;
confirmStatus: ConfirmStatus;
}
export interface DataOverviewProfile {
columns: DataOverviewColumn[];
summary: {
totalRows: number;
totalColumns: number;
numericColumns: number;
categoricalColumns: number;
datetimeColumns: number;
textColumns: number;
overallMissingRate: number;
totalMissingCells: number;
};
}
export interface DataOverviewColumn {
name: string;
type: ColumnType;
missingCount: number;
missingRate: number;
uniqueCount: number;
totalCount: number;
isIdLike?: boolean;
mean?: number;
std?: number;
median?: number;
min?: number;
max?: number;
q1?: number;
q3?: number;
outlierCount?: number;
outlierRate?: number;
skewness?: number;
kurtosis?: number;
topValues?: Array<{ value: string; count: number; percentage: number }>;
totalLevels?: number;
}
export interface FiveSectionReport {
basicInfo: { title: string; content: string };
missingData: {
title: string;
content: string;
varsWithMissing?: string[];
completeCaseCount?: number;
};
dataTypes: {
title: string;
content: string;
categoricalVars?: string[];
numericVars?: string[];
needsConfirmation?: boolean;
};
outliers: {
title: string;
content: string;
method?: string;
varsWithOutliers?: string[];
};
normality: {
title: string;
content: string;
method?: string;
nonNormalVars?: string[];
normalVars?: string[];
};
}
export interface PicoInference {
population: string | null;
intervention: string | null;
comparison: string | null;
outcome: string | null;
confidence: 'high' | 'medium' | 'low';
status: ConfirmStatus;
}
export interface DataContext {
dataOverview: {
profile: DataOverviewProfile;
normalityTests: NormalityTestResult[];
completeCaseCount: number;
generatedAt: string;
} | null;
variableDictionary: VariableDictEntry[];
fiveSectionReport: FiveSectionReport | null;
picoInference: PicoInference | null;
loading: boolean;
selectedVariable: string | null;
variableDetail: VariableDetailData | null;
variableDetailLoading: boolean;
}
export interface VariableDetailData {
variable: string;
type: ColumnType;
totalCount: number;
missingCount: number;
missingRate: number;
uniqueCount: number;
descriptive?: Record<string, number | null>;
outliers?: { count: number; rate: number; lowerBound: number; upperBound: number };
histogram?: { counts: number[]; edges: number[] };
normalityTest?: { method: string; statistic: number; pValue: number; isNormal: boolean } | null;
qqPlot?: { theoretical: number[]; observed: number[] };
distribution?: Array<{ value: string; count: number; percentage: number }>;
}