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:
218
frontend-v2/src/modules/ssa/components/AskUserCard.tsx
Normal file
218
frontend-v2/src/modules/ssa/components/AskUserCard.tsx
Normal 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;
|
||||
140
frontend-v2/src/modules/ssa/components/DataContextCard.tsx
Normal file
140
frontend-v2/src/modules/ssa/components/DataContextCard.tsx
Normal 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;
|
||||
@@ -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;
|
||||
|
||||
@@ -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)
|
||||
* - H3:streaming 期间锁定输入
|
||||
*
|
||||
* 保留不变: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;
|
||||
|
||||
183
frontend-v2/src/modules/ssa/components/VariableDetailPanel.tsx
Normal file
183
frontend-v2/src/modules/ssa/components/VariableDetailPanel.tsx
Normal 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;
|
||||
@@ -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;
|
||||
@@ -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';
|
||||
|
||||
@@ -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';
|
||||
|
||||
362
frontend-v2/src/modules/ssa/hooks/useSSAChat.ts
Normal file
362
frontend-v2/src/modules/ssa/hooks/useSSAChat.ts
Normal file
@@ -0,0 +1,362 @@
|
||||
/**
|
||||
* useSSAChat — Phase II 统一对话 Hook
|
||||
*
|
||||
* 基于 POST /api/v1/ssa/sessions/:id/chat SSE 端点,
|
||||
* 提供流式对话能力,替换原有 parseIntent -> generateWorkflowPlan 流程。
|
||||
*
|
||||
* 能力:
|
||||
* - SSE 流式消息接收(OpenAI Compatible 格式)
|
||||
* - 意图分类元数据解析(intent_classified 事件)
|
||||
* - H3:isGenerating 输入锁
|
||||
* - 对话历史加载(GET /sessions/:id/chat/history)
|
||||
* - 深度思考内容(reasoning_content)
|
||||
* - 中断请求
|
||||
*/
|
||||
|
||||
import { useState, useCallback, useRef } from 'react';
|
||||
import { getAccessToken } 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;
|
||||
120
frontend-v2/src/modules/ssa/mocks/mock-data-context.json
Normal file
120
frontend-v2/src/modules/ssa/mocks/mock-data-context.json
Normal 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"]
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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 ====================
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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 }>;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user