fix(ssa): Fix 7 integration bugs and refactor frontend unified state management

Bug fixes:
- Fix garbled error messages in chat (TypeWriter rendering issue)
- Fix R engine NA crash in descriptive.R (defensive isTRUE/is.na checks)
- Fix intent misclassification for statistical significance queries
- Fix step 2 results not displayed (accept warning status alongside success)
- Fix incomplete R code download (only step 1 included)
- Fix multi-task state confusion (clicking old card shows new results)
- Add R engine and backend parameter logging for debugging

Refactor - Unified Record Architecture:
- Replace 12 global singleton fields with AnalysisRecord as single source of truth
- Remove isWorkflowMode branching across all components
- One Analysis = One Record = N Steps paradigm
- selectRecord only sets currentRecordId, all rendering derives from currentRecord
- Fix cross-hook-instance issue: executeWorkflow fallback to store currentRecordId

Updated files: ssaStore, useWorkflow, useAnalysis, SSAChatPane, SSAWorkspacePane,
SSACodeModal, WorkflowTimeline, QueryService, WorkflowExecutorService, descriptive.R

Tested: Manual integration test passed - multi-task switching, R code completeness
Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
2026-02-21 22:58:59 +08:00
parent 371e1c069c
commit 11676f2840
17 changed files with 1573 additions and 1829 deletions

View File

@@ -79,7 +79,15 @@ const SSA_INTENT_PROMPT = `你是一个临床统计分析意图理解引擎。
{"goal":"regression","outcome_var":"Death","outcome_type":"binary","predictor_vars":["Age","BMI","Smoking","Stage"],"predictor_types":["continuous","continuous","binary","categorical"],"grouping_var":null,"design":"independent","confidence":0.8,"reasoning":"用户想分析影响死亡率的因素Death是二分类结局其余变量作为预测因素纳入logistic回归"}
\`\`\`
### 示例 4模糊表达 — 需要追问
### 示例 4统计学意义/检验
用户: "Yqol和bmi是否有统计学意义"
数据画像中有: Yqol [numeric], bmi [numeric], sex [categorical], age [numeric]
输出:
\`\`\`json
{"goal":"correlation","outcome_var":"Yqol","outcome_type":"continuous","predictor_vars":["bmi"],"predictor_types":["continuous"],"grouping_var":null,"design":"independent","confidence":0.85,"reasoning":"用户想了解Yqol和bmi之间是否存在统计学显著关系两者都是连续变量适合相关分析或回归分析"}
\`\`\`
### 示例 5模糊表达 — 需要追问
用户: "帮我分析一下这份数据"
数据画像中有: 10个变量
输出:
@@ -87,7 +95,7 @@ const SSA_INTENT_PROMPT = `你是一个临床统计分析意图理解引擎。
{"goal":"descriptive","outcome_var":null,"outcome_type":null,"predictor_vars":[],"predictor_types":[],"grouping_var":null,"design":"independent","confidence":0.35,"reasoning":"用户没有指定任何分析目标和变量,只能先做描述性统计,建议追问具体分析目的"}
\`\`\`
### 示例 5:队列研究
### 示例 6:队列研究
用户: "我想做一个完整的队列研究分析,看看新药对预后的影响"
数据画像中有: Drug [categorical, 2个水平], Outcome [categorical, 2个水平: 0/1], Age [numeric], Gender [categorical], BMI [numeric], Comorbidity [categorical]
输出:
@@ -127,7 +135,7 @@ async function main() {
content: SSA_INTENT_PROMPT,
model_config: { model: 'deepseek-v3', temperature: 0.3, maxTokens: 2048 },
status: 'ACTIVE',
changelog: `Phase Q v1.0: 5 组 Few-Shot + Confidence Rubric 客观化`,
changelog: `Phase Q v1.1: 6 组 Few-Shot (增加统计学意义示例) + Confidence Rubric 客观化`,
created_by: 'system-seed',
}
});

View File

@@ -411,7 +411,9 @@ export class QueryService {
if (query.includes('比较') || query.includes('差异') || query.includes('不同') || query.includes('有没有效')) {
goal = 'comparison';
} else if (query.includes('相关') || query.includes('关系') || query.includes('关联')) {
} else if (query.includes('相关') || query.includes('关系') || query.includes('关联')
|| query.includes('统计学意义') || query.includes('显著') || query.includes('检验')
|| query.includes('p值') || query.includes('有无差别')) {
goal = 'correlation';
} else if (query.includes('影响') || query.includes('因素') || query.includes('预测') || query.includes('回归')) {
goal = 'regression';

View File

@@ -395,6 +395,11 @@ export class WorkflowExecutorService extends EventEmitter {
});
// 调用 R 服务
logger.info('[SSA:Executor] Calling R service', {
step: step.stepOrder,
toolCode: step.toolCode,
inputParams: step.inputParams,
});
const response = await this.rClient.post(`/api/v1/skills/${step.toolCode}`, {
data_source: dataSource,
params: step.inputParams,
@@ -410,6 +415,16 @@ export class WorkflowExecutorService extends EventEmitter {
if (response.data.status === 'error' || response.data.status === 'blocked') {
const rMsg = response.data.message || '执行失败';
const classified = classifyRError(rMsg);
logger.warn('[SSA:Executor] R tool returned error', {
step: step.stepOrder,
toolCode: step.toolCode,
rMessage: rMsg,
rErrorCode: response.data.error_code,
rUserHint: response.data.user_hint,
classifiedCode: classified.code,
});
return {
stepOrder: step.stepOrder,
toolCode: step.toolCode,

View File

@@ -74,13 +74,47 @@ export interface PrunedProfile {
// 2. LLM 原始输出的 Zod Schema静态版本
// ────────────────────────────────────────────
const VALID_VAR_TYPES = ['continuous', 'binary', 'categorical', 'ordinal', 'datetime'] as const;
const VAR_TYPE_ALIAS: Record<string, VariableType> = {
numeric: 'continuous',
integer: 'continuous',
int: 'continuous',
float: 'continuous',
double: 'continuous',
number: 'continuous',
real: 'continuous',
factor: 'categorical',
string: 'categorical',
text: 'categorical',
character: 'categorical',
char: 'categorical',
nominal: 'categorical',
boolean: 'binary',
bool: 'binary',
logical: 'binary',
dichotomous: 'binary',
date: 'datetime',
time: 'datetime',
timestamp: 'datetime',
};
function normalizeVarType(val: unknown): VariableType {
if (typeof val !== 'string') return 'continuous';
const lower = val.toLowerCase().trim();
if ((VALID_VAR_TYPES as readonly string[]).includes(lower)) return lower as VariableType;
return VAR_TYPE_ALIAS[lower] ?? 'continuous';
}
const varTypeSchema = z.preprocess(normalizeVarType, z.enum(VALID_VAR_TYPES));
/** LLM 直接输出的 JSON 结构Zod 校验用) */
export const LLMIntentOutputSchema = z.object({
goal: z.enum(['comparison', 'correlation', 'regression', 'descriptive', 'cohort_study']),
outcome_var: z.string().nullable().default(null),
outcome_type: z.enum(['continuous', 'binary', 'categorical', 'ordinal', 'datetime']).nullable().default(null),
outcome_type: varTypeSchema.nullable().default(null),
predictor_vars: z.array(z.string()).default([]),
predictor_types: z.array(z.enum(['continuous', 'binary', 'categorical', 'ordinal', 'datetime'])).default([]),
predictor_types: z.array(varTypeSchema).default([]),
grouping_var: z.string().nullable().default(null),
design: z.enum(['independent', 'paired', 'longitudinal', 'cross_sectional']).default('independent'),
confidence: z.number().min(0).max(1).default(0.5),

View File

@@ -1,19 +1,17 @@
# SSA智能统计分析模块 - 当前状态与开发指南
> **文档版本:** v2.0
> **文档版本:** v2.1
> **创建日期:** 2026-02-18
> **最后更新:** 2026-02-21
> **维护者:** 开发团队
> **当前状态:** 🎉 **QPER 智能化主线闭环完成Q→P→E→R 端到端 40/40 通过**
> **当前状态:** 🎉 **QPER 主线闭环 + 集成测试通过 + 统一状态管理重构完成**
> **文档目的:** 快速了解SSA模块状态为新AI助手提供上下文
>
> **🎉 重大里程碑2026-02-21**
> - ✅ **QPER 四层架构主线闭环** — Phase E+ / Q / P / R 全部完成93.5h 计划工时
> - ✅ **端到端测试 40/40 通过** — 两条完整链路(差异比较 + 相关分析)全部跑通
> - ✅ **LLM 智能意图理解** — 自然语言→四维信息提取Confidence=0.95
> - ✅ **配置化决策表驱动** — JSON 驱动方法选择,热更新 API方法学团队可配置
> - ✅ **LLM 论文级结论生成** — 6 要素结论 + 槽位注入反幻觉 + Zod 强校验 + 敏感性冲突准则
> - ✅ **四层降级体系** — 每层 LLM 失败时自动 fallback系统不中断
> **最新进展2026-02-21**
> - ✅ **前后端集成测试** — 7 个 Bug 全部修复R 引擎防御、意图识别、前端状态)
> - ✅ **统一状态管理重构** — 消除 isWorkflowMode 双轨逻辑AnalysisRecord 成为唯一数据源
> - ✅ **多任务切换** — 点击不同卡片正确显示各自的分析计划和结果
> - ✅ **R 代码完整性** — 多步骤分析的所有步骤代码均可下载/复制
---
@@ -26,9 +24,10 @@
| **模块名称** | SSA - 智能统计分析 (Smart Statistical Analysis) |
| **模块定位** | AI驱动的"白盒"统计分析系统 |
| **架构模式** | **QPER — Query → Planner → Execute → Reflection** |
| **前端状态模型** | **Unified Record Architecture — 一次分析 = 一个 Record = N 个 Steps** |
| **商业价值** | ⭐⭐⭐⭐⭐ 极高 |
| **目标用户** | 临床研究人员、生物统计师 |
| **开发状态** | 🎉 **QPER 主线闭环完成Phase Deploy 待启动** |
| **开发状态** | 🎉 **QPER 主线闭环 + 集成测试通过Phase Deploy 待启动** |
### 核心目标
@@ -79,6 +78,38 @@
---
## 🎨 前端架构:统一状态管理
> **2026-02-21 重构完成** — 消除 isWorkflowMode 双轨逻辑
### 数据模型
```typescript
AnalysisRecord {
id: string; // = workflowId or generated
query: string; // 用户原始问题
createdAt: string;
status: 'planning' | 'executing' | 'completed' | 'error';
plan: WorkflowPlan | null; // 统一用 WorkflowPlan单步也是 1 步的 Plan
steps: WorkflowStepResult[]; // 统一用步骤数组
progress: number; // 0-100
conclusionReport: ConclusionReport | null;
}
```
### Store 结构
- `analysisHistory: AnalysisRecord[]` — 所有分析记录
- `currentRecordId: string | null` — 当前激活的记录
- 派生:`currentRecord = analysisHistory.find(r => r.id === currentRecordId)`
- 操作:`addRecord(query, plan)` / `updateRecord(id, patch)` / `selectRecord(id)`
### 已删除的全局字段
`currentPlan``executionResult``traceSteps``workflowPlan``workflowSteps``workflowProgress``conclusionReport``isWorkflowMode` 及所有对应 setter。
---
## 📋 开发进度
| Phase | 任务 | 工时 | 状态 | 完成日期 |
@@ -91,21 +122,23 @@
| **Phase Q** | **LLM 意图理解** | **33h** | ✅ **已完成** | 2026-02-21 |
| **Phase P** | **决策表 + 流程模板** | **23h** | ✅ **已完成** | 2026-02-21 |
| **Phase R** | **LLM 论文级结论** | **22h** | ✅ **已完成** | 2026-02-21 |
| **集成测试** | **Bug 修复 + 统一状态管理重构** | **~4h** | ✅ **已完成** | 2026-02-21 |
| Phase Deploy | 工具补齐 + 部署上线 | 37h | 📋 待开始 | - |
| Phase Q+ | 人机协同增强 | 20h | 📋 待开始 | - |
| **QPER 透明化** | **Pipeline 可观测性增强** | TBD | 📋 待开始 | - |
### 已完成核心功能
| 组件 | 完成项 | 状态 |
|------|--------|------|
| **R 服务** | 7 个 R 工具T 检验、描述统计、卡方、Logistic、相关分析等+ Block-based 输出 | ✅ |
| **Q 层** | QueryService + LLM Intent + Zod 动态防幻觉 + 追问卡片 + DataProfile 增强 | ✅ |
| **R 服务** | 7 个 R 工具 + Block-based 输出 + 防御性编程NA 安全) | ✅ |
| **Q 层** | QueryService + LLM Intent + Zod 防幻觉 + 追问卡片 + 统计学意义关键词增强 | ✅ |
| **P 层** | ConfigLoader + DecisionTable + FlowTemplate + PlannedTrace + 热更新 API | ✅ |
| **E 层** | WorkflowExecutor + RClient + SSE 实时进度 + 错误分类映射 | ✅ |
| **E 层** | WorkflowExecutor + RClient + SSE 实时进度 + 错误分类映射 + 参数日志 | ✅ |
| **R 层** | ReflectionService + 槽位注入 + Zod 校验 + 敏感性冲突准则 + 结论缓存 + Word 增强 | ✅ |
| **前端** | V11 UI + DynamicReport + ClarificationCard + ConclusionReport渐入动画+ Word/R 代码导出 | ✅ |
| **前端** | 统一 Record 架构 + 多任务切换 + 已完成标记 + DynamicReport + Word/R 导出 | ✅ |
| **Python** | DataProfileServiceis_id_like 标记)+ CSV 解析 | ✅ |
| **测试** | QPER 端到端测试 40/40 通过 | ✅ |
| **测试** | QPER 端到端 40/40 + 集成测试 7 Bug 修复 | ✅ |
---
@@ -137,11 +170,25 @@ backend/src/modules/ssa/
│ └── config.routes.ts # 热更新 API
└── ...
backend/scripts/
├── seed-ssa-intent-prompt.ts # Q 层 Prompt 种子
├── seed-ssa-reflection-prompt.ts # R 层 Prompt 种子
├── test-ssa-qper-e2e.ts # QPER 端到端测试
└── ...
frontend-v2/src/modules/ssa/
├── stores/
│ └── ssaStore.ts # Zustand — Unified Record Architecture
├── hooks/
│ ├── useWorkflow.ts # 工作流 HookaddRecord/updateRecord
│ └── useAnalysis.ts # 上传/Legacy 兼容
├── components/
│ ├── SSAChatPane.tsx # 对话区(卡片 → selectRecord
│ ├── SSAWorkspacePane.tsx # 工作区(基于 currentRecord 渲染)
│ ├── SSACodeModal.tsx # R 代码模态框(从 record.steps 聚合)
│ ├── WorkflowTimeline.tsx # 执行计划时间线
│ └── DynamicReport.tsx # Block-based 结果渲染
└── types/
└── index.ts # 前端类型定义
r-statistics-service/
├── plumber.R # API 入口(含参数日志)
└── tools/
└── descriptive.R # 描述性统计NA 安全防御)
```
---
@@ -190,6 +237,7 @@ npx tsx scripts/seed-ssa-reflection-prompt.ts
|------|------|
| **QPER 开发计划(主线)** | `04-开发计划/10-QPER架构开发计划-智能化主线.md` |
| **QPER 开发总结** | `06-开发记录/SSA-QPER架构开发总结-2026-02-21.md` |
| **集成测试 Bug 修复** | `06-开发记录/2026-02-21-集成测试Bug修复与统一状态管理重构.md` |
| **智能化愿景设计** | `00-系统设计/SSA-Pro 理想状态与智能化愿景设计.md` |
| **PRD** | `00-系统设计/PRD SSA-Pro 严谨型智能统计分析模块.md` |
| **架构设计 V4** | `00-系统设计/SSA-Pro 严谨型智能统计分析架构设计方案V4.md` |
@@ -198,13 +246,25 @@ npx tsx scripts/seed-ssa-reflection-prompt.ts
## 🎯 下一步
1. **Phase Deploy37h** — 补齐 ANOVA / Fisher / Wilcoxon / 线性回归 + 复合工具 ST_BASELINE_TABLE + 部署上线
2. **Phase Q+20h** — 变量数据字典AI 先猜用户微调)+ 变量选择确认面板AI 推荐医生确认)
3. **前端集成测试** — 用户手动测试 QPER 全链路的真实交互体验
### 近期(优先级高)
1. **QPER 透明化Pipeline 可观测性)**
- Q 层:展示 LLM 解析结果goal、变量、置信度和降级原因
- P 层:展示决策表匹配过程和流程模板填充参数
- E 层:实时展示步骤输入参数 + R 返回摘要;开发模式显示 R 原始错误
- R 层:展示槽位注入内容和 Zod 校验状态
- 开发者面板:持久化 trace_log + LLM prompt/response 可查看
2. **Phase Deploy37h** — 补齐 ANOVA / Fisher / Wilcoxon / 线性回归 + 复合工具 ST_BASELINE_TABLE + 部署上线
### 中期
3. **Phase Q+20h** — 变量数据字典AI 先猜用户微调)+ 变量选择确认面板AI 推荐医生确认)
4. **前端 UI 细节打磨** — 执行计划格式美化、错误状态视觉增强
---
**文档版本:** v2.0
**文档版本:** v2.1
**最后更新:** 2026-02-21
**当前状态:** 🎉 QPER 主线闭环完成,端到端 40/40 通过
**下一步:** Phase Deploy 工具补齐 + 部署上线
**当前状态:** 🎉 QPER 主线闭环 + 集成测试通过 + 统一状态管理重构完成
**下一步:** QPER 透明化 → Phase Deploy 工具补齐 + 部署上线

View File

@@ -0,0 +1,153 @@
# SSA 前后端集成测试 Bug 修复与统一状态管理重构
> **日期:** 2026-02-21
> **阶段:** QPER 闭环后首次集成测试 + 架构重构
> **核心成果:** 修复 7 个集成 Bug + 完成统一状态管理重构(消除双轨逻辑)
---
## 一、Bug 修复清单
### Bug 1: 前端错误消息乱码(`分分析行执行执失行败失…undefined重`
| 项目 | 内容 |
|------|------|
| **现象** | 执行失败时,对话区错误消息显示为乱码字符 |
| **根因** | 错误消息被 TypeWriter 组件逐字渲染导致字符交错errText 提取有竞态导致 undefined |
| **修复** | `useWorkflow.ts` — 确保 errText 始终为 String(),错误消息添加 `artifactType: 'execution'` 跳过 TypeWriter |
| **文件** | `frontend-v2/src/modules/ssa/hooks/useWorkflow.ts` |
### Bug 2: R 引擎 `missing value where TRUE/FALSE needed` 错误
| 项目 | 内容 |
|------|------|
| **现象** | 描述性统计执行时 R 崩溃,返回 500 错误 |
| **根因** | `descriptive.R` 中多处 if 条件group_var 判断、var_types 比较、分组子集)接收到 NA 而非 TRUE/FALSE |
| **修复** | 全面防御性编程:`isTRUE()``is.na()` 检查、robust group_var 归一化、sapply 类型推断 tryCatch、可复现代码生成 tryCatch |
| **文件** | `r-statistics-service/tools/descriptive.R` |
### Bug 3: 分析意图识别错误("统计学意义" → 描述性统计)
| 项目 | 内容 |
|------|------|
| **现象** | 用户问"Yqol和bmi是否有统计学意义",系统使用描述性统计而非 T 检验/相关分析 |
| **根因** | Q 层 LLM Prompt 和 Regex fallback 缺少"统计学意义""显著""检验""p值"等关键词到 correlation goal 的映射 |
| **修复** | `QueryService.ts` fallbackToRegex 增加统计学意义关键词;`seed-ssa-intent-prompt.ts` 新增 Few-Shot 示例 |
| **文件** | `backend/src/modules/ssa/services/QueryService.ts`, `backend/scripts/seed-ssa-intent-prompt.ts` |
### Bug 4: 步骤 2Logistic 回归)结果不显示
| 项目 | 内容 |
|------|------|
| **现象** | R 返回 status="warning"ggplot2 废弃警告),前端仅过滤 status="success",导致步骤 2 结果丢失 |
| **根因** | 前端 SSAWorkspacePane、SSACodeModal 只识别 `success`,不识别 `warning` |
| **修复** | 引入 `stepHasResult` 辅助函数:`(s.status === 'success' \|\| s.status === 'warning') && s.result`,替换所有 6 处过滤 |
| **文件** | `frontend-v2/src/modules/ssa/components/SSAWorkspacePane.tsx`, `SSACodeModal.tsx` |
### Bug 5: R 代码下载不完整(仅步骤 1
| 项目 | 内容 |
|------|------|
| **现象** | 多步骤分析完成后,下载的 R 代码只包含步骤 1描述统计缺少步骤 2回归 |
| **根因** | 与 Bug 4 同源 — SSACodeModal 过滤条件只匹配 `success` |
| **修复** | 随 Bug 4 一并修复 |
### Bug 6: 多任务状态混淆(点击旧卡片显示新任务结果)
| 项目 | 内容 |
|------|------|
| **现象** | 完成两次分析后,点击第 1 次的卡片显示的是第 2 次的结果 |
| **根因** | workflowPlan/workflowSteps/conclusionReport 等是全局单例,切换记录时未正确同步 |
| **修复** | 通过统一状态管理重构彻底解决(见下文第二节) |
### Bug 7: 后端/R 引擎调试日志不足
| 项目 | 内容 |
|------|------|
| **现象** | R 执行失败时无法定位具体哪些变量传入导致错误 |
| **修复** | `WorkflowExecutorService.ts` 增加 R 调用前参数日志;`plumber.R` 增加参数接收日志和错误日志 |
| **文件** | `backend/src/modules/ssa/services/WorkflowExecutorService.ts`, `r-statistics-service/plumber.R` |
---
## 二、统一状态管理重构
### 问题描述
前端存在"双轨逻辑"
- **Legacy 路径**`currentPlan` / `executionResult` / `traceSteps`(全局单例)
- **Workflow 路径**`workflowPlan` / `workflowSteps` / `workflowProgress` / `conclusionReport`(也是全局单例)
- `isWorkflowMode` 分支在多个组件中分叉
多任务场景下,全局单例被最新任务覆盖,导致切换卡片时显示错误数据。
### 重构方案
**核心思想:一次分析 = 一个 Record = N 个 Steps**
```
AnalysisRecord {
id, query, createdAt,
status: 'planning' | 'executing' | 'completed' | 'error',
plan: WorkflowPlan | null,
steps: WorkflowStepResult[],
progress: number,
conclusionReport: ConclusionReport | null,
}
```
### 改动文件
| 文件 | 改动要点 |
|------|---------|
| `ssaStore.ts` | 删除 12 个全局单例字段及 setter新增 `addRecord`/`updateRecord`/`selectRecord``selectRecord` 只设 currentRecordId不复制数据 |
| `useWorkflow.ts` | SSE 事件处理只调用 `updateRecord(rid, patch)`;删除对 `setWorkflowSteps` 等全局 setter 的调用 |
| `useAnalysis.ts` | `generatePlan` 改为调用 `addRecord` 创建 1-step Record |
| `SSAChatPane.tsx` | SAP/Result 卡片统一行为:`selectRecord(recordId)` + 打开工作区;已完成记录显示绿色"已完成"徽章 |
| `SSAWorkspacePane.tsx` | 所有渲染基于 `currentRecord`;删除 `isWorkflowMode` 分支phase 直接使用 `record.status` |
| `SSACodeModal.tsx` | 从 `currentRecord.steps` 聚合代码;删除 Legacy fallback |
| `ssa-workspace.css` | 新增 `.sap-card-badge` 样式 |
### 额外修复
- **executeWorkflow 跨 hook 实例问题**`SSAChatPane``SSAWorkspacePane` 各自有独立的 `useWorkflow()` 实例,`generateWorkflowPlan` 在 ChatPane 实例中设置了 `currentRecordIdRef`,但 `executeWorkflow` 在 WorkspacePane 实例中执行时 ref 为 null。修复fallback 到 store 的 `currentRecordId`
---
## 三、未完成工作与后续计划
### QPER 透明化与可观测性
目前 QPER 四层的执行细节对用户不够透明,出错时用户难以了解具体原因,开发调试也依赖日志查看:
1. **Q 层透明化**
- 展示 LLM 意图解析的 ParsedQuery 结果goal、变量、设计类型、置信度
- 低置信度时展示降级原因("LLM 超时,使用关键词匹配"
2. **P 层透明化**
- 展示决策表匹配过程(匹配了哪条规则、得分)
- 展示流程模板填充参数
3. **E 层透明化**
- 实时展示每个步骤的输入参数和 R 返回结果摘要
- 错误时展示 R 原始错误信息(开发模式)
4. **R 层透明化**
- 展示 LLM 结论生成的槽位注入内容
- Zod 校验失败时展示具体字段错误
5. **开发调试增强**
- 持久化 trace_log 到数据库
- 前端"开发者面板"查看完整 QPER pipeline 日志
- LLM 调用的 prompt/response 可查看
### 其他待完成项
- Phase Deploy补齐 ANOVA / Fisher / Wilcoxon / 线性回归等 R 工具
- Phase Q+:变量数据字典 + 变量选择确认面板
- 前端 UI 细节打磨:执行计划格式美化、错误状态视觉增强
---
**测试状态:** 手动集成测试通过多任务切换、卡片状态、R 代码完整性)
**影响范围:** 纯前端重构 + R 脚本防御性修复 + 后端日志增强,后端 API 不变

View File

@@ -24,6 +24,7 @@ import {
} from 'lucide-react';
import { useNavigate } from 'react-router-dom';
import { useSSAStore } from '../stores/ssaStore';
import type { AnalysisRecord } from '../stores/ssaStore';
import { useAnalysis } from '../hooks/useAnalysis';
import { useWorkflow } from '../hooks/useWorkflow';
import type { SSAMessage } from '../types';
@@ -40,16 +41,15 @@ export const SSAChatPane: React.FC = () => {
mountedFile,
setMountedFile,
setCurrentSession,
setActivePane,
setWorkspaceOpen,
currentPlan,
isLoading,
isExecuting,
error,
setError,
addToast,
addMessage,
selectAnalysisRecord,
selectRecord,
analysisHistory,
dataProfile,
dataProfileLoading,
} = useSSAStore();
@@ -78,10 +78,9 @@ export const SSAChatPane: React.FC = () => {
}, []);
useEffect(() => {
// 延迟滚动,确保 DOM 更新完成
const timer = setTimeout(scrollToBottom, 100);
return () => clearTimeout(timer);
}, [messages, currentPlan, scrollToBottom]);
}, [messages, scrollToBottom]);
const handleBack = () => {
navigate(-1);
@@ -154,18 +153,20 @@ export const SSAChatPane: React.FC = () => {
const query = inputValue;
setInputValue('');
// Immediately show user message in chat
addMessage({
id: crypto.randomUUID(),
role: 'user',
content: query,
createdAt: new Date().toISOString(),
});
try {
if (currentSession?.id) {
// Phase Q: 先做意图解析,低置信度时追问
const intentResp = await parseIntent(currentSession.id, query);
if (intentResp.needsClarification && intentResp.clarificationCards?.length > 0) {
addMessage({
id: crypto.randomUUID(),
role: 'user',
content: query,
createdAt: new Date().toISOString(),
});
addMessage({
id: crypto.randomUUID(),
role: 'assistant',
@@ -180,7 +181,7 @@ export const SSAChatPane: React.FC = () => {
return;
}
// 置信度足够 → 直接生成工作流计划
// 置信度足够 → 直接生成工作流计划(不再重复添加用户消息)
await generateWorkflowPlan(currentSession.id, query);
} else {
await generatePlan(query);
@@ -235,15 +236,13 @@ export const SSAChatPane: React.FC = () => {
}
};
// 打开工作区,可选择特定的分析记录
const handleOpenWorkspace = useCallback((recordId?: string) => {
if (recordId) {
selectAnalysisRecord(recordId);
selectRecord(recordId);
} else {
setWorkspaceOpen(true);
setActivePane('sap');
}
}, [selectAnalysisRecord, setWorkspaceOpen, setActivePane]);
}, [selectRecord, setWorkspaceOpen]);
const formatFileSize = (bytes: number) => {
if (bytes < 1024) return `${bytes}B`;
@@ -321,21 +320,31 @@ export const SSAChatPane: React.FC = () => {
msg.content
)}
{/* SAP 卡片 - 只有消息中明确标记为 sap 类型时才显示 */}
{msg.artifactType === 'sap' && msg.recordId && (
<button className="sap-card" onClick={() => handleOpenWorkspace(msg.recordId)}>
<div className="sap-card-left">
<div className="sap-card-icon">
<FileSignature size={16} />
{/* SAP / Result 卡片 - 统一行为selectRecord + 打开工作区 */}
{(msg.artifactType === 'sap' || msg.artifactType === 'result') && msg.recordId && (() => {
const rec = analysisHistory.find((r: AnalysisRecord) => r.id === msg.recordId);
const isCompleted = rec?.status === 'completed';
const isSap = msg.artifactType === 'sap';
return (
<button className="sap-card" onClick={() => handleOpenWorkspace(msg.recordId)}>
<div className="sap-card-left">
<div className="sap-card-icon">
{isSap ? <FileSignature size={16} /> : <BarChart2 size={16} />}
</div>
<div className="sap-card-content">
<div className="sap-card-title">
{isSap ? '查看分析计划' : '查看分析结果'}
{isCompleted && <span className="sap-card-badge"></span>}
</div>
<div className="sap-card-hint">
{rec?.plan?.title || '点击打开工作区查看详情'}
</div>
</div>
</div>
<div className="sap-card-content">
<div className="sap-card-title"> (SAP)</div>
<div className="sap-card-hint"></div>
</div>
</div>
<ArrowRight size={16} className="sap-card-arrow" />
</button>
)}
<ArrowRight size={16} className="sap-card-arrow" />
</button>
);
})()}
</div>
</div>
);

View File

@@ -1,85 +1,79 @@
/**
* SSACodeModal - V11 R代码模态框
* SSACodeModal - R 代码模态框 (Unified Record Architecture)
*
* 100% 还原 V11 原型图
* 调用后端 API 获取真实执行代码
* 从 currentRecord.steps 聚合所有步骤的可复现代码。
*/
import React, { useEffect, useState } from 'react';
import { X, Download, Loader2 } from 'lucide-react';
import { useSSAStore } from '../stores/ssaStore';
import { useAnalysis } from '../hooks/useAnalysis';
export const SSACodeModal: React.FC = () => {
const { codeModalVisible, setCodeModalVisible, executionResult, addToast, isWorkflowMode, workflowSteps } = useSSAStore();
const { downloadCode } = useAnalysis();
const {
codeModalVisible,
setCodeModalVisible,
addToast,
currentRecordId,
analysisHistory,
} = useSSAStore();
const [code, setCode] = useState<string>('');
const [isLoading, setIsLoading] = useState(false);
useEffect(() => {
if (codeModalVisible) {
loadCode();
}
}, [codeModalVisible]);
const record = currentRecordId
? analysisHistory.find((r) => r.id === currentRecordId) ?? null
: null;
const loadCode = async () => {
useEffect(() => {
if (!codeModalVisible) return;
setIsLoading(true);
try {
if (isWorkflowMode && workflowSteps.length > 0) {
const allCode = workflowSteps
.filter(s => s.status === 'success' && s.result)
.map(s => {
const steps = record?.steps ?? [];
const successSteps = steps.filter(
(s) => (s.status === 'success' || s.status === 'warning') && s.result
);
if (successSteps.length > 0) {
const allCode = successSteps
.map((s) => {
const stepCode = (s.result as any)?.reproducible_code;
const header = `# ========================================\n# 步骤 ${s.step_number}: ${s.tool_name}\n# ========================================\n`;
return header + (stepCode || `# 该步骤暂无可用代码`);
return header + (stepCode || '# 该步骤暂无可用代码');
})
.join('\n\n');
setCode(allCode || '# 暂无可用代码\n# 请先执行分析');
} else {
const result = await downloadCode();
const text = await result.blob.text();
setCode(text);
}
} catch (error) {
if (executionResult?.reproducibleCode) {
setCode(executionResult.reproducibleCode);
setCode(allCode);
} else {
setCode('# 暂无可用代码\n# 请先执行分析');
}
} finally {
setIsLoading(false);
}
};
}, [codeModalVisible, record]);
if (!codeModalVisible) return null;
const handleClose = () => {
setCodeModalVisible(false);
const handleClose = () => setCodeModalVisible(false);
const generateFilename = () => {
const now = new Date();
const pad = (n: number) => n.toString().padStart(2, '0');
const ts = `${now.getFullYear()}${pad(now.getMonth() + 1)}${pad(now.getDate())}_${pad(now.getHours())}${pad(now.getMinutes())}${pad(now.getSeconds())}`;
const title = (record?.plan?.title || 'analysis')
.replace(/[^a-zA-Z0-9\u4e00-\u9fa5_-]/g, '_')
.slice(0, 30);
return `SSA_${title}_${ts}.R`;
};
const handleDownload = async () => {
const handleDownload = () => {
try {
if (isWorkflowMode && code) {
const blob = new Blob([code], { type: 'text/plain' });
const url = URL.createObjectURL(blob);
const a = document.createElement('a');
a.href = url;
a.download = 'workflow_analysis.R';
a.click();
URL.revokeObjectURL(url);
addToast('R 脚本已下载', 'success');
handleClose();
} else {
const result = await downloadCode();
const url = URL.createObjectURL(result.blob);
const a = document.createElement('a');
a.href = url;
a.download = result.filename;
a.click();
URL.revokeObjectURL(url);
addToast('R 脚本已下载', 'success');
handleClose();
}
} catch (error) {
const blob = new Blob([code], { type: 'text/plain' });
const url = URL.createObjectURL(blob);
const a = document.createElement('a');
a.href = url;
a.download = generateFilename();
a.click();
URL.revokeObjectURL(url);
addToast('R 脚本已下载', 'success');
handleClose();
} catch {
addToast('下载失败', 'error');
}
};

File diff suppressed because it is too large Load Diff

View File

@@ -1,9 +1,20 @@
/**
* 多步骤工作流时间线组件
* WorkflowTimeline - 多步骤分析计划时间线
*
* Phase 2A: 在工作区显示多步骤分析计划的垂直时间线
* 精美卡片式布局:标题区 → 护栏横幅 → 步骤卡片列表 → 底部提示
*/
import React from 'react';
import {
FlaskConical,
BarChart3,
Shield,
AlertTriangle,
CheckCircle,
XCircle,
Loader2,
Clock,
ListChecks,
} from 'lucide-react';
import type { WorkflowPlan, WorkflowStepDef, WorkflowStepResult, WorkflowStepStatus } from '../types';
interface WorkflowTimelineProps {
@@ -13,109 +24,151 @@ interface WorkflowTimelineProps {
isExecuting?: boolean;
}
const statusConfig: Record<WorkflowStepStatus | 'pending', {
icon: string;
color: string;
bg: string;
animation?: string;
const statusStyle: Record<WorkflowStepStatus | 'pending', {
borderColor: string;
dotBg: string;
dotColor: string;
}> = {
pending: { icon: '○', color: '#94a3b8', bg: '#f1f5f9' },
running: { icon: '◎', color: '#2563eb', bg: '#eff6ff', animation: 'pulse' },
success: { icon: '✓', color: '#059669', bg: '#ecfdf5' },
failed: { icon: '✕', color: '#dc2626', bg: '#fef2f2' },
skipped: { icon: '⊘', color: '#94a3b8', bg: '#f8fafc' },
warning: { icon: '!', color: '#d97706', bg: '#fffbeb' },
pending: { borderColor: '#e2e8f0', dotBg: '#f1f5f9', dotColor: '#94a3b8' },
running: { borderColor: '#3b82f6', dotBg: '#dbeafe', dotColor: '#2563eb' },
success: { borderColor: '#10b981', dotBg: '#d1fae5', dotColor: '#059669' },
failed: { borderColor: '#ef4444', dotBg: '#fee2e2', dotColor: '#dc2626' },
skipped: { borderColor: '#e2e8f0', dotBg: '#f8fafc', dotColor: '#94a3b8' },
warning: { borderColor: '#f59e0b', dotBg: '#fef3c7', dotColor: '#d97706' },
};
const toolIcons: Record<string, string> = {
't_test': '📊',
'welch_t_test': '📊',
'paired_t_test': '🔗',
'mann_whitney_u': '📈',
'wilcoxon_signed_rank': '📈',
'chi_square_test': '📋',
'fisher_exact_test': '📋',
'one_way_anova': '📉',
'kruskal_wallis': '📉',
'pearson_correlation': '🔄',
'spearman_correlation': '🔄',
'logistic_regression': '📐',
'default': '🔬',
const StatusDot: React.FC<{ status: WorkflowStepStatus | 'pending'; num: number }> = ({ status, num }) => {
const s = statusStyle[status];
if (status === 'running') {
return (
<span className="wt-dot running" style={{ background: s.dotBg, borderColor: s.dotColor }}>
<Loader2 size={14} className="spin" style={{ color: s.dotColor }} />
</span>
);
}
if (status === 'success') {
return (
<span className="wt-dot" style={{ background: s.dotBg, borderColor: s.dotColor }}>
<CheckCircle size={14} style={{ color: s.dotColor }} />
</span>
);
}
if (status === 'failed') {
return (
<span className="wt-dot" style={{ background: s.dotBg, borderColor: s.dotColor }}>
<XCircle size={14} style={{ color: s.dotColor }} />
</span>
);
}
return (
<span className="wt-dot" style={{ background: s.dotBg, borderColor: s.dotColor, color: s.dotColor }}>
{num}
</span>
);
};
const getToolIcon = (toolCode: string): string => {
return toolIcons[toolCode] || toolIcons.default;
const HIDDEN_PARAMS = new Set(['session_id', 'sessionId', 'data_path', 'dataPath', 'original_filename']);
const formatValue = (value: unknown): string => {
if (value === null || value === undefined) return '—';
if (Array.isArray(value)) {
if (value.length === 0) return '—';
if (value.length <= 4) return value.join(', ');
return `${value.slice(0, 3).join(', ')} … 等${value.length}`;
}
const s = String(value);
return s.length > 50 ? s.slice(0, 47) + '…' : s;
};
interface StepItemProps {
const PARAM_LABELS: Record<string, string> = {
variables: '分析变量',
outcome_var: '结局变量 (Y)',
predictors: '自变量 (X)',
group_var: '分组变量',
value_var: '因变量',
method: '统计方法',
alternative: '假设方向',
conf_level: '置信水平',
var_x: '变量 X',
var_y: '变量 Y',
};
interface StepCardProps {
step: WorkflowStepDef;
result?: WorkflowStepResult;
isLast: boolean;
isCurrent: boolean;
}
const StepItem: React.FC<StepItemProps> = ({ step, result, isLast, isCurrent }) => {
const status = result?.status || 'pending';
const config = statusConfig[status];
const StepCard: React.FC<StepCardProps> = ({ step, result, isLast, isCurrent }) => {
const status: WorkflowStepStatus | 'pending' = result?.status || 'pending';
const s = statusStyle[status];
const visibleParams = step.params
? Object.entries(step.params).filter(([k]) => !HIDDEN_PARAMS.has(k))
: [];
return (
<div className={`timeline-step ${status} ${isCurrent ? 'current' : ''}`}>
<div className="step-connector">
<div
className={`step-dot ${config.animation || ''}`}
style={{ backgroundColor: config.bg, borderColor: config.color }}
>
<span style={{ color: config.color }}>{config.icon}</span>
</div>
{!isLast && <div className="step-line" style={{ borderColor: result?.status === 'success' ? '#059669' : '#e2e8f0' }} />}
<div className={`wt-step-row ${isCurrent ? 'wt-current' : ''}`}>
{/* Left rail */}
<div className="wt-rail">
<StatusDot status={status} num={step.step_number} />
{!isLast && (
<div
className="wt-line"
style={{ borderColor: status === 'success' ? '#10b981' : '#e2e8f0' }}
/>
)}
</div>
<div className="step-content">
<div className="step-header">
<span className="step-number"> {step.step_number}</span>
<span className="tool-icon">{getToolIcon(step.tool_code)}</span>
<span className="tool-name">{step.tool_name}</span>
{step.is_sensitivity && (
<span className="sensitivity-badge"></span>
)}
{result?.duration_ms && (
<span className="step-duration">{result.duration_ms}ms</span>
{/* Card */}
<div className="wt-card" style={{ borderLeftColor: s.borderColor }}>
<div className="wt-card-head">
<div className="wt-card-title-row">
<span className="wt-step-badge"> {step.step_number}</span>
<span className="wt-tool-name">{step.tool_name}</span>
{step.is_sensitivity && <span className="wt-sensitivity"></span>}
</div>
{result?.duration_ms != null && (
<span className="wt-duration">{result.duration_ms}ms</span>
)}
</div>
<div className="step-description">{step.description}</div>
{step.description && (
<p className="wt-card-desc">{step.description}</p>
)}
{step.switch_condition && (
<div className="step-guardrail">
🛡 {step.switch_condition}
<div className="wt-guardrail-inline">
<Shield size={12} />
<span>{step.switch_condition}</span>
</div>
)}
{step.params && Object.keys(step.params).length > 0 && (
<div className="step-params">
{Object.entries(step.params).slice(0, 3).map(([key, value]) => (
<span key={key} className="param-tag">
{key}: {String(value)}
</span>
{visibleParams.length > 0 && (
<div className="wt-params-grid">
{visibleParams.slice(0, 5).map(([key, value]) => (
<div key={key} className="wt-param-item">
<span className="wt-param-label">{PARAM_LABELS[key] || key}</span>
<span className="wt-param-val">{formatValue(value)}</span>
</div>
))}
</div>
)}
{result?.status === 'success' && result.result?.p_value !== undefined && (
<div className="step-result-preview">
<span className="result-badge">
<div className="wt-result-row">
<span className="wt-p-badge">
p = {result.result.p_value < 0.001 ? '< 0.001' : result.result.p_value.toFixed(4)}
</span>
{result.result.p_value < 0.05 && (
<span className="significant-badge"> *</span>
)}
{result.result.p_value < 0.05 && <span className="wt-sig-badge"> *</span>}
</div>
)}
{result?.status === 'failed' && result.error && (
<div className="step-error">
<span className="error-icon"></span>
<span className="error-message">{result.error}</span>
<div className="wt-error-row">
<AlertTriangle size={13} />
<span>{typeof result.error === 'object' ? (result.error as any)?.userHint || JSON.stringify(result.error) : result.error}</span>
</div>
)}
</div>
@@ -129,78 +182,82 @@ export const WorkflowTimeline: React.FC<WorkflowTimelineProps> = ({
currentStep,
isExecuting = false,
}) => {
const getStepResult = (stepNumber: number): WorkflowStepResult | undefined => {
return stepResults.find(r => r.step_number === stepNumber);
};
const completedSteps = stepResults.filter(r => r.status === 'success').length;
const progress = plan.total_steps > 0 ? (completedSteps / plan.total_steps) * 100 : 0;
const getResult = (n: number) => stepResults.find(r => r.step_number === n);
const done = stepResults.filter(r => r.status === 'success').length;
const pct = plan.total_steps > 0 ? (done / plan.total_steps) * 100 : 0;
return (
<div className="workflow-timeline">
<div className="timeline-header">
<div className="header-info">
<h3 className="timeline-title">{plan.title}</h3>
<p className="timeline-description">{plan.description}</p>
<div className="wt-root">
{/* Header */}
<div className="wt-header">
<div className="wt-header-icon">
<FlaskConical size={20} />
</div>
<div className="header-meta">
<span className="step-count">
{plan.total_steps}
</span>
{plan.estimated_time_seconds && (
<span className="estimated-time">
{Math.ceil(plan.estimated_time_seconds / 60)}
<div className="wt-header-body">
<h3 className="wt-title">{plan.title}</h3>
<p className="wt-desc">{plan.description}</p>
<div className="wt-meta">
<span className="wt-meta-item">
<ListChecks size={13} />
{plan.total_steps}
</span>
)}
{plan.estimated_time_seconds != null && (
<span className="wt-meta-item">
<Clock size={13} />
{plan.estimated_time_seconds < 60 ? `${plan.estimated_time_seconds}` : `${Math.ceil(plan.estimated_time_seconds / 60)}分钟`}
</span>
)}
</div>
</div>
</div>
{/* EPV Warning */}
{plan.epv_warning && (
<div className="epv-warning-banner">
<span className="epv-icon"></span>
<div className="wt-banner wt-banner-warn">
<AlertTriangle size={15} />
<span>{plan.epv_warning}</span>
</div>
)}
{/* Guardrail Banner */}
{plan.planned_trace?.fallbackTool && (
<div className="guardrail-banner">
<span className="guardrail-icon">🛡</span>
<div className="wt-banner wt-banner-guard">
<Shield size={15} />
<span>
{plan.planned_trace.primaryTool}
&nbsp;&nbsp;{plan.planned_trace.switchCondition} {plan.planned_trace.fallbackTool}
<b>{plan.planned_trace.primaryTool}</b>
{' → '} {plan.planned_trace.switchCondition} <b>{plan.planned_trace.fallbackTool}</b>
</span>
</div>
)}
{/* Progress */}
{isExecuting && (
<div className="timeline-progress">
<div className="progress-bar">
<div
className="progress-fill"
style={{ width: `${progress}%` }}
/>
<div className="wt-progress-bar-wrap">
<div className="wt-progress-track">
<div className="wt-progress-fill" style={{ width: `${pct}%` }} />
</div>
<span className="progress-text">
{completedSteps}/{plan.total_steps}
</span>
<span className="wt-progress-label">{done}/{plan.total_steps}</span>
</div>
)}
<div className="timeline-steps">
{plan.steps.map((step, index) => (
<StepItem
{/* Steps */}
<div className="wt-steps">
{plan.steps.map((step, i) => (
<StepCard
key={step.step_number}
step={step}
result={getStepResult(step.step_number)}
isLast={index === plan.steps.length - 1}
result={getResult(step.step_number)}
isLast={i === plan.steps.length - 1}
isCurrent={currentStep === step.step_number}
/>
))}
</div>
{/* Footer */}
{!isExecuting && stepResults.length === 0 && (
<div className="timeline-footer">
<span className="ready-hint"> </span>
<div className="wt-footer">
<BarChart3 size={14} />
<span></span>
</div>
)}
</div>

View File

@@ -1,15 +1,14 @@
/**
* SSA 分析相关的自定义 Hook
* SSA 分析相关的自定义 Hook (Updated for Unified Record Architecture)
*
* 遵循规范:
* - 使用 apiClient带认证的 axios 实例)
* - 使用 getAccessToken 处理文件上传
* uploadData / generatePlan 仍由 SSAChatPane 使用。
* executeAnalysis / exportReport 保留但已切换到 record-based 存储。
*/
import { useCallback, useState } from 'react';
import apiClient from '@/common/api/axios';
import { getAccessToken } from '@/framework/auth/api';
import { useSSAStore } from '../stores/ssaStore';
import type { AnalysisPlan, ExecutionResult, SSAMessage, TraceStep } from '../types';
import type { AnalysisPlan, ExecutionResult, SSAMessage } from '../types';
import {
Document,
Packer,
@@ -60,18 +59,13 @@ interface UseAnalysisReturn {
export function useAnalysis(): UseAnalysisReturn {
const {
currentSession,
currentPlan,
setCurrentPlan,
setExecutionResult,
setTraceSteps,
updateTraceStep,
addMessage,
setLoading,
setExecuting,
isExecuting,
setError,
addAnalysisRecord,
updateAnalysisRecord,
addRecord,
updateRecord,
} = useSSAStore();
const [isUploading, setIsUploading] = useState(false);
@@ -85,7 +79,6 @@ export function useAnalysis(): UseAnalysisReturn {
formData.append('file', file);
try {
// 文件上传使用 fetch + 手动添加认证头(不设置 Content-Type
const token = getAccessToken();
const headers: HeadersInit = {};
if (token) {
@@ -98,10 +91,7 @@ export function useAnalysis(): UseAnalysisReturn {
body: formData,
});
if (!response.ok) {
throw new Error('上传失败');
}
if (!response.ok) throw new Error('上传失败');
const result = await response.json();
setUploadProgress(100);
return result;
@@ -115,10 +105,7 @@ export function useAnalysis(): UseAnalysisReturn {
const generatePlan = useCallback(
async (query: string): Promise<AnalysisPlan> => {
if (!currentSession) {
throw new Error('请先上传数据');
}
if (!currentSession) throw new Error('请先上传数据');
setLoading(true);
try {
@@ -134,31 +121,38 @@ export function useAnalysis(): UseAnalysisReturn {
`${API_BASE}/sessions/${currentSession.id}/plan`,
{ query }
);
const plan: AnalysisPlan = response.data;
// 创建分析记录(支持多任务)
const recordId = addAnalysisRecord(query, plan);
const recordId = addRecord(query, {
workflow_id: plan.id || `legacy_${Date.now()}`,
title: plan.title || plan.toolName || '统计分析',
total_steps: 1,
steps: [{
step_number: 1,
tool_code: plan.toolCode || 'unknown',
tool_name: plan.toolName || '统计分析',
description: plan.description || '',
params: plan.parameters || {},
}],
} as any);
// 消息中携带 recordId便于点击时定位
const planMessage: SSAMessage = {
id: crypto.randomUUID(),
role: 'assistant',
content: `已生成分析方案:${plan.toolName}\n\n${plan.description}`,
artifactType: 'sap',
recordId, // 关联到分析记录
recordId,
createdAt: new Date().toISOString(),
};
addMessage(planMessage);
const confirmMessage: SSAMessage = {
addMessage({
id: crypto.randomUUID(),
role: 'assistant',
content: '请确认数据映射并执行分析。',
artifactType: 'confirm',
createdAt: new Date().toISOString(),
};
addMessage(confirmMessage);
});
return plan;
} catch (error) {
@@ -168,404 +162,125 @@ export function useAnalysis(): UseAnalysisReturn {
setLoading(false);
}
},
[currentSession, addMessage, setCurrentPlan, setLoading, setError, addAnalysisRecord]
[currentSession, addMessage, setLoading, setError, addRecord]
);
const executePlan = useCallback(
async (_planId: string): Promise<ExecutionResult> => {
if (!currentSession) {
throw new Error('请先上传数据');
}
if (!currentSession) throw new Error('请先上传数据');
// 获取当前 plan从 store
const plan = useSSAStore.getState().currentPlan;
if (!plan) {
throw new Error('请先生成分析计划');
}
const record = (() => {
const s = useSSAStore.getState();
return s.currentRecordId
? s.analysisHistory.find((r) => r.id === s.currentRecordId) ?? null
: null;
})();
const planStep = record?.plan?.steps?.[0];
if (!planStep) throw new Error('请先生成分析计划');
setExecuting(true);
setExecutionResult(null);
const initialSteps: TraceStep[] = [
{ index: 0, name: '参数验证', status: 'pending', message: '等待执行' },
{ index: 1, name: '护栏检查', status: 'pending', message: '等待执行' },
{ index: 2, name: '统计计算', status: 'pending', message: '等待执行' },
{ index: 3, name: '可视化生成', status: 'pending', message: '等待执行' },
{ index: 4, name: '结果格式化', status: 'pending', message: '等待执行' },
];
setTraceSteps(initialSteps);
const rid = record!.id;
updateRecord(rid, { status: 'executing', steps: [], progress: 0 });
try {
updateTraceStep(0, { status: 'running' });
// 发送完整的 plan 对象(转换为后端格式)
const response = await apiClient.post(
`${API_BASE}/sessions/${currentSession.id}/execute`,
{
plan: {
tool_code: plan.toolCode,
tool_name: plan.toolName,
params: plan.parameters,
guardrails: plan.guardrails
}
tool_code: planStep.tool_code,
tool_name: planStep.tool_name,
params: planStep.params,
},
}
);
const result: ExecutionResult = response.data;
initialSteps.forEach((_, i) => {
updateTraceStep(i, { status: 'success' });
updateRecord(rid, {
status: 'completed',
progress: 100,
steps: [{
step_number: 1,
tool_code: planStep.tool_code,
tool_name: planStep.tool_name,
status: 'success',
result: result as any,
started_at: new Date().toISOString(),
completed_at: new Date().toISOString(),
duration_ms: result.executionMs,
logs: [],
}],
});
result.guardrailResults?.forEach((gr) => {
if (gr.actionType === 'Switch' && gr.actionTaken) {
updateTraceStep(1, {
status: 'switched',
actionType: 'Switch',
switchTarget: gr.switchTarget,
message: gr.message,
});
} else if (gr.actionType === 'Warn') {
updateTraceStep(1, {
actionType: 'Warn',
message: gr.message,
});
}
});
setExecutionResult(result);
// 更新分析记录
const recordId = useSSAStore.getState().currentRecordId;
if (recordId) {
updateAnalysisRecord(recordId, {
executionResult: result,
traceSteps: useSSAStore.getState().traceSteps,
});
}
const resultMessage: SSAMessage = {
addMessage({
id: crypto.randomUUID(),
role: 'assistant',
content: result.interpretation || '分析完成,请查看右侧结果面板。',
artifactType: 'result',
recordId: recordId || undefined, // 关联到分析记录
recordId: rid,
createdAt: new Date().toISOString(),
};
addMessage(resultMessage);
});
return result;
} catch (error: any) {
initialSteps.forEach((step, i) => {
if (step.status === 'running' || step.status === 'pending') {
updateTraceStep(i, { status: 'failed' });
}
});
// 提取 R 服务返回的具体错误信息
updateRecord(rid, { status: 'error' });
const errorData = error.response?.data;
const errorMessage = errorData?.user_hint || errorData?.error ||
(error instanceof Error ? error.message : '执行出错');
setError(errorMessage);
throw new Error(errorMessage);
} finally {
setExecuting(false);
}
},
[
currentSession,
addMessage,
setExecutionResult,
setTraceSteps,
updateTraceStep,
setExecuting,
setError,
]
[currentSession, addMessage, setExecuting, setError, updateRecord]
);
const executeAnalysis = useCallback(async (): Promise<ExecutionResult> => {
if (!currentPlan) {
throw new Error('请先生成分析计划');
}
return executePlan(currentPlan.id);
}, [currentPlan, executePlan]);
return executePlan('current');
}, [executePlan]);
const downloadCode = useCallback(async (): Promise<DownloadResult> => {
if (!currentSession) {
throw new Error('请先上传数据');
}
if (!currentSession) throw new Error('请先上传数据');
const response = await apiClient.get(
`${API_BASE}/sessions/${currentSession.id}/download-code`,
{ responseType: 'blob' }
);
const contentDisposition = response.headers['content-disposition'];
let filename = `analysis_${currentSession.id}.R`;
if (contentDisposition) {
const filenameMatch = contentDisposition.match(/filename[^;=\n]*=((['"]).*?\2|[^;\n]*)/);
if (filenameMatch) {
let extractedName = filenameMatch[1].replace(/['"]/g, '');
try {
extractedName = decodeURIComponent(extractedName);
} catch {
// 解码失败,使用原始值
}
if (extractedName) {
filename = extractedName;
}
try { extractedName = decodeURIComponent(extractedName); } catch { /* ok */ }
if (extractedName) filename = extractedName;
}
}
return { blob: response.data, filename };
}, [currentSession]);
const exportReport = useCallback(async () => {
const result = useSSAStore.getState().executionResult;
const plan = useSSAStore.getState().currentPlan;
const session = useSSAStore.getState().currentSession;
const mountedFile = useSSAStore.getState().mountedFile;
const { isWorkflowMode, workflowSteps, workflowPlan, conclusionReport } = useSSAStore.getState();
if (isWorkflowMode && workflowSteps.some(s => s.status === 'success')) {
return exportWorkflowReport(workflowSteps, workflowPlan, conclusionReport, session, mountedFile);
}
if (!result) {
const state = useSSAStore.getState();
const record = state.currentRecordId
? state.analysisHistory.find((r) => r.id === state.currentRecordId) ?? null
: null;
if (!record) {
setError('暂无分析结果可导出');
return;
}
const now = new Date();
const dateStr = now.toLocaleString('zh-CN');
const pValue = result.results?.pValue ?? (result.results as any)?.p_value;
const pValueStr = pValue !== undefined
? (pValue < 0.001 ? '< 0.001' : pValue.toFixed(4))
: '-';
const groupVar = String(plan?.parameters?.groupVar || plan?.parameters?.group_var || '-');
const valueVar = String(plan?.parameters?.valueVar || plan?.parameters?.value_var || '-');
const dataFileName = mountedFile?.name || session?.title || '数据文件';
const rowCount = mountedFile?.rowCount || session?.dataSchema?.rowCount || 0;
const createTableRow = (cells: string[], isHeader = false) => {
return new TableRow({
children: cells.map(text => new TableCell({
children: [new Paragraph({
children: [new TextRun({ text, bold: isHeader })],
})],
width: { size: 100 / cells.length, type: WidthType.PERCENTAGE },
})),
});
};
const tableBorders = {
top: { style: BorderStyle.SINGLE, size: 1 },
bottom: { style: BorderStyle.SINGLE, size: 1 },
left: { style: BorderStyle.SINGLE, size: 1 },
right: { style: BorderStyle.SINGLE, size: 1 },
insideHorizontal: { style: BorderStyle.SINGLE, size: 1 },
insideVertical: { style: BorderStyle.SINGLE, size: 1 },
};
const sections: (Paragraph | Table)[] = [];
let sectionNum = 1;
sections.push(
new Paragraph({
text: '统计分析报告',
heading: HeadingLevel.TITLE,
alignment: AlignmentType.CENTER,
}),
new Paragraph({ text: '' }),
new Paragraph({ children: [
new TextRun({ text: '研究课题:', bold: true }),
new TextRun(session?.title || plan?.title || '未命名分析'),
]}),
new Paragraph({ children: [
new TextRun({ text: '生成时间:', bold: true }),
new TextRun(dateStr),
]}),
new Paragraph({ text: '' }),
const steps = record.steps.filter(
(s) => (s.status === 'success' || s.status === 'warning') && s.result
);
sections.push(
new Paragraph({
text: `${sectionNum++}. 数据描述`,
heading: HeadingLevel.HEADING_1,
}),
new Table({
width: { size: 100, type: WidthType.PERCENTAGE },
borders: tableBorders,
rows: [
createTableRow(['项目', '内容'], true),
createTableRow(['数据文件', dataFileName]),
createTableRow(['样本量', `${rowCount}`]),
createTableRow(['分组变量 (X)', groupVar]),
createTableRow(['分析变量 (Y)', valueVar]),
],
}),
new Paragraph({ text: '' }),
);
sections.push(
new Paragraph({
text: `${sectionNum++}. 分析方法`,
heading: HeadingLevel.HEADING_1,
}),
new Paragraph({
text: `本研究采用 ${result.results?.method || plan?.toolName || '统计检验'} 方法,` +
`比较 ${groupVar} 分组下 ${valueVar} 的差异。`,
}),
new Paragraph({ text: '' }),
);
if (result.guardrailResults?.length) {
sections.push(
new Paragraph({
text: `${sectionNum++}. 前提条件检验`,
heading: HeadingLevel.HEADING_1,
}),
new Table({
width: { size: 100, type: WidthType.PERCENTAGE },
borders: tableBorders,
rows: [
createTableRow(['检查项', '结果', '说明'], true),
...result.guardrailResults.map((gr: { checkName: string; passed: boolean; actionType: string; message: string }) => createTableRow([
gr.checkName,
gr.passed ? '通过' : gr.actionType === 'Switch' ? '降级' : '未通过',
gr.message,
])),
],
}),
new Paragraph({ text: '' }),
);
if (steps.length === 0) {
setError('暂无分析结果可导出');
return;
}
const resultAny = result.results as any;
const groupStats = resultAny?.groupStats || resultAny?.group_stats;
if (groupStats?.length) {
sections.push(
new Paragraph({
text: `${sectionNum++}. 描述性统计`,
heading: HeadingLevel.HEADING_1,
}),
new Table({
width: { size: 100, type: WidthType.PERCENTAGE },
borders: tableBorders,
rows: [
createTableRow(['分组', '样本量 (n)', '均值 (Mean)', '标准差 (SD)'], true),
...groupStats.map((gs: any) => createTableRow([
gs.group,
String(gs.n),
gs.mean?.toFixed(4) || '-',
gs.sd?.toFixed(4) || '-',
])),
],
}),
new Paragraph({ text: '' }),
);
}
const session = state.currentSession;
const mountedFile = state.mountedFile;
const conclusion = record.conclusionReport;
sections.push(
new Paragraph({
text: `${sectionNum++}. 统计检验结果`,
heading: HeadingLevel.HEADING_1,
}),
new Table({
width: { size: 100, type: WidthType.PERCENTAGE },
borders: tableBorders,
rows: [
createTableRow(['指标', '值'], true),
createTableRow(['统计方法', resultAny?.method || '-']),
createTableRow(['统计量 (t/F/χ²)', resultAny?.statistic?.toFixed(4) || '-']),
createTableRow(['自由度 (df)', resultAny?.df?.toFixed(2) || '-']),
createTableRow(['P 值', pValueStr]),
...(resultAny?.effectSize ? [createTableRow(['效应量', resultAny.effectSize.toFixed(3)])] : []),
...(resultAny?.confInt ? [createTableRow(['95% 置信区间', `[${resultAny.confInt[0]?.toFixed(4)}, ${resultAny.confInt[1]?.toFixed(4)}]`])] : []),
],
}),
new Paragraph({ text: '' }),
);
const plotData = result.plots?.[0];
if (plotData) {
const imageBase64 = typeof plotData === 'string' ? plotData : plotData.imageBase64;
if (imageBase64) {
try {
const base64Data = imageBase64.replace(/^data:image\/\w+;base64,/, '');
const imageBuffer = Uint8Array.from(atob(base64Data), c => c.charCodeAt(0));
sections.push(
new Paragraph({
text: `${sectionNum++}. 可视化结果`,
heading: HeadingLevel.HEADING_1,
}),
new Paragraph({
children: [
new ImageRun({
data: imageBuffer,
transformation: { width: 450, height: 300 },
type: 'png',
}),
],
alignment: AlignmentType.CENTER,
}),
new Paragraph({
text: `图 1. ${valueVar}${groupVar} 分组下的分布`,
alignment: AlignmentType.CENTER,
}),
new Paragraph({ text: '' }),
);
} catch (e) {
console.warn('图片导出失败', e);
}
}
}
sections.push(
new Paragraph({
text: `${sectionNum++}. 结论`,
heading: HeadingLevel.HEADING_1,
}),
new Paragraph({
text: result.interpretation ||
(pValue !== undefined && pValue < 0.05
? `${groupVar} 分组间的 ${valueVar} 差异具有统计学意义 (P = ${pValueStr})。`
: `${groupVar} 分组间的 ${valueVar} 差异无统计学意义 (P = ${pValueStr})。`),
}),
new Paragraph({ text: '' }),
new Paragraph({ text: '' }),
new Paragraph({
children: [
new TextRun({ text: '本报告由智能统计分析系统自动生成', italics: true, color: '666666' }),
],
}),
new Paragraph({
children: [
new TextRun({ text: `执行耗时: ${result.executionMs}ms`, italics: true, color: '666666' }),
],
}),
);
const doc = new Document({
sections: [{
children: sections,
}],
});
const dateTimeStr = `${now.getFullYear()}${String(now.getMonth() + 1).padStart(2, '0')}${String(now.getDate()).padStart(2, '0')}_${String(now.getHours()).padStart(2, '0')}${String(now.getMinutes()).padStart(2, '0')}`;
const safeFileName = dataFileName.replace(/\.(csv|xlsx|xls)$/i, '').replace(/[^a-zA-Z0-9\u4e00-\u9fa5_-]/g, '_');
const blob = await Packer.toBlob(doc);
const url = URL.createObjectURL(blob);
const a = document.createElement('a');
a.href = url;
a.download = `统计分析报告_${safeFileName}_${dateTimeStr}.docx`;
a.click();
URL.revokeObjectURL(url);
await exportWorkflowReport(steps, record.plan, conclusion, session, mountedFile);
}, [setError]);
const exportWorkflowReport = async (
@@ -603,7 +318,7 @@ export function useAnalysis(): UseAnalysisReturn {
let sectionNum = 1;
sections.push(
new Paragraph({ text: '多步骤统计分析报告', heading: HeadingLevel.TITLE, alignment: AlignmentType.CENTER }),
new Paragraph({ text: '统计分析报告', heading: HeadingLevel.TITLE, alignment: AlignmentType.CENTER }),
new Paragraph({ text: '' }),
new Paragraph({ children: [
new TextRun({ text: '研究课题:', bold: true }),
@@ -632,8 +347,7 @@ export function useAnalysis(): UseAnalysisReturn {
);
}
const successSteps = steps.filter(s => s.status === 'success' && s.result);
for (const step of successSteps) {
for (const step of steps) {
const r = step.result as any;
sections.push(
new Paragraph({ text: `${sectionNum++}. 步骤 ${step.step_number}: ${step.tool_name}`, heading: HeadingLevel.HEADING_1 }),
@@ -654,7 +368,6 @@ export function useAnalysis(): UseAnalysisReturn {
new Paragraph({ text: '' }),
);
}
if (r?.variables && typeof r.variables === 'object') {
const classifyExportVar = (v: any): 'numeric' | 'categorical' | 'unknown' => {
if (!v) return 'unknown';
@@ -669,11 +382,9 @@ export function useAnalysis(): UseAnalysisReturn {
}
return 'unknown';
};
const varEntries = Object.entries(r.variables);
const numericVars = varEntries.filter(([, v]) => classifyExportVar(v) === 'numeric');
const catVars = varEntries.filter(([, v]) => classifyExportVar(v) === 'categorical');
if (numericVars.length > 0) {
const numRows: TableRow[] = [createTableRow(['变量', 'N', '均值 ± 标准差', '中位数', 'Q1', 'Q3', '最小值', '最大值'], true)];
for (const [varName, rawVs] of numericVars) {
@@ -703,7 +414,6 @@ export function useAnalysis(): UseAnalysisReturn {
new Paragraph({ text: '' }),
);
}
if (catVars.length > 0) {
sections.push(new Paragraph({ text: '分类变量统计', heading: HeadingLevel.HEADING_2 }));
for (const [varName, rawVs] of catVars) {
@@ -753,7 +463,6 @@ export function useAnalysis(): UseAnalysisReturn {
statsRows.push(createTableRow(['效应量', esStr]));
}
if (r?.conf_int) statsRows.push(createTableRow(['95% CI', `[${r.conf_int.map((v: number) => v.toFixed(4)).join(', ')}]`]));
sections.push(new Table({ width: { size: 100, type: WidthType.PERCENTAGE }, borders: tableBorders, rows: statsRows }));
sections.push(new Paragraph({ text: '' }));
}
@@ -812,7 +521,7 @@ export function useAnalysis(): UseAnalysisReturn {
}),
new Paragraph({ text: '' }),
);
} catch (e) { /* skip */ }
} catch { /* skip */ }
}
}
}
@@ -825,11 +534,10 @@ export function useAnalysis(): UseAnalysisReturn {
new Paragraph({ text: '' }),
);
}
if (conclusion?.recommendations?.length > 0) {
sections.push(
new Paragraph({ text: `${sectionNum++}. 建议`, heading: HeadingLevel.HEADING_1 }),
...conclusion.recommendations.map((r: string) => new Paragraph({ text: `${r}` })),
...conclusion.recommendations.map((rec: string) => new Paragraph({ text: `${rec}` })),
new Paragraph({ text: '' }),
);
}
@@ -840,7 +548,7 @@ export function useAnalysis(): UseAnalysisReturn {
new TextRun({ text: '本报告由智能统计分析系统自动生成', italics: true, color: '666666' }),
]}),
new Paragraph({ children: [
new TextRun({ text: `总执行耗时: ${steps.reduce((s, st) => s + (st.duration_ms || 0), 0)}ms`, italics: true, color: '666666' }),
new TextRun({ text: `总执行耗时: ${steps.reduce((s: number, st: any) => s + (st.duration_ms || 0), 0)}ms`, italics: true, color: '666666' }),
]}),
);
@@ -852,7 +560,7 @@ export function useAnalysis(): UseAnalysisReturn {
const url = URL.createObjectURL(blob);
const a = document.createElement('a');
a.href = url;
a.download = `多步骤分析报告_${safeFileName}_${dateTimeStr}.docx`;
a.download = `统计分析报告_${safeFileName}_${dateTimeStr}.docx`;
a.click();
URL.revokeObjectURL(url);
};

View File

@@ -1,18 +1,19 @@
/**
* 多步骤工作流 Hook
* 多步骤工作流 Hook (Unified Record Architecture)
*
* Phase 2A: 处理数据画像、工作流规划、SSE 执行等
* 所有分析状态通过 addRecord / updateRecord 写入 AnalysisRecord
* 不再操作全局单例字段。
*/
import { useCallback, useRef } from 'react';
import apiClient from '@/common/api/axios';
import { getAccessToken } from '@/framework/auth/api';
import { useSSAStore } from '../stores/ssaStore';
import type { AnalysisRecord } from '../stores/ssaStore';
import type {
DataProfile,
WorkflowPlan,
WorkflowStepResult,
SSEMessage,
SSAMessage,
IntentResult,
ClarificationCardData,
} from '../types';
@@ -44,14 +45,8 @@ export function useWorkflow(): UseWorkflowReturn {
setDataProfile,
setDataProfileLoading,
dataProfileLoading,
setWorkflowPlan,
setWorkflowPlanLoading,
workflowPlanLoading,
setWorkflowSteps,
updateWorkflowStep,
setWorkflowProgress,
setConclusionReport,
setIsWorkflowMode,
setActivePane,
setWorkspaceOpen,
addMessage,
@@ -59,30 +54,30 @@ export function useWorkflow(): UseWorkflowReturn {
isExecuting,
setError,
addToast,
addRecord,
updateRecord,
} = useSSAStore();
const abortControllerRef = useRef<AbortController | null>(null);
const eventSourceRef = useRef<EventSource | null>(null);
const currentRecordIdRef = useRef<string | null>(null);
// ========== Data Profile ==========
const generateDataProfile = useCallback(async (sessionId: string): Promise<DataProfile> => {
setDataProfileLoading(true);
setError(null);
try {
const response = await apiClient.post(`${API_BASE}/workflow/profile`, { sessionId });
const profile: DataProfile = response.data.profile;
setDataProfile(profile);
const profileMessage: SSAMessage = {
addMessage({
id: crypto.randomUUID(),
role: 'assistant',
content: `数据质量核查完成:${profile.quality_grade}级 (${profile.quality_score}分)`,
artifactType: 'sap',
artifactType: 'profile',
createdAt: new Date().toISOString(),
};
addMessage(profileMessage);
});
return profile;
} catch (error: any) {
const errorMsg = error.response?.data?.message || error.message || '数据画像生成失败';
@@ -94,51 +89,43 @@ export function useWorkflow(): UseWorkflowReturn {
}
}, [setDataProfile, setDataProfileLoading, setError, addMessage, addToast]);
// ========== Workflow Plan ==========
const generateWorkflowPlan = useCallback(async (
sessionId: string,
query: string
): Promise<WorkflowPlan> => {
setWorkflowPlanLoading(true);
setError(null);
setIsWorkflowMode(true);
try {
const userMessage: SSAMessage = {
id: crypto.randomUUID(),
role: 'user',
content: query,
createdAt: new Date().toISOString(),
};
addMessage(userMessage);
const response = await apiClient.post(`${API_BASE}/workflow/plan`, {
sessionId,
userQuery: query
userQuery: query,
});
const plan: WorkflowPlan = response.data.plan;
setWorkflowPlan(plan);
const recordId = addRecord(query, plan);
currentRecordIdRef.current = recordId;
setActivePane('sap');
setWorkspaceOpen(true);
const planMessage: SSAMessage = {
addMessage({
id: crypto.randomUUID(),
role: 'assistant',
content: `已生成分析方案:${plan.title}\n共 ${plan.total_steps} 个分析步骤`,
artifactType: 'sap',
recordId,
createdAt: new Date().toISOString(),
};
addMessage(planMessage);
const confirmMessage: SSAMessage = {
});
addMessage({
id: crypto.randomUUID(),
role: 'assistant',
content: '请确认分析计划并开始执行。',
artifactType: 'confirm',
createdAt: new Date().toISOString(),
};
addMessage(confirmMessage);
});
return plan;
} catch (error: any) {
const errorMsg = error.response?.data?.message || error.message || '工作流规划失败';
@@ -148,27 +135,16 @@ export function useWorkflow(): UseWorkflowReturn {
} finally {
setWorkflowPlanLoading(false);
}
}, [
setWorkflowPlan,
setWorkflowPlanLoading,
setIsWorkflowMode,
setActivePane,
setWorkspaceOpen,
addMessage,
setError,
addToast
]);
}, [setWorkflowPlanLoading, setActivePane, setWorkspaceOpen, addMessage, setError, addToast, addRecord]);
// ========== Intent Parsing ==========
/**
* Phase Q: 解析用户意图(不直接生成计划)
*/
const parseIntent = useCallback(async (
sessionId: string,
query: string
): Promise<IntentResponse> => {
setWorkflowPlanLoading(true);
setError(null);
try {
const response = await apiClient.post(`${API_BASE}/workflow/intent`, {
sessionId,
@@ -184,9 +160,8 @@ export function useWorkflow(): UseWorkflowReturn {
}
}, [setWorkflowPlanLoading, setError]);
/**
* Phase Q: 处理用户追问回答
*/
// ========== Clarification ==========
const handleClarify = useCallback(async (
sessionId: string,
userQuery: string,
@@ -194,31 +169,28 @@ export function useWorkflow(): UseWorkflowReturn {
): Promise<IntentResponse> => {
setWorkflowPlanLoading(true);
setError(null);
try {
const response = await apiClient.post(`${API_BASE}/workflow/clarify`, {
sessionId,
userQuery,
selections,
});
const data = response.data as IntentResponse;
if (data.plan) {
setWorkflowPlan(data.plan);
const recordId = addRecord(userQuery, data.plan);
currentRecordIdRef.current = recordId;
setActivePane('sap');
setWorkspaceOpen(true);
setIsWorkflowMode(true);
addMessage({
id: crypto.randomUUID(),
role: 'assistant',
content: `已生成分析方案:${data.plan.title}\n共 ${data.plan.total_steps} 个分析步骤`,
artifactType: 'sap',
recordId,
createdAt: new Date().toISOString(),
});
}
return data;
} catch (error: any) {
const errorMsg = error.response?.data?.message || error.message || '处理追问失败';
@@ -227,70 +199,64 @@ export function useWorkflow(): UseWorkflowReturn {
} finally {
setWorkflowPlanLoading(false);
}
}, [setWorkflowPlanLoading, setError, setWorkflowPlan, setActivePane, setWorkspaceOpen, setIsWorkflowMode, addMessage]);
}, [setWorkflowPlanLoading, setError, addRecord, setActivePane, setWorkspaceOpen, addMessage]);
// ========== Workflow Execution (SSE) ==========
const executeWorkflow = useCallback(async (
_sessionId: string,
workflowId: string
): Promise<void> => {
const rid = currentRecordIdRef.current || useSSAStore.getState().currentRecordId;
if (!rid) throw new Error('No active record');
setExecuting(true);
setActivePane('execution');
setWorkflowSteps([]);
setWorkflowProgress(0);
setConclusionReport(null);
updateRecord(rid, { status: 'executing', steps: [], progress: 0, conclusionReport: null });
setError(null);
const token = getAccessToken();
return new Promise((resolve, reject) => {
const streamUrl = `${API_BASE}/workflow/${workflowId}/stream`;
abortControllerRef.current = new AbortController();
fetch(streamUrl, {
method: 'GET',
headers: {
'Authorization': `Bearer ${token}`,
'Accept': 'text/event-stream',
Authorization: `Bearer ${token}`,
Accept: 'text/event-stream',
},
signal: abortControllerRef.current.signal,
}).then(async (response) => {
if (!response.ok) {
throw new Error(`HTTP error! status: ${response.status}`);
}
})
.then(async (response) => {
if (!response.ok) throw new Error(`HTTP error! status: ${response.status}`);
const reader = response.body?.getReader();
if (!reader) throw new Error('No response body');
const reader = response.body?.getReader();
if (!reader) {
throw new Error('No response body');
}
const decoder = new TextDecoder();
let buffer = '';
const decoder = new TextDecoder();
let buffer = '';
const getSteps = (): WorkflowStepResult[] => {
const rec = useSSAStore.getState().analysisHistory.find((r) => r.id === rid);
return rec?.steps ?? [];
};
const processLine = (line: string) => {
if (line.startsWith('data:')) {
const jsonStr = line.slice(5).trim();
if (jsonStr) {
try {
const message: SSEMessage = JSON.parse(jsonStr);
handleSSEMessage(message);
} catch (e) {
console.warn('Failed to parse SSE message:', jsonStr);
}
}
}
};
const patchSteps = (steps: WorkflowStepResult[], extra?: Partial<AnalysisRecord>) => {
updateRecord(rid, { steps, ...extra });
};
const handleSSEMessage = (message: SSEMessage) => {
// 兼容后端的驼峰命名和顶层字段
const toolCode = message.toolCode || message.data?.tool_code || '';
const toolName = message.toolName || message.data?.tool_name || '';
const stepNumber = message.step;
const handleSSEMessage = (message: SSEMessage) => {
const toolCode = message.toolCode || message.data?.tool_code || '';
const toolName = message.toolName || message.data?.tool_name || '';
const stepNumber = message.step;
switch (message.type) {
case 'step_start':
if (stepNumber !== undefined) {
const stepResult: WorkflowStepResult = {
switch (message.type) {
case 'step_start': {
if (stepNumber === undefined) break;
const cur = getSteps();
if (cur.some((s) => s.step_number === stepNumber)) break;
const newStep: WorkflowStepResult = {
step_number: stepNumber,
tool_code: toolCode,
tool_name: toolName,
@@ -298,143 +264,197 @@ export function useWorkflow(): UseWorkflowReturn {
started_at: new Date().toISOString(),
logs: message.message ? [message.message] : [],
};
const currentSteps = useSSAStore.getState().workflowSteps;
// 避免重复添加
if (!currentSteps.some(s => s.step_number === stepNumber)) {
setWorkflowSteps([...currentSteps, stepResult]);
}
patchSteps([...cur, newStep]);
break;
}
break;
case 'step_progress':
if (stepNumber !== undefined && message.message) {
updateWorkflowStep(stepNumber, {
logs: (useSSAStore.getState().workflowSteps
.find(s => s.step_number === stepNumber)?.logs || [])
.concat(message.message),
});
case 'step_progress': {
if (stepNumber === undefined || !message.message) break;
const cur = getSteps();
patchSteps(
cur.map((s) =>
s.step_number === stepNumber
? { ...s, logs: [...(s.logs || []), message.message!] }
: s
)
);
break;
}
break;
case 'step_complete':
if (stepNumber !== undefined) {
case 'step_complete': {
if (stepNumber === undefined) break;
const result = message.result || message.data?.result;
const durationMs = message.duration_ms || message.durationMs || message.data?.duration_ms;
const reportBlocks = message.reportBlocks
|| (result as any)?.report_blocks
|| message.data?.reportBlocks;
updateWorkflowStep(stepNumber, {
status: message.status || message.data?.status || 'success',
completed_at: new Date().toISOString(),
duration_ms: durationMs,
result: result,
reportBlocks: reportBlocks || undefined,
});
const reportBlocks = message.reportBlocks || (result as any)?.report_blocks || message.data?.reportBlocks;
const totalSteps = message.total_steps || message.totalSteps || 2;
const progress = (stepNumber / totalSteps) * 100;
setWorkflowProgress(progress);
}
break;
case 'step_error':
if (stepNumber !== undefined) {
updateWorkflowStep(stepNumber, {
status: 'failed',
completed_at: new Date().toISOString(),
error: message.error || message.message,
const cur = getSteps();
patchSteps(
cur.map((s) =>
s.step_number === stepNumber
? {
...s,
status: message.status || message.data?.status || 'success',
completed_at: new Date().toISOString(),
duration_ms: durationMs,
result,
reportBlocks: reportBlocks || undefined,
}
: s
),
{ progress }
);
break;
}
case 'step_error': {
if (stepNumber === undefined) break;
const rawErr = message.error || message.message;
const errStr =
typeof rawErr === 'object'
? (rawErr as any)?.userHint || (rawErr as any)?.message || JSON.stringify(rawErr)
: String(rawErr || '执行失败');
const cur = getSteps();
patchSteps(
cur.map((s) =>
s.step_number === stepNumber
? { ...s, status: 'failed', completed_at: new Date().toISOString(), error: errStr }
: s
)
);
break;
}
case 'workflow_complete': {
setExecuting(false);
const rawStatus = (message as any).status ?? (message as any).data?.status;
const isError = rawStatus === 'error' || rawStatus === 'failed';
const finalSteps = getSteps();
const hasAnySuccess = finalSteps.some(
(s) => s.status === 'success' || s.status === 'warning'
);
if (isError && !hasAnySuccess) {
updateRecord(rid, { status: 'error', progress: 0 });
const firstErr = finalSteps.find((s) => s.status === 'failed')?.error;
let errText = '执行过程中发生错误';
if (firstErr) {
errText =
typeof firstErr === 'object'
? String((firstErr as any)?.userHint || (firstErr as any)?.message || errText)
: String(firstErr);
}
addMessage({
id: crypto.randomUUID(),
role: 'assistant',
content: `分析执行失败:${errText}`,
artifactType: 'execution',
recordId: rid,
createdAt: new Date().toISOString(),
});
addToast('工作流执行失败', 'error');
resolve();
break;
}
const conclusion = message.conclusion || null;
updateRecord(rid, {
status: 'completed',
progress: 100,
conclusionReport: conclusion,
});
}
break;
case 'workflow_complete':
setWorkflowProgress(100);
setExecuting(false);
if (message.conclusion) {
setConclusionReport(message.conclusion);
setActivePane('result');
const completeMessage: SSAMessage = {
id: crypto.randomUUID(),
role: 'assistant',
content: `分析完成!${message.conclusion.executive_summary?.slice(0, 100) || '查看右侧结果面板获取详细信息'}`,
artifactType: 'result',
createdAt: new Date().toISOString(),
};
addMessage(completeMessage);
} else {
// 即使没有 conclusion也标记为完成
const completeMessage: SSAMessage = {
id: crypto.randomUUID(),
role: 'assistant',
content: '分析执行完成!',
artifactType: 'result',
createdAt: new Date().toISOString(),
};
addMessage(completeMessage);
if (conclusion) {
setActivePane('result');
addMessage({
id: crypto.randomUUID(),
role: 'assistant',
content: `分析完成!${conclusion.executive_summary?.slice(0, 100) || '查看右侧结果面板获取详细信息'}`,
artifactType: 'result',
recordId: rid,
createdAt: new Date().toISOString(),
});
} else if (hasAnySuccess) {
addMessage({
id: crypto.randomUUID(),
role: 'assistant',
content: '分析执行完成!',
artifactType: 'result',
recordId: rid,
createdAt: new Date().toISOString(),
});
} else {
addMessage({
id: crypto.randomUUID(),
role: 'assistant',
content: '分析执行完成,但未生成结论报告。',
createdAt: new Date().toISOString(),
});
}
addToast('工作流执行完成', 'success');
resolve();
break;
}
addToast('工作流执行完成', 'success');
resolve();
break;
case 'workflow_error': {
const rawWfErr = message.error || '工作流执行失败';
const wfErrStr =
typeof rawWfErr === 'object'
? (rawWfErr as any)?.userHint || (rawWfErr as any)?.message || JSON.stringify(rawWfErr)
: String(rawWfErr);
updateRecord(rid, { status: 'error' });
setError(wfErrStr);
addToast(wfErrStr, 'error');
setExecuting(false);
reject(new Error(wfErrStr));
break;
}
case 'workflow_error':
const errorMsg = message.error || '工作流执行失败';
setError(errorMsg);
addToast(errorMsg, 'error');
setExecuting(false);
reject(new Error(errorMsg));
break;
case 'connected':
break;
}
};
case 'connected':
// 连接确认消息,忽略
break;
const processLine = (line: string) => {
if (line.startsWith('data:')) {
const jsonStr = line.slice(5).trim();
if (jsonStr) {
try {
handleSSEMessage(JSON.parse(jsonStr));
} catch (e) {
console.warn('Failed to parse SSE message:', jsonStr);
}
}
}
};
while (true) {
const { done, value } = await reader.read();
if (done) break;
buffer += decoder.decode(value, { stream: true });
const lines = buffer.split('\n');
buffer = lines.pop() || '';
for (const line of lines) processLine(line);
}
};
while (true) {
const { done, value } = await reader.read();
if (done) break;
buffer += decoder.decode(value, { stream: true });
const lines = buffer.split('\n');
buffer = lines.pop() || '';
for (const line of lines) {
processLine(line);
if (buffer) processLine(buffer);
})
.catch((error) => {
if (error.name === 'AbortError') {
addToast('工作流已取消', 'info');
resolve();
} else {
const errorMsg = error.message || '工作流执行失败';
setError(errorMsg);
addToast(errorMsg, 'error');
reject(error);
}
}
if (buffer) {
processLine(buffer);
}
}).catch((error) => {
if (error.name === 'AbortError') {
addToast('工作流已取消', 'info');
resolve();
} else {
const errorMsg = error.message || '工作流执行失败';
setError(errorMsg);
addToast(errorMsg, 'error');
reject(error);
}
setExecuting(false);
});
setExecuting(false);
});
});
}, [
setExecuting,
setActivePane,
setWorkflowSteps,
setWorkflowProgress,
setConclusionReport,
updateWorkflowStep,
addMessage,
setError,
addToast
]);
}, [setExecuting, setActivePane, addMessage, setError, addToast, updateRecord]);
// ========== Cancel ==========
const cancelWorkflow = useCallback(() => {
if (abortControllerRef.current) {

View File

@@ -1,17 +1,15 @@
/**
* SSA 状态管理 - Zustand Store
* SSA 状态管理 - Zustand Store (Unified Record Architecture)
*
* V11 版本 - 完全还原原型图设计
* 支持多任务模式:同一会话中可进行多次分析
* 核心思想: 一次分析 = 一个 Record = N 个 Steps
* 所有分析状态统一存储在 AnalysisRecord 中,
* 通过 currentRecordId 派生当前记录,消除全局单例。
*/
import { create } from 'zustand';
import type {
SSAMode,
SSASession,
SSAMessage,
AnalysisPlan,
ExecutionResult,
TraceStep,
DataProfile,
WorkflowPlan,
WorkflowStepResult,
@@ -32,23 +30,25 @@ interface Toast {
type: 'info' | 'success' | 'error';
}
/** 分析记录 - 支持多任务 */
export type RecordStatus = 'planning' | 'executing' | 'completed' | 'error';
/** 统一分析记录 — 无论单步 / 多步,全部走此模型 */
export interface AnalysisRecord {
id: string;
query: string;
createdAt: string;
plan: AnalysisPlan;
executionResult: ExecutionResult | null;
traceSteps: TraceStep[];
status: RecordStatus;
plan: WorkflowPlan | null;
steps: WorkflowStepResult[];
progress: number;
conclusionReport: ConclusionReport | null;
}
interface SSAState {
mode: SSAMode;
currentSession: SSASession | null;
messages: SSAMessage[];
currentPlan: AnalysisPlan | null;
executionResult: ExecutionResult | null;
traceSteps: TraceStep[];
isLoading: boolean;
isExecuting: boolean;
error: string | null;
@@ -61,29 +61,19 @@ interface SSAState {
workspaceOpen: boolean;
toasts: Toast[];
// 多任务支持
analysisHistory: AnalysisRecord[];
currentRecordId: string | null;
// Phase 2A: 多步骤工作流状态
dataProfile: DataProfile | null;
dataProfileLoading: boolean;
dataProfileModalVisible: boolean;
workflowPlan: WorkflowPlan | null;
workflowPlanLoading: boolean;
workflowSteps: WorkflowStepResult[];
workflowProgress: number; // 0-100
conclusionReport: ConclusionReport | null;
isWorkflowMode: boolean; // 是否使用多步骤工作流模式
// ---- actions ----
setMode: (mode: SSAMode) => void;
setCurrentSession: (session: SSASession | null) => void;
addMessage: (message: SSAMessage) => void;
setMessages: (messages: SSAMessage[]) => void;
setCurrentPlan: (plan: AnalysisPlan | null) => void;
setExecutionResult: (result: ExecutionResult | null) => void;
setTraceSteps: (steps: TraceStep[]) => void;
updateTraceStep: (index: number, step: Partial<TraceStep>) => void;
setLoading: (loading: boolean) => void;
setExecuting: (executing: boolean) => void;
setError: (error: string | null) => void;
@@ -99,33 +89,22 @@ interface SSAState {
removeToast: (id: string) => void;
hydrateFromHistory: (session: SSASession) => void;
// 多任务操作
addAnalysisRecord: (query: string, plan: AnalysisPlan) => string;
updateAnalysisRecord: (id: string, update: Partial<Omit<AnalysisRecord, 'id'>>) => void;
selectAnalysisRecord: (id: string) => void;
getCurrentRecord: () => AnalysisRecord | null;
// Record operations (unified)
addRecord: (query: string, plan: WorkflowPlan) => string;
updateRecord: (id: string, patch: Partial<Omit<AnalysisRecord, 'id'>>) => void;
selectRecord: (id: string) => void;
// Phase 2A: 多步骤工作流操作
// Data profile
setDataProfile: (profile: DataProfile | null) => void;
setDataProfileLoading: (loading: boolean) => void;
setDataProfileModalVisible: (visible: boolean) => void;
setWorkflowPlan: (plan: WorkflowPlan | null) => void;
setWorkflowPlanLoading: (loading: boolean) => void;
setWorkflowSteps: (steps: WorkflowStepResult[]) => void;
updateWorkflowStep: (stepNumber: number, update: Partial<WorkflowStepResult>) => void;
setWorkflowProgress: (progress: number) => void;
setConclusionReport: (report: ConclusionReport | null) => void;
setIsWorkflowMode: (isWorkflow: boolean) => void;
resetWorkflow: () => void;
}
const initialState = {
mode: 'analysis' as SSAMode,
currentSession: null,
messages: [],
currentPlan: null,
executionResult: null,
traceSteps: [],
isLoading: false,
isExecuting: false,
error: null,
@@ -137,59 +116,29 @@ const initialState = {
toasts: [] as Toast[],
analysisHistory: [] as AnalysisRecord[],
currentRecordId: null as string | null,
// Phase 2A: 多步骤工作流初始状态
dataProfile: null as DataProfile | null,
dataProfileLoading: false,
dataProfileModalVisible: false,
workflowPlan: null as WorkflowPlan | null,
workflowPlanLoading: false,
workflowSteps: [] as WorkflowStepResult[],
workflowProgress: 0,
conclusionReport: null as ConclusionReport | null,
isWorkflowMode: false,
};
export const useSSAStore = create<SSAState>((set) => ({
...initialState,
setMode: (mode) => set({ mode }),
setCurrentSession: (session) => set({ currentSession: session }),
addMessage: (message) =>
set((state) => ({ messages: [...state.messages, message] })),
setMessages: (messages) => set({ messages }),
setCurrentPlan: (plan) => set({ currentPlan: plan }),
setExecutionResult: (result) => set({ executionResult: result }),
setTraceSteps: (steps) => set({ traceSteps: steps }),
updateTraceStep: (index, step) =>
set((state) => ({
traceSteps: state.traceSteps.map((s, i) =>
i === index ? { ...s, ...step } : s
),
})),
setLoading: (loading) => set({ isLoading: loading }),
setExecuting: (executing) => set({ isExecuting: executing }),
setError: (error) => set({ error }),
reset: () => set(initialState),
setActivePane: (pane) => set({ activePane: pane }),
setMountedFile: (file) => set({ mountedFile: file }),
setCodeModalVisible: (visible) => set({ codeModalVisible: visible }),
setSidebarExpanded: (expanded) => set({ sidebarExpanded: expanded }),
setWorkspaceOpen: (open) => set({ workspaceOpen: open }),
addToast: (message, type = 'info') =>
@@ -206,142 +155,72 @@ export const useSSAStore = create<SSAState>((set) => ({
})),
hydrateFromHistory: (session) => {
if (session.executionResult) {
set({
activePane: 'result',
executionResult: session.executionResult,
currentSession: session,
workspaceOpen: true,
});
} else if (session.currentPlan) {
set({
activePane: 'sap',
currentPlan: session.currentPlan,
currentSession: session,
workspaceOpen: true,
});
} else {
set({
activePane: 'empty',
currentSession: session,
workspaceOpen: false,
});
}
set({
currentSession: session,
workspaceOpen: false,
activePane: 'empty',
});
if (session.dataSchema) {
set({
mountedFile: {
name: session.title || 'data.csv',
size: 0,
rowCount: session.dataSchema.rowCount,
}
},
});
}
},
// 添加新的分析记录
addAnalysisRecord: (query, plan) => {
const recordId = plan.id || `record_${Date.now()}`;
// ==================== Record operations ====================
addRecord: (query, plan) => {
const recordId = plan.workflow_id || `record_${Date.now()}`;
const newRecord: AnalysisRecord = {
id: recordId,
query,
createdAt: new Date().toISOString(),
status: 'planning',
plan,
executionResult: null,
traceSteps: [],
steps: [],
progress: 0,
conclusionReport: null,
};
set((state) => ({
analysisHistory: [...state.analysisHistory, newRecord],
currentRecordId: recordId,
currentPlan: plan,
executionResult: null,
traceSteps: [],
}));
return recordId;
},
// 更新分析记录(如执行结果)
updateAnalysisRecord: (id, update) => {
set((state) => {
const updatedHistory = state.analysisHistory.map((record) =>
record.id === id ? { ...record, ...update } : record
);
// 如果更新的是当前记录,同步更新当前状态
const isCurrentRecord = state.currentRecordId === id;
return {
analysisHistory: updatedHistory,
...(isCurrentRecord && update.executionResult !== undefined
? { executionResult: update.executionResult }
: {}),
...(isCurrentRecord && update.traceSteps !== undefined
? { traceSteps: update.traceSteps }
: {}),
};
});
},
// 选择/切换到某个分析记录
selectAnalysisRecord: (id) => {
set((state) => {
const record = state.analysisHistory.find((r) => r.id === id);
if (!record) return state;
return {
currentRecordId: id,
currentPlan: record.plan,
executionResult: record.executionResult,
traceSteps: record.traceSteps,
activePane: record.executionResult ? 'result' : 'sap',
workspaceOpen: true,
};
});
},
// 获取当前记录(使用 get 方法避免循环引用)
getCurrentRecord: (): AnalysisRecord | null => {
return null; // 此方法在组件中通过直接访问 state 实现
},
// Phase 2A: 多步骤工作流操作
setDataProfile: (profile) => set({ dataProfile: profile }),
setDataProfileLoading: (loading) => set({ dataProfileLoading: loading }),
setDataProfileModalVisible: (visible) => set({ dataProfileModalVisible: visible }),
setWorkflowPlan: (plan) => set({ workflowPlan: plan }),
setWorkflowPlanLoading: (loading) => set({ workflowPlanLoading: loading }),
setWorkflowSteps: (steps) => set({ workflowSteps: steps }),
updateWorkflowStep: (stepNumber, update) =>
updateRecord: (id, patch) => {
set((state) => ({
workflowSteps: state.workflowSteps.map((s) =>
s.step_number === stepNumber ? { ...s, ...update } : s
analysisHistory: state.analysisHistory.map((r) =>
r.id === id ? { ...r, ...patch } : r
),
})),
}));
},
setWorkflowProgress: (progress) => set({ workflowProgress: progress }),
setConclusionReport: (report) => set({ conclusionReport: report }),
setIsWorkflowMode: (isWorkflow) => set({ isWorkflowMode: isWorkflow }),
resetWorkflow: () =>
selectRecord: (id) => {
set({
dataProfile: null,
dataProfileLoading: false,
workflowPlan: null,
workflowPlanLoading: false,
workflowSteps: [],
workflowProgress: 0,
conclusionReport: null,
isWorkflowMode: false,
}),
currentRecordId: id,
workspaceOpen: true,
});
},
// Data profile
setDataProfile: (profile) => set({ dataProfile: profile }),
setDataProfileLoading: (loading) => set({ dataProfileLoading: loading }),
setDataProfileModalVisible: (visible) => set({ dataProfileModalVisible: visible }),
setWorkflowPlanLoading: (loading) => set({ workflowPlanLoading: loading }),
}));
// ==================== Derived selectors ====================
/** Get the currently selected record (call inside component / callback) */
export function getCurrentRecord(): AnalysisRecord | null {
const { analysisHistory, currentRecordId } = useSSAStore.getState();
if (!currentRecordId) return null;
return analysisHistory.find((r) => r.id === currentRecordId) ?? null;
}
export default useSSAStore;

View File

@@ -561,6 +561,19 @@
font-size: 14px;
font-weight: 700;
color: #1e40af;
display: flex;
align-items: center;
gap: 6px;
}
.sap-card-badge {
font-size: 10px;
font-weight: 600;
color: #059669;
background: #ecfdf5;
border: 1px solid #a7f3d0;
border-radius: 4px;
padding: 1px 6px;
}
.sap-card-hint {
@@ -2723,238 +2736,213 @@
Phase 2A: 工作流时间线样式
============================================ */
.workflow-timeline {
padding: 20px;
/* ============================================
WorkflowTimeline v2 — wt-* namespace
============================================ */
.wt-root {
padding: 24px 20px;
}
.workflow-timeline .timeline-header {
/* --- Header --- */
.wt-header {
display: flex;
gap: 14px;
align-items: flex-start;
margin-bottom: 20px;
padding-bottom: 16px;
padding-bottom: 18px;
border-bottom: 1px solid #e2e8f0;
}
.workflow-timeline .header-info {
margin-bottom: 12px;
.wt-header-icon {
width: 40px;
height: 40px;
border-radius: 10px;
background: linear-gradient(135deg, #6366f1 0%, #8b5cf6 100%);
color: white;
display: flex;
align-items: center;
justify-content: center;
flex-shrink: 0;
}
.workflow-timeline .timeline-title {
.wt-header-body {
flex: 1;
min-width: 0;
}
.wt-title {
margin: 0;
font-size: 16px;
font-weight: 600;
color: #1e293b;
line-height: 1.4;
font-size: 17px;
font-weight: 700;
color: #0f172a;
line-height: 1.35;
}
.workflow-timeline .timeline-description {
margin: 8px 0 0 0;
.wt-desc {
margin: 6px 0 0;
font-size: 13px;
color: #64748b;
line-height: 1.6;
word-break: break-word;
overflow-wrap: break-word;
}
.workflow-timeline .header-meta {
.wt-meta {
display: flex;
gap: 16px;
margin-top: 0;
gap: 14px;
margin-top: 10px;
}
.workflow-timeline .step-count,
.workflow-timeline .estimated-time {
.wt-meta-item {
display: inline-flex;
align-items: center;
gap: 5px;
font-size: 12px;
color: #94a3b8;
display: flex;
align-items: center;
gap: 4px;
font-weight: 500;
}
.workflow-timeline .timeline-progress {
/* --- Banners --- */
.wt-banner {
display: flex;
align-items: center;
gap: 10px;
padding: 10px 14px;
margin-bottom: 14px;
font-size: 12.5px;
border-radius: 8px;
line-height: 1.5;
}
.wt-banner b {
font-weight: 600;
}
.wt-banner-warn {
color: #92400e;
background: #fffbeb;
border: 1px solid #fde68a;
}
.wt-banner-guard {
color: #1e40af;
background: #eff6ff;
border: 1px solid #bfdbfe;
}
/* --- Progress --- */
.wt-progress-bar-wrap {
display: flex;
align-items: center;
gap: 12px;
margin-bottom: 20px;
padding: 12px 16px;
background: #f8fafc;
border-radius: 8px;
margin-bottom: 18px;
}
.workflow-timeline .progress-bar {
.wt-progress-track {
flex: 1;
height: 6px;
height: 5px;
background: #e2e8f0;
border-radius: 3px;
overflow: hidden;
}
.workflow-timeline .progress-fill {
.wt-progress-fill {
height: 100%;
background: linear-gradient(90deg, #3b82f6 0%, #2563eb 100%);
background: linear-gradient(90deg, #6366f1, #3b82f6);
border-radius: 3px;
transition: width 0.3s ease;
transition: width .4s ease;
}
.workflow-timeline .progress-text {
.wt-progress-label {
font-size: 12px;
font-weight: 500;
font-weight: 600;
color: #64748b;
white-space: nowrap;
}
.workflow-timeline .timeline-steps {
/* --- Steps container --- */
.wt-steps {
display: flex;
flex-direction: column;
}
.workflow-timeline .timeline-step {
/* --- Step row (rail + card) --- */
.wt-step-row {
display: flex;
gap: 16px;
padding-bottom: 20px;
gap: 14px;
min-height: 48px;
}
.wt-step-row.wt-current .wt-card {
box-shadow: 0 0 0 2px #bfdbfe;
}
.workflow-timeline .timeline-step.current {
background: #eff6ff;
margin: 0 -20px;
padding: 16px 20px;
border-radius: 8px;
}
.workflow-timeline .step-connector {
/* Rail */
.wt-rail {
display: flex;
flex-direction: column;
align-items: center;
width: 24px;
width: 28px;
flex-shrink: 0;
}
.workflow-timeline .step-dot {
width: 24px;
height: 24px;
.wt-dot {
width: 28px;
height: 28px;
border-radius: 50%;
border: 2px solid;
display: flex;
align-items: center;
justify-content: center;
font-size: 12px;
font-weight: 600;
font-weight: 700;
flex-shrink: 0;
}
.workflow-timeline .step-dot.pulse {
.wt-dot.running {
animation: pulse 1.5s ease-in-out infinite;
}
.workflow-timeline .step-line {
.wt-line {
flex: 1;
width: 2px;
width: 0;
min-height: 16px;
border-left: 2px dashed #e2e8f0;
margin-top: 4px;
margin: 4px 0;
}
.workflow-timeline .step-content {
/* Card */
.wt-card {
flex: 1;
min-width: 0;
background: white;
border: 1px solid #e2e8f0;
border-left: 3px solid #e2e8f0;
border-radius: 10px;
padding: 14px 16px;
margin-bottom: 12px;
transition: box-shadow .2s, border-color .2s;
}
.workflow-timeline .step-header {
.wt-card:hover {
box-shadow: 0 2px 8px rgba(0,0,0,.05);
}
.wt-card-head {
display: flex;
align-items: center;
justify-content: space-between;
gap: 8px;
}
.wt-card-title-row {
display: flex;
align-items: center;
gap: 8px;
margin-bottom: 6px;
min-width: 0;
}
.workflow-timeline .step-number {
font-size: 12px;
font-weight: 500;
color: #64748b;
.wt-step-badge {
display: inline-flex;
align-items: center;
padding: 2px 8px;
font-size: 11px;
font-weight: 600;
color: #6366f1;
background: #eef2ff;
border-radius: 4px;
white-space: nowrap;
}
.workflow-timeline .tool-icon {
font-size: 14px;
}
.workflow-timeline .tool-name {
.wt-tool-name {
font-size: 14px;
font-weight: 600;
color: #1e293b;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.workflow-timeline .step-duration {
font-size: 11px;
color: #94a3b8;
margin-left: auto;
}
.workflow-timeline .step-description {
font-size: 13px;
color: #64748b;
margin-bottom: 8px;
}
.workflow-timeline .step-params {
display: flex;
flex-wrap: wrap;
gap: 6px;
}
.workflow-timeline .param-tag {
padding: 2px 8px;
background: #f1f5f9;
border-radius: 4px;
font-size: 11px;
color: #64748b;
font-family: 'SF Mono', Monaco, monospace;
}
.workflow-timeline .step-result-preview {
display: flex;
gap: 8px;
margin-top: 8px;
}
.workflow-timeline .result-badge {
padding: 2px 8px;
background: #f1f5f9;
border-radius: 4px;
font-size: 11px;
color: #64748b;
font-family: 'SF Mono', Monaco, monospace;
}
.workflow-timeline .significant-badge {
padding: 2px 8px;
background: #ecfdf5;
border-radius: 4px;
font-size: 11px;
color: #059669;
font-weight: 500;
}
.workflow-timeline .step-error {
display: flex;
align-items: flex-start;
gap: 6px;
margin-top: 8px;
padding: 8px;
background: #fef2f2;
border-radius: 6px;
}
.workflow-timeline .error-icon {
flex-shrink: 0;
}
.workflow-timeline .error-message {
font-size: 12px;
color: #dc2626;
}
.workflow-timeline .timeline-footer {
padding-top: 16px;
text-align: center;
}
.workflow-timeline .sensitivity-badge {
display: inline-block;
.wt-sensitivity {
display: inline-flex;
padding: 1px 8px;
font-size: 11px;
font-weight: 600;
@@ -2962,12 +2950,28 @@
background: #fffbeb;
border: 1px solid #fcd34d;
border-radius: 10px;
margin-left: 6px;
white-space: nowrap;
}
.wt-duration {
font-size: 11px;
color: #94a3b8;
font-family: 'SF Mono', Monaco, monospace;
white-space: nowrap;
}
.workflow-timeline .step-guardrail {
margin-top: 4px;
padding: 4px 8px;
/* Card body */
.wt-card-desc {
margin: 8px 0 0;
font-size: 12.5px;
color: #64748b;
line-height: 1.5;
}
.wt-guardrail-inline {
display: flex;
align-items: center;
gap: 6px;
margin-top: 8px;
padding: 5px 10px;
font-size: 12px;
color: #1d4ed8;
background: #eff6ff;
@@ -2975,45 +2979,83 @@
border-radius: 4px;
}
.workflow-timeline .epv-warning-banner {
display: flex;
align-items: center;
gap: 8px;
padding: 10px 14px;
margin-bottom: 12px;
font-size: 13px;
color: #92400e;
background: #fffbeb;
border: 1px solid #fcd34d;
/* Params grid */
.wt-params-grid {
display: grid;
grid-template-columns: auto 1fr;
gap: 4px 10px;
margin-top: 10px;
padding: 10px 12px;
background: #f8fafc;
border: 1px solid #f1f5f9;
border-radius: 8px;
}
.workflow-timeline .epv-warning-banner .epv-icon {
font-size: 16px;
flex-shrink: 0;
.wt-param-item {
display: contents;
}
.workflow-timeline .guardrail-banner {
display: flex;
align-items: center;
gap: 8px;
padding: 10px 14px;
margin-bottom: 12px;
font-size: 13px;
color: #1e40af;
background: #eff6ff;
border: 1px solid #93c5fd;
border-radius: 8px;
.wt-param-label {
font-size: 11px;
font-weight: 600;
color: #475569;
white-space: nowrap;
line-height: 1.8;
}
.workflow-timeline .guardrail-banner .guardrail-icon {
font-size: 16px;
flex-shrink: 0;
}
.workflow-timeline .ready-hint {
font-size: 13px;
.wt-param-val {
font-size: 11.5px;
color: #64748b;
font-family: 'SF Mono', Monaco, monospace;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
line-height: 1.8;
}
/* Result / Error */
.wt-result-row {
display: flex;
gap: 8px;
margin-top: 10px;
}
.wt-p-badge {
padding: 3px 10px;
background: #f1f5f9;
border-radius: 6px;
font-size: 12px;
color: #475569;
font-family: 'SF Mono', Monaco, monospace;
font-weight: 500;
}
.wt-sig-badge {
padding: 3px 10px;
background: #dcfce7;
border-radius: 6px;
font-size: 12px;
color: #15803d;
font-weight: 600;
}
.wt-error-row {
display: flex;
align-items: flex-start;
gap: 8px;
margin-top: 10px;
padding: 8px 12px;
background: #fef2f2;
border-radius: 6px;
font-size: 12px;
color: #dc2626;
line-height: 1.5;
}
/* Footer */
.wt-footer {
display: flex;
align-items: center;
justify-content: center;
gap: 8px;
padding: 16px 0 4px;
font-size: 13px;
color: #94a3b8;
font-weight: 500;
}
/* ============================================

View File

@@ -83,7 +83,7 @@ export interface AnalysisPlan {
dependentVar?: string;
}
export type ArtifactType = 'sap' | 'confirm' | 'execution' | 'result';
export type ArtifactType = 'sap' | 'confirm' | 'execution' | 'result' | 'profile';
export interface SSAMessage {
id: string;

View File

@@ -187,6 +187,16 @@ function(req, tool_code) {
# 解析请求体
input <- jsonlite::fromJSON(req$postBody, simplifyVector = FALSE)
# 记录传入参数(便于调试)
param_names <- if (!is.null(input$params)) paste(names(input$params), collapse=", ") else "NULL"
message(glue::glue("[Skill:{tool_code}] params keys: [{param_names}]"))
if (!is.null(input$params$variables)) {
message(glue::glue("[Skill:{tool_code}] variables ({length(input$params$variables)}): [{paste(input$params$variables, collapse=', ')}]"))
}
if (!is.null(input$params$group_var)) {
message(glue::glue("[Skill:{tool_code}] group_var: {input$params$group_var}"))
}
# Debug 模式:保留临时文件用于排查
debug_mode <- isTRUE(input$debug)
@@ -248,7 +258,7 @@ function(req, tool_code) {
return(result)
}, error = function(e) {
# 使用友好错误映射
message(glue::glue("[Skill:{tool_code}] ERROR: {e$message}"))
return(map_r_error(e$message))
})
}

View File

@@ -31,14 +31,25 @@ run_analysis <- function(input) {
log_add(glue("数据加载成功: {nrow(df)} 行, {ncol(df)} 列"))
p <- input$params
variables <- p$variables # 变量列表(可选,空则分析全部)
group_var <- p$group_var # 分组变量(可选)
variables <- p$variables
group_var <- p$group_var
# Normalize group_var: ensure it's NULL or a valid non-empty string (never NA)
if (is.null(group_var) || length(group_var) == 0 || isTRUE(is.na(group_var)) || !nzchar(trimws(as.character(group_var[1])))) {
group_var <- NULL
} else {
group_var <- as.character(group_var[1])
}
log_add(glue("=== 输入参数 === variables: [{paste(variables, collapse=', ')}], group_var: {ifelse(is.null(group_var), 'NULL', group_var)}"))
log_add(glue("=== 数据列 === [{paste(names(df), collapse=', ')}]"))
# ===== 确定要分析的变量 =====
if (is.null(variables) || length(variables) == 0) {
variables <- names(df)
log_add("未指定变量,分析全部列")
}
variables <- as.character(variables)
# 排除分组变量本身
if (!is.null(group_var) && group_var %in% variables) {
@@ -48,13 +59,15 @@ run_analysis <- function(input) {
# 校验变量存在性
missing_vars <- setdiff(variables, names(df))
if (length(missing_vars) > 0) {
log_add(glue("缺失变量: [{paste(missing_vars, collapse=', ')}]"))
return(make_error(ERROR_CODES$E001_COLUMN_NOT_FOUND,
col = paste(missing_vars, collapse = ", ")))
}
log_add(glue("最终分析变量 ({length(variables)}): [{paste(variables, collapse=', ')}]"))
# 校验分组变量
groups <- NULL
if (!is.null(group_var) && group_var != "") {
if (!is.null(group_var)) {
if (!(group_var %in% names(df))) {
return(make_error(ERROR_CODES$E001_COLUMN_NOT_FOUND, col = group_var))
}
@@ -63,25 +76,32 @@ run_analysis <- function(input) {
}
# ===== 变量类型推断 =====
var_types <- sapply(variables, function(v) {
vals <- df[[v]]
if (is.numeric(vals)) {
non_na_count <- sum(!is.na(vals))
if (non_na_count == 0) {
return("categorical") # 全是 NA当作分类变量
}
unique_count <- length(unique(vals[!is.na(vals)]))
unique_ratio <- unique_count / non_na_count
if (unique_ratio < 0.05 && unique_count <= 10) {
var_types <- tryCatch({
result <- sapply(variables, function(v) {
vals <- df[[v]]
if (is.null(vals)) return("categorical")
if (isTRUE(is.numeric(vals))) {
non_na_count <- sum(!is.na(vals))
if (non_na_count == 0) return("categorical")
unique_count <- length(unique(vals[!is.na(vals)]))
unique_ratio <- unique_count / non_na_count
if (isTRUE(unique_ratio < 0.05) && isTRUE(unique_count <= 10)) {
return("categorical")
}
return("numeric")
} else {
return("categorical")
}
return("numeric")
} else {
return("categorical")
}
})
if (is.null(names(result))) names(result) <- variables
result
}, error = function(e) {
log_add(paste("变量类型推断失败:", e$message))
setNames(rep("categorical", length(variables)), variables)
})
log_add(glue("数值变量: {sum(var_types == 'numeric')}, 分类变量: {sum(var_types == 'categorical')}"))
log_add(glue("数值变量: {sum(var_types == 'numeric', na.rm=TRUE)}, 分类变量: {sum(var_types == 'categorical', na.rm=TRUE)}"))
log_add(glue("var_types 详情: {paste(names(var_types), '=', var_types, collapse=', ')}"))
# ===== 计算描述性统计 =====
warnings_list <- c()
@@ -106,7 +126,8 @@ run_analysis <- function(input) {
# 有分组
group_stats <- list()
for (g in groups) {
subset_vals <- df[df[[group_var]] == g, v, drop = TRUE]
mask <- df[[group_var]] == g & !is.na(df[[group_var]])
subset_vals <- df[mask, v, drop = TRUE]
if (identical(var_type, "numeric")) {
group_stats[[as.character(g)]] <- calc_numeric_stats(subset_vals, v)
} else {
@@ -145,7 +166,7 @@ run_analysis <- function(input) {
for (v in vars_to_plot) {
plot_base64 <- tryCatch({
if (var_types[v] == "numeric") {
if (isTRUE(var_types[v] == "numeric")) {
generate_histogram(df, v, group_var)
} else {
generate_bar_chart(df, v, group_var)
@@ -167,6 +188,67 @@ run_analysis <- function(input) {
"data.csv"
}
# Build dynamic visualization code based on actual variables
plot_code_section <- tryCatch({
plot_code_lines <- c()
for (v in vars_to_plot) {
safe_v <- gsub('"', '\\\\"', v)
vt <- if (is.null(var_types) || is.na(var_types[v])) "categorical" else as.character(var_types[v])
safe_var_name <- gsub("[^a-zA-Z0-9]", "_", v)
if (vt == "numeric") {
if (!is.null(group_var) && group_var != "") {
safe_g <- gsub('"', '\\\\"', group_var)
plot_code_lines <- c(plot_code_lines, glue('
# Histogram: {safe_v}
p_{safe_var_name} <- ggplot(df[!is.na(df[["{safe_v}"]]), ], aes(x = .data[["{safe_v}"]], fill = factor(.data[["{safe_g}"]]))) +
geom_histogram(alpha = 0.6, position = "identity", bins = 30) +
scale_fill_brewer(palette = "Set1", name = "{safe_g}") +
labs(title = "Distribution of {safe_v}", x = "{safe_v}", y = "Count") +
theme_minimal()
print(p_{safe_var_name})
'))
} else {
plot_code_lines <- c(plot_code_lines, glue('
# Histogram: {safe_v}
p_{safe_var_name} <- ggplot(df[!is.na(df[["{safe_v}"]]), ], aes(x = .data[["{safe_v}"]])) +
geom_histogram(fill = "#3b82f6", alpha = 0.7, bins = 30) +
labs(title = "Distribution of {safe_v}", x = "{safe_v}", y = "Count") +
theme_minimal()
print(p_{safe_var_name})
'))
}
} else {
if (!is.null(group_var) && group_var != "") {
safe_g <- gsub('"', '\\\\"', group_var)
plot_code_lines <- c(plot_code_lines, glue('
# Bar chart: {safe_v}
p_{safe_var_name} <- ggplot(df[!is.na(df[["{safe_v}"]]), ], aes(x = factor(.data[["{safe_v}"]]), fill = factor(.data[["{safe_g}"]]))) +
geom_bar(position = "dodge") +
scale_fill_brewer(palette = "Set1", name = "{safe_g}") +
labs(title = "Frequency of {safe_v}", x = "{safe_v}", y = "Count") +
theme_minimal() +
theme(axis.text.x = element_text(angle = 45, hjust = 1))
print(p_{safe_var_name})
'))
} else {
plot_code_lines <- c(plot_code_lines, glue('
# Bar chart: {safe_v}
p_{safe_var_name} <- ggplot(df[!is.na(df[["{safe_v}"]]), ], aes(x = factor(.data[["{safe_v}"]]))) +
geom_bar(fill = "#3b82f6", alpha = 0.7) +
labs(title = "Frequency of {safe_v}", x = "{safe_v}", y = "Count") +
theme_minimal() +
theme(axis.text.x = element_text(angle = 45, hjust = 1))
print(p_{safe_var_name})
'))
}
}
}
paste(plot_code_lines, collapse = "\n")
}, error = function(e) {
log_add(paste("reproducible_code visualization generation failed:", e$message))
"# ggplot(df, aes(x = your_variable)) + geom_histogram()"
})
reproducible_code <- glue('
# SSA-Pro 自动生成代码
# 工具: 描述性统计
@@ -181,7 +263,7 @@ df <- read.csv("{original_filename}")
# 数值变量描述性统计
numeric_vars <- sapply(df, is.numeric)
if (any(numeric_vars)) {{
summary(df[, numeric_vars, drop = FALSE])
print(summary(df[, numeric_vars, drop = FALSE]))
}}
# 分类变量频数表
@@ -193,8 +275,8 @@ if (any(categorical_vars)) {{
}}
}}
# 可视化示例
# ggplot(df, aes(x = your_variable)) + geom_histogram()
# ======== 可视化 ========
{plot_code_section}
')
# ===== 返回结果 =====