feat(ssa): SSA Agent mode MVP - prompt management + Phase 5A guardrails + UX enhancements

Backend:
- Agent core prompts (Planner + Coder) now loaded from PromptService with 3-tier fallback (DB -> cache -> hardcoded)
- Seed script (seed-ssa-agent-prompts.ts) for idempotent SSA_AGENT_PLANNER + SSA_AGENT_CODER setup
- SSA fallback prompts added to prompt.fallbacks.ts
- Phase 5A: XML tag extraction, defensive programming prompt, high-fidelity schema injection, AST pre-check
- Default agent mode migration + session CRUD (rename/delete) APIs
- R Docker: structured error handling (20+ patterns) + AST syntax pre-check

Frontend:
- Default agent mode (QPER toggle removed), view code fix, analysis result cards in chat
- Session history sidebar with inline rename/delete, robust plan parsing from reviewResult
- R code export wrapper for local reproducibility (package checks + data loader + polyfills)
- SSA workspace CSS updates for sidebar actions and plan display

Docs:
- SSA module doc v4.2: Prompt inventory (2 Agent active / 11 QPER archived), dev progress updated
- System overview doc v6.8: SSA Agent MVP milestone
- Deployment checklist: DB-5 (seed script) + BE-10 (prompt management)

Made-with: Cursor
This commit is contained in:
2026-03-08 15:23:09 +08:00
parent c681155de2
commit ac724266c1
24 changed files with 1598 additions and 140 deletions

View File

@@ -69,7 +69,63 @@ export const AgentCodePanel: React.FC<AgentCodePanelProps> = ({ onAction, action
);
}
const { status, planText, planSteps, generatedCode, partialCode, errorMessage, retryCount, durationMs } = agentExecution;
const { status, planText, planSteps: rawPlanSteps, generatedCode, partialCode, errorMessage, retryCount, durationMs } = agentExecution;
// 防御性:从 planText JSON 解析步骤(支持 steps / plan.steps绝不展示原始 JSON
const planSteps = React.useMemo(() => {
if (rawPlanSteps && rawPlanSteps.length > 0) return rawPlanSteps;
if (!planText) return undefined;
// 尝试直接 parse或去除 ```json 包裹后 parse
const tryParse = (text: string) => {
try { return JSON.parse(text); } catch { return null; }
};
let parsed = tryParse(planText);
if (!parsed) {
const m = planText.match(/```(?:json)?\s*([\s\S]*?)```/);
if (m) parsed = tryParse(m[1].trim());
}
if (!parsed) return undefined;
const stepsArray = Array.isArray(parsed?.steps)
? parsed.steps
: Array.isArray(parsed?.plan?.steps)
? parsed.plan.steps
: null;
if (stepsArray?.length) {
return stepsArray.map((s: any) => ({
order: s.order ?? 0,
method: s.method ?? '',
description: s.description ?? '',
rationale: s.rationale,
}));
}
return undefined;
}, [rawPlanSteps, planText]);
// 解析计划的标题/研究类型等元信息
const planMeta = React.useMemo(() => {
if (!planText) return null;
const tryParse = (text: string) => {
try { return JSON.parse(text); } catch { return null; }
};
let parsed = tryParse(planText);
if (!parsed) {
const m = planText.match(/```(?:json)?\s*([\s\S]*?)```/);
if (m) parsed = tryParse(m[1].trim());
}
if (!parsed) return null;
return {
title: parsed.title as string | undefined,
designType: parsed.designType as string | undefined,
variables: parsed.variables as {
outcome?: string[];
predictors?: string[];
grouping?: string | null;
confounders?: string[];
} | undefined,
};
}, [planText]);
const isStreamingCode = status === 'coding' && !!partialCode;
const displayCode = isStreamingCode ? partialCode : (generatedCode || partialCode);
@@ -97,19 +153,48 @@ export const AgentCodePanel: React.FC<AgentCodePanelProps> = ({ onAction, action
<span className="badge-done"></span>
)}
</div>
{/* 计划元信息(标题、研究类型、变量) */}
{planMeta && (
<div className="agent-plan-meta">
{planMeta.title && <div className="plan-meta-title">{planMeta.title}</div>}
<div className="plan-meta-tags">
{planMeta.designType && <span className="plan-tag design">{planMeta.designType}</span>}
{planMeta.variables?.outcome?.map(v => (
<span key={v} className="plan-tag outcome">: {v}</span>
))}
{planMeta.variables?.predictors?.map(v => (
<span key={v} className="plan-tag predictor">: {v}</span>
))}
{planMeta.variables?.grouping && (
<span className="plan-tag grouping">: {planMeta.variables.grouping}</span>
)}
{planMeta.variables?.confounders?.map(v => (
<span key={v} className="plan-tag confounder">: {v}</span>
))}
</div>
</div>
)}
{/* 分析步骤(友好展示,不展示原始 JSON */}
{planSteps && planSteps.length > 0 && (
<div className="agent-plan-steps">
{planSteps.map((s, i) => (
<div key={i} className="plan-step-item">
<span className="step-num">{s.order}</span>
<span className="step-method">{s.method}</span>
<span className="step-desc">{s.description}</span>
<div className="plan-step-body">
<span className="step-method">{s.method}</span>
<span className="step-desc">{s.description}</span>
{s.rationale && <span className="step-rationale">{s.rationale}</span>}
</div>
</div>
))}
</div>
)}
{planText && !planSteps?.length && (
<div className="agent-plan-text">{planText}</div>
{planText && (!planSteps || planSteps.length === 0) && (
<div className="agent-plan-unparseable">
</div>
)}
{/* 计划确认操作按钮 */}

View File

@@ -43,7 +43,6 @@ import { ClarificationCard } from './ClarificationCard';
import { AskUserCard } from './AskUserCard';
import type { AskUserResponseData } from './AskUserCard';
import { ThinkingBlock } from '@/shared/components/Chat';
import { ModeToggle } from './ModeToggle';
import type { ClarificationCardData, IntentResult } from '../types';
export const SSAChatPane: React.FC = () => {
@@ -104,7 +103,7 @@ export const SSAChatPane: React.FC = () => {
}, [currentSession?.id, loadHistory, clearMessages]);
// 方案 B: 注册 agentActionHandler让右侧工作区按钮能触发 Agent 操作
const { setAgentActionHandler, selectAgentExecution, agentExecutionHistory } = useSSAStore();
const { setAgentActionHandler, selectAgentExecution } = useSSAStore();
useEffect(() => {
if (currentSession?.id) {
setAgentActionHandler((action: string) =>
@@ -299,7 +298,6 @@ export const SSAChatPane: React.FC = () => {
</span>
</div>
<div className="chat-header-right">
{currentSession && <ModeToggle />}
<EngineStatus
isExecuting={isExecuting}
isLoading={isLoading || isPlanLoading}
@@ -390,13 +388,38 @@ export const SSAChatPane: React.FC = () => {
{/* Phase II: 流式对话消息(来自 useSSAChat */}
{chatMessages.map((msg: ChatMessage) => {
const isSystemAudit = (msg as any).intent === 'system';
const isSystemAudit = msg.intent === 'system';
// 系统审计消息(方案 B右侧操作的审计纪要
if (isSystemAudit) {
return (
<div key={msg.id} className="message message-system slide-up">
<div className="system-audit-msg">{msg.content}</div>
{msg.executionId && (
<button
className="sap-card agent-result-card"
onClick={() => {
selectAgentExecution(msg.executionId!);
setWorkspaceOpen(true);
}}
>
<div className="sap-card-left">
<div className="sap-card-icon">
<BarChart2 size={16} />
</div>
<div className="sap-card-content">
<div className="sap-card-title">
<span className="sap-card-badge"></span>
</div>
<div className="sap-card-hint">
{msg.executionQuery || '点击打开工作区查看详情'}
</div>
</div>
</div>
<ArrowRight size={16} className="sap-card-arrow" />
</button>
)}
</div>
);
}
@@ -448,27 +471,6 @@ export const SSAChatPane: React.FC = () => {
);
})}
{/* 时光机卡片Agent 执行历史(点击可切换右侧工作区) */}
{agentExecutionHistory.filter(e => e.status === 'completed').length > 1 && (
<div className="agent-history-cards slide-up">
{agentExecutionHistory
.filter(e => e.status === 'completed')
.slice(0, -1)
.map(exec => (
<button
key={exec.id}
className="agent-history-card"
onClick={() => selectAgentExecution(exec.id)}
>
<BarChart2 size={14} />
<span className="agent-history-query">{exec.query}</span>
<span className="agent-history-badge"></span>
<ArrowRight size={12} />
</button>
))}
</div>
)}
{/* 数据画像生成中指示器 */}
{dataProfileLoading && (
<div className="message message-ai slide-up">
@@ -678,6 +680,7 @@ const INTENT_LABELS: Record<ChatIntentType, string> = {
analyze: '统计分析',
discuss: '结果讨论',
feedback: '结果改进',
system: '系统',
};
const EngineStatus: React.FC<EngineStatusProps> = ({

View File

@@ -1,12 +1,154 @@
/**
* SSACodeModal - R 代码模态框 (Unified Record Architecture)
*
* 从 currentRecord.steps 聚合所有步骤的可复现代码。
* 导出时自动包裹 Export Wrapper
* 1. 数据加载层read.csv / read_excel + 文件名自动填入)
* 2. 平台辅助函数 Polyfillmake_*_block → 本地 print / 保存 PNG
* 3. Agent 生成的核心分析代码
*
* 在线查看显示原始代码;下载/复制时注入 Wrapper。
*/
import React, { useEffect, useState } from 'react';
import { X, Download, Loader2 } from 'lucide-react';
import React, { useEffect, useState, useMemo } from 'react';
import { X, Download, Copy, Loader2 } from 'lucide-react';
import { useSSAStore } from '../stores/ssaStore';
/**
* 从 R 代码中提取所有 library(xxx) 调用的包名
*/
function extractPackages(code: string): string[] {
const pkgs = new Set<string>();
const re = /library\((\w+)\)/g;
let m: RegExpExecArray | null;
while ((m = re.exec(code)) !== null) pkgs.add(m[1]);
return [...pkgs];
}
/**
* 生成本地复现 Wrapper数据加载 + Polyfill + 包检查
*/
function buildExportWrapper(agentCode: string, fileName: string, query: string): string {
const packages = extractPackages(agentCode);
const pkgVector = packages.map(p => `"${p}"`).join(', ');
const dataLoader = `# =====================================================================
# SSA-Pro 智能统计分析 — 本地复现脚本
# 分析任务: ${query}
# 导出时间: ${new Date().toLocaleString('zh-CN')}
# =====================================================================
# 使用说明:
# 1. 将本脚本与数据文件放在同一目录
# 2. 在 RStudio 中打开,设置工作目录为脚本所在目录
# 3. 全选运行 (Ctrl+A → Ctrl+Enter)
# 4. 图表将自动保存为 PNG 文件到当前目录
# =====================================================================
# ── 1. 环境检查:缺失包提示 ──
required_packages <- c(${pkgVector})
missing_packages <- required_packages[!(required_packages %in% installed.packages()[,"Package"])]
if (length(missing_packages) > 0) {
stop(paste0(
"缺少以下 R 包,请先安装:\\n ",
paste(missing_packages, collapse = ", "),
"\\n\\n运行以下命令安装:\\n install.packages(c(",
paste0('"', missing_packages, '"', collapse = ", "), "))"
))
}
# ── 2. 数据加载(请确保数据文件与本脚本在同一目录) ──
.DATA_FILE <- "${fileName}"
if (!file.exists(.DATA_FILE)) {
stop(paste0(
"找不到数据文件: ", .DATA_FILE, "\\n",
"请将数据文件复制到脚本所在目录,或修改上方 .DATA_FILE 变量为正确路径。"
))
}
if (grepl("\\\\.csv$", .DATA_FILE, ignore.case = TRUE)) {
df <- read.csv(.DATA_FILE, stringsAsFactors = FALSE, fileEncoding = "UTF-8")
} else if (grepl("\\\\.xlsx?$", .DATA_FILE, ignore.case = TRUE)) {
if (!requireNamespace("readxl", quietly = TRUE)) {
stop("读取 Excel 文件需要 readxl 包,请运行: install.packages(\\"readxl\\")")
}
df <- readxl::read_excel(.DATA_FILE)
df <- as.data.frame(df)
} else {
stop(paste0("不支持的文件格式: ", .DATA_FILE, "(仅支持 .csv / .xlsx"))
}
cat(paste0("✓ 数据已加载: ", nrow(df), " 行 × ", ncol(df), " 列\\n"))`;
const polyfills = `
# ── 3. 平台辅助函数 — 本地兼容层 (Polyfill) ──
# 以下函数在平台沙箱中用于构建 UI 报告块,
# 本地运行时替换为控制台输出 + 图片文件保存。
make_markdown_block <- function(content, title = NULL) {
cat("\\n")
if (!is.null(title)) cat("【", title, "】\\n")
cat(content, "\\n")
invisible(list(type = "markdown", content = content))
}
make_table_block <- function(headers, rows, title = NULL, footnote = NULL) {
cat("\\n")
if (!is.null(title)) cat("── ", title, " ──\\n")
mat <- do.call(rbind, rows)
colnames(mat) <- headers
print(as.data.frame(mat, stringsAsFactors = FALSE))
if (!is.null(footnote)) cat("注: ", footnote, "\\n")
invisible(list(type = "table"))
}
make_table_block_from_df <- function(df_arg, title = NULL, footnote = NULL, digits = NULL) {
cat("\\n")
if (!is.null(title)) cat("── ", title, " ──\\n")
if (!is.null(digits)) {
nums <- sapply(df_arg, is.numeric)
df_arg[nums] <- lapply(df_arg[nums], round, digits = digits)
}
print(df_arg, row.names = FALSE)
if (!is.null(footnote)) cat("注: ", footnote, "\\n")
invisible(list(type = "table"))
}
make_image_block <- function(base64_data, title = "", alt = "") {
# 将 base64 图片数据解码并保存为本地 PNG 文件
tryCatch({
raw_b64 <- sub("^data:image/[^;]+;base64,", "", base64_data)
safe_name <- gsub("[^a-zA-Z0-9_\\u4e00-\\u9fa5]", "_", title)
if (nchar(safe_name) == 0 || nchar(safe_name) > 60) safe_name <- "plot"
out_file <- paste0(safe_name, ".png")
# 避免同名覆盖
counter <- 1
while (file.exists(out_file)) {
out_file <- paste0(safe_name, "_", counter, ".png")
counter <- counter + 1
}
writeBin(base64enc::base64decode(raw_b64), out_file)
cat(paste0("✓ 图表已保存: ", out_file, "\\n"))
}, error = function(e) {
cat(paste0("✗ 图表保存失败: ", e$message, "\\n"))
})
invisible(list(type = "image"))
}
make_kv_block <- function(items, title = NULL) {
cat("\\n")
if (!is.null(title)) cat("── ", title, " ──\\n")
for (nm in names(items)) {
cat(sprintf(" %s: %s\\n", nm, items[[nm]]))
}
invisible(list(type = "key_value"))
}`;
const coreSection = `
# =====================================================================
# 4. 核心分析代码(由 AI Agent 生成,无需修改)
# =====================================================================`;
return [dataLoader, polyfills, coreSection, agentCode].join('\n\n');
}
export const SSACodeModal: React.FC = () => {
const {
codeModalVisible,
@@ -16,9 +158,10 @@ export const SSACodeModal: React.FC = () => {
analysisHistory,
executionMode,
agentExecution,
mountedFile,
} = useSSAStore();
const [code, setCode] = useState<string>('');
const [rawCode, setRawCode] = useState<string>('');
const [isLoading, setIsLoading] = useState(false);
const record = currentRecordId
@@ -30,8 +173,7 @@ export const SSACodeModal: React.FC = () => {
setIsLoading(true);
try {
if (executionMode === 'agent' && agentExecution?.generatedCode) {
const header = `# ========================================\n# Agent 生成的 R 代码\n# 分析任务: ${agentExecution.query || '统计分析'}\n# ========================================\n`;
setCode(header + agentExecution.generatedCode);
setRawCode(agentExecution.generatedCode);
} else {
const steps = record?.steps ?? [];
const successSteps = steps.filter(
@@ -45,9 +187,9 @@ export const SSACodeModal: React.FC = () => {
return header + (stepCode || '# 该步骤暂无可用代码');
})
.join('\n\n');
setCode(allCode);
setRawCode(allCode);
} else {
setCode('# 暂无可用代码\n# 请先执行分析');
setRawCode('');
}
}
} finally {
@@ -55,6 +197,22 @@ export const SSACodeModal: React.FC = () => {
}
}, [codeModalVisible, record, executionMode, agentExecution]);
const fileName = mountedFile?.name || 'data.csv';
const query = agentExecution?.query || record?.query || '统计分析';
// 带 Wrapper 的完整导出代码(用于下载和复制)
const exportCode = useMemo(() => {
if (!rawCode) return '# 暂无可用代码\n# 请先执行分析';
if (executionMode === 'agent') {
return buildExportWrapper(rawCode, fileName, query);
}
// QPER 模式:原样输出(已含 reproducible_code
return rawCode;
}, [rawCode, executionMode, fileName, query]);
// 在线预览:显示带 Wrapper 的完整代码
const displayCode = exportCode;
if (!codeModalVisible) return null;
const handleClose = () => setCodeModalVisible(false);
@@ -74,14 +232,14 @@ export const SSACodeModal: React.FC = () => {
const handleDownload = () => {
try {
const blob = new Blob([code], { type: 'text/plain' });
const blob = new Blob([exportCode], { type: 'text/plain; charset=utf-8' });
const url = URL.createObjectURL(blob);
const a = document.createElement('a');
a.href = url;
a.download = generateFilename();
a.click();
URL.revokeObjectURL(url);
addToast('R 脚本已下载', 'success');
addToast('R 脚本已下载(含本地复现环境)', 'success');
handleClose();
} catch {
addToast('下载失败', 'error');
@@ -89,8 +247,8 @@ export const SSACodeModal: React.FC = () => {
};
const handleCopy = () => {
navigator.clipboard.writeText(code);
addToast('代码已复制', 'success');
navigator.clipboard.writeText(exportCode);
addToast('代码已复制(含本地复现环境)', 'success');
};
return (
@@ -114,16 +272,17 @@ export const SSACodeModal: React.FC = () => {
</div>
) : (
<pre className="code-block">
<code>{code}</code>
<code>{displayCode}</code>
</pre>
)}
</div>
<footer className="code-modal-footer">
<button className="copy-btn" onClick={handleCopy} disabled={isLoading}>
<button className="copy-btn" onClick={handleCopy} disabled={isLoading || !rawCode}>
<Copy size={14} />
</button>
<button className="download-btn" onClick={handleDownload} disabled={isLoading}>
<button className="download-btn" onClick={handleDownload} disabled={isLoading || !rawCode}>
<Download size={14} />
.R
</button>

View File

@@ -6,12 +6,14 @@
* - Logo + 新建按钮
* - 历史记录列表(展开时显示)
*/
import React, { useState, useEffect } from 'react';
import React, { useState, useEffect, useCallback } from 'react';
import {
Menu,
BarChart3,
Plus,
MessageSquare
MessageSquare,
Pencil,
Trash2,
} from 'lucide-react';
import { useSSAStore } from '../stores/ssaStore';
import apiClient from '@/common/api/axios';
@@ -19,8 +21,9 @@ import type { SSASession } from '../types';
interface HistoryItem {
id: string;
title: string;
status: 'active' | 'completed' | 'archived';
title: string | null;
status: string;
updatedAt: string;
}
export const SSASidebar: React.FC = () => {
@@ -29,14 +32,22 @@ export const SSASidebar: React.FC = () => {
setSidebarExpanded,
currentSession,
hydrateFromHistory,
setCurrentSession,
reset
} = useSSAStore();
const [historyItems, setHistoryItems] = useState<HistoryItem[]>([]);
const [isLoading, setIsLoading] = useState(false);
const [editingId, setEditingId] = useState<string | null>(null);
const [editingTitle, setEditingTitle] = useState('');
const [deleteConfirmId, setDeleteConfirmId] = useState<string | null>(null);
useEffect(() => {
if (sidebarExpanded) {
fetchHistory();
}, [currentSession?.id]);
useEffect(() => {
if (sidebarExpanded && historyItems.length === 0) {
fetchHistory();
}
}, [sidebarExpanded]);
@@ -62,9 +73,8 @@ export const SSASidebar: React.FC = () => {
reset();
};
const handleSelectSession = async (sessionId: string) => {
const handleSelectSession = useCallback(async (sessionId: string) => {
if (sessionId === currentSession?.id) return;
try {
const response = await apiClient.get(`/api/v1/ssa/sessions/${sessionId}`);
const session: SSASession = response.data;
@@ -72,7 +82,57 @@ export const SSASidebar: React.FC = () => {
} catch (error) {
console.error('Failed to load session:', error);
}
};
}, [currentSession?.id, hydrateFromHistory]);
const handleRename = useCallback((item: HistoryItem) => {
setEditingId(item.id);
setEditingTitle((item.title || '未命名分析').replace(/\.(csv|xlsx?)$/i, ''));
}, []);
const saveRename = useCallback(async () => {
if (!editingId || !editingTitle.trim()) {
setEditingId(null);
return;
}
try {
await apiClient.patch(`/api/v1/ssa/sessions/${editingId}`, {
title: editingTitle.trim(),
});
setHistoryItems((prev) =>
prev.map((s) => (s.id === editingId ? { ...s, title: editingTitle.trim() } : s)),
);
if (currentSession?.id === editingId) {
setCurrentSession({ ...currentSession, title: editingTitle.trim() } as SSASession);
}
} catch (e) {
console.error('Failed to rename session:', e);
} finally {
setEditingId(null);
}
}, [editingId, editingTitle, currentSession, setCurrentSession]);
const handleDelete = useCallback(async (sessionId: string) => {
setDeleteConfirmId(sessionId);
}, []);
const confirmDelete = useCallback(async () => {
if (!deleteConfirmId) return;
try {
await apiClient.delete(`/api/v1/ssa/sessions/${deleteConfirmId}`);
setHistoryItems((prev) => prev.filter((s) => s.id !== deleteConfirmId));
if (currentSession?.id === deleteConfirmId) {
reset();
}
} catch (e) {
console.error('Failed to delete session:', e);
} finally {
setDeleteConfirmId(null);
}
}, [deleteConfirmId, currentSession?.id, reset]);
const cancelDelete = useCallback(() => {
setDeleteConfirmId(null);
}, []);
return (
<aside className={`ssa-sidebar ${sidebarExpanded ? 'expanded' : ''}`}>
@@ -116,16 +176,79 @@ export const SSASidebar: React.FC = () => {
) : historyItems.length === 0 ? (
<div className="history-empty"></div>
) : (
historyItems.map((item) => (
<button
key={item.id}
className={`history-item ${item.id === currentSession?.id ? 'active' : ''}`}
onClick={() => handleSelectSession(item.id)}
>
<MessageSquare size={14} className="history-item-icon" />
<span className="history-item-title">{item.title}</span>
</button>
))
<>
{historyItems.map((item) => {
const isActive = item.id === currentSession?.id;
const isEditing = editingId === item.id;
const isDeleting = deleteConfirmId === item.id;
const displayTitle = (item.title || '未命名分析').replace(/\.(csv|xlsx?)$/i, '');
if (isEditing) {
return (
<div key={item.id} className="history-item history-item-edit">
<input
className="history-item-input"
value={editingTitle}
onChange={(e) => setEditingTitle(e.target.value)}
onKeyDown={(e) => {
if (e.key === 'Enter') saveRename();
if (e.key === 'Escape') setEditingId(null);
}}
onBlur={saveRename}
autoFocus
aria-label="编辑名称"
/>
</div>
);
}
if (isDeleting) {
return (
<div key={item.id} className="history-delete-confirm">
<span>{displayTitle}</span>
<div className="history-delete-btns">
<button type="button" onClick={cancelDelete}></button>
<button type="button" className="danger" onClick={confirmDelete}></button>
</div>
</div>
);
}
return (
<div
key={item.id}
className={`history-item-wrapper ${isActive ? 'active' : ''}`}
>
<button
className="history-item"
onClick={() => handleSelectSession(item.id)}
title={item.title || '未命名分析'}
>
<MessageSquare size={14} className="history-item-icon" />
<span className="history-item-title">{displayTitle}</span>
</button>
<div className="history-item-actions">
<button
type="button"
className="history-action-btn"
onClick={(e) => { e.stopPropagation(); handleRename(item); }}
title="重命名"
>
<Pencil size={12} />
</button>
<button
type="button"
className="history-action-btn danger"
onClick={(e) => { e.stopPropagation(); handleDelete(item.id); }}
title="删除"
>
<Trash2 size={12} />
</button>
</div>
</div>
);
})}
</>
)}
</div>
</div>

View File

@@ -230,14 +230,8 @@ export const SSAWorkspacePane: React.FC = () => {
}
};
const [showAgentCode, setShowAgentCode] = useState(false);
const handleExportCode = () => {
if (executionMode === 'agent' && agentExecution?.generatedCode) {
setShowAgentCode(prev => !prev);
} else {
setCodeModalVisible(true);
}
setCodeModalVisible(true);
};
const scrollToSection = (section: 'sap' | 'execution' | 'result') => {

View File

@@ -23,7 +23,7 @@ import type { WorkflowPlan } from '../types';
// Types
// ────────────────────────────────────────────
export type ChatIntentType = 'chat' | 'explore' | 'consult' | 'analyze' | 'discuss' | 'feedback';
export type ChatIntentType = 'chat' | 'explore' | 'consult' | 'analyze' | 'discuss' | 'feedback' | 'system';
export interface ChatMessage {
id: string;
@@ -33,6 +33,10 @@ export interface ChatMessage {
intent?: ChatIntentType;
status?: 'complete' | 'generating' | 'error';
createdAt: string;
/** Agent 执行 ID用于在对话中渲染可点击的结果卡片 */
executionId?: string;
/** Agent 执行的查询摘要 */
executionQuery?: string;
}
export interface IntentMeta {
@@ -134,20 +138,45 @@ export function useSSAChat(): UseSSAChatReturn {
if (!resp.ok) return;
const data = await resp.json();
const loaded: ChatMessage[] = [];
if (data.messages?.length > 0) {
const loaded: ChatMessage[] = data.messages
.filter((m: any) => m.status !== 'generating')
.map((m: any) => ({
id: m.id,
role: m.role,
content: m.content || '',
thinking: m.thinkingContent,
intent: m.intent,
status: m.status || 'complete',
createdAt: m.createdAt,
}));
setChatMessages(loaded);
loaded.push(
...data.messages
.filter((m: any) => m.status !== 'generating')
.map((m: any) => ({
id: m.id,
role: m.role as 'user' | 'assistant',
content: m.content || '',
thinking: m.thinkingContent,
intent: m.intent,
status: (m.status || 'complete') as 'complete' | 'generating' | 'error',
createdAt: m.createdAt,
executionId: m.executionId,
executionQuery: m.executionQuery,
})),
);
}
// 从 store 中的 agentExecutionHistory 注入结果卡片
const { agentExecutionHistory } = useSSAStore.getState();
for (const exec of agentExecutionHistory) {
if (exec.status === 'completed') {
loaded.push({
id: `exec-card-${exec.id}`,
role: 'assistant',
content: `✅ 分析完成:${exec.query}`,
status: 'complete',
intent: 'system',
executionId: exec.id,
executionQuery: exec.query,
createdAt: exec.createdAt || new Date().toISOString(),
});
}
}
loaded.sort((a, b) => new Date(a.createdAt).getTime() - new Date(b.createdAt).getTime());
setChatMessages(loaded);
} catch (err) {
console.warn('[useSSAChat] Failed to load history:', err);
}
@@ -329,13 +358,27 @@ export function useSSAChat(): UseSSAChatReturn {
}
if (parsed.type === 'code_result') {
const { updateAgentExecution } = useSSAStore.getState();
const { updateAgentExecution, agentExecution: curExec } = useSSAStore.getState();
updateAgentExecution({
reportBlocks: parsed.reportBlocks,
generatedCode: parsed.code || useSSAStore.getState().agentExecution?.generatedCode,
generatedCode: parsed.code || curExec?.generatedCode,
status: 'completed',
durationMs: parsed.durationMs,
});
// 在对话中插入可点击的结果卡片
const execId = curExec?.id;
const execQuery = curExec?.query || content;
setChatMessages(prev => [...prev, {
id: crypto.randomUUID(),
role: 'assistant' as const,
content: `✅ 分析完成:${execQuery}`,
status: 'complete' as const,
intent: 'system',
executionId: execId,
executionQuery: execQuery,
createdAt: new Date().toISOString(),
}]);
continue;
}
@@ -448,7 +491,7 @@ export function useSSAChat(): UseSSAChatReturn {
role: 'assistant' as const,
content: auditContent,
status: 'complete' as const,
intent: 'system' as any,
intent: 'system',
createdAt: new Date().toISOString(),
}]);

View File

@@ -161,7 +161,7 @@ const initialState = {
dataProfileModalVisible: false,
workflowPlanLoading: false,
hasUnsavedPlanChanges: false,
executionMode: 'qper' as ExecutionMode,
executionMode: 'agent' as ExecutionMode,
agentExecution: null as AgentExecutionRecord | null,
agentExecutionHistory: [] as AgentExecutionRecord[],
agentActionHandler: null as ((action: string) => Promise<void>) | null,
@@ -234,10 +234,66 @@ export const useSSAStore = create<SSAState>((set) => ({
})),
hydrateFromHistory: (session) => {
// 从后端 agentExecutions 恢复 Agent 执行历史
const rawExecs = (session as any).agentExecutions as any[] | undefined;
const agentHistory = (rawExecs || []).map((e: any) => {
// 优先从 reviewResult结构化 JSON提取 planSteps其次 planText
let planSteps: Array<{ order: number; method: string; description: string; rationale?: string }> | undefined;
let planMeta: { title?: string; designType?: string } | undefined;
const structured = e.reviewResult;
if (structured) {
const stepsArray = Array.isArray(structured.steps) ? structured.steps : structured?.plan?.steps;
if (Array.isArray(stepsArray)) {
planSteps = stepsArray.map((s: any) => ({
order: s.order,
method: s.method,
description: s.description,
rationale: s.rationale,
}));
}
planMeta = { title: structured.title, designType: structured.designType };
}
if (!planSteps?.length && e.planText) {
try {
const parsed = JSON.parse(e.planText);
const arr = Array.isArray(parsed?.steps) ? parsed.steps : parsed?.plan?.steps;
if (Array.isArray(arr)) {
planSteps = arr.map((s: any) => ({
order: s.order, method: s.method, description: s.description, rationale: s.rationale,
}));
}
if (!planMeta) planMeta = { title: parsed.title, designType: parsed.designType };
} catch { /* not JSON */ }
}
return {
id: e.id,
sessionId: session.id,
query: e.query,
planText: structured ? JSON.stringify(structured) : e.planText,
planSteps,
generatedCode: e.generatedCode,
reportBlocks: e.reportBlocks,
retryCount: e.retryCount || 0,
status: e.status,
errorMessage: e.errorMessage,
durationMs: e.durationMs,
createdAt: e.createdAt,
};
});
// 找到最新的已完成执行作为当前展示
const latestCompleted = [...agentHistory].reverse().find(e => e.status === 'completed');
set({
currentSession: session,
workspaceOpen: false,
activePane: 'empty',
workspaceOpen: !!latestCompleted,
activePane: latestCompleted ? 'result' : 'empty',
executionMode: 'agent',
agentExecutionHistory: agentHistory,
agentExecution: latestCompleted || null,
});
if (session.dataSchema) {
set({

View File

@@ -293,6 +293,118 @@
text-overflow: ellipsis;
}
/* 历史项容器:左侧按钮 + 右侧操作图标 */
.history-item-wrapper {
position: relative;
display: flex;
align-items: center;
border-radius: 8px;
}
.history-item-wrapper .history-item {
flex: 1;
min-width: 0;
padding-right: 4px;
}
.history-item-wrapper.active .history-item {
background-color: white;
border: 1px solid #e2e8f0;
color: #1d4ed8;
font-weight: 500;
box-shadow: 0 1px 2px rgba(0,0,0,0.05);
}
/* 行内操作图标hover 时显示) */
.history-item-actions {
display: flex;
align-items: center;
gap: 2px;
flex-shrink: 0;
opacity: 0;
transition: opacity 0.15s;
position: absolute;
right: 4px;
background: linear-gradient(to right, transparent, #f1f5f9 30%);
padding-left: 16px;
padding-right: 4px;
}
.history-item-wrapper:hover .history-item-actions {
opacity: 1;
}
.history-item-wrapper.active .history-item-actions {
background: linear-gradient(to right, transparent, white 30%);
}
.history-action-btn {
padding: 4px;
border: none;
background: transparent;
color: #94a3b8;
cursor: pointer;
border-radius: 4px;
display: flex;
align-items: center;
justify-content: center;
}
.history-action-btn:hover {
color: #475569;
background: #e2e8f0;
}
.history-action-btn.danger:hover {
color: #dc2626;
background: #fef2f2;
}
/* 重命名:行内编辑 */
.history-item-edit {
padding: 6px 12px;
border-radius: 8px;
background: #f8fafc;
}
.history-item-input {
width: 100%;
padding: 6px 8px;
font-size: 14px;
border: 1px solid #e2e8f0;
border-radius: 6px;
outline: none;
}
.history-item-input:focus {
border-color: #3b82f6;
box-shadow: 0 0 0 2px rgba(59, 130, 246, 0.2);
}
/* 删除确认(替代原位置) */
.history-delete-confirm {
padding: 10px 12px;
background: #fef2f2;
border: 1px solid #fecaca;
border-radius: 8px;
font-size: 13px;
color: #991b1b;
}
.history-delete-btns {
margin-top: 8px;
display: flex;
gap: 8px;
justify-content: flex-end;
}
.history-delete-btns button {
padding: 5px 12px;
font-size: 13px;
border-radius: 6px;
border: 1px solid #e2e8f0;
background: white;
color: #334155;
cursor: pointer;
}
.history-delete-btns button.danger {
background: #dc2626;
border-color: #dc2626;
color: white;
}
.history-delete-btns button.danger:hover {
background: #b91c1c;
}
/* ========================================== */
/* 主容器 */
/* ========================================== */

View File

@@ -1405,10 +1405,34 @@
min-width: 60px;
}
.plan-step-item .plan-step-body {
display: flex;
flex-direction: column;
gap: 4px;
flex: 1;
min-width: 0;
}
.plan-step-item .step-desc {
color: #94a3b8;
}
.plan-step-item .step-rationale {
font-size: 11px;
color: #64748b;
font-style: italic;
margin-top: 2px;
}
.agent-plan-unparseable {
padding: 12px 16px;
font-size: 13px;
color: #94a3b8;
background: rgba(100, 116, 139, 0.15);
border-radius: 8px;
margin: 0 16px 12px;
}
.agent-plan-text {
padding: 4px 16px 12px;
font-size: 13px;
@@ -1417,6 +1441,61 @@
white-space: pre-wrap;
}
/* 计划元信息 */
.agent-plan-meta {
padding: 8px 16px 4px;
}
.plan-meta-title {
font-size: 14px;
font-weight: 600;
color: #e2e8f0;
margin-bottom: 8px;
line-height: 1.4;
}
.plan-meta-tags {
display: flex;
flex-wrap: wrap;
gap: 6px;
margin-bottom: 4px;
}
.plan-tag {
display: inline-flex;
align-items: center;
padding: 2px 8px;
border-radius: 4px;
font-size: 11px;
font-weight: 500;
line-height: 1.6;
}
.plan-tag.design {
background: rgba(99, 102, 241, 0.15);
color: #818cf8;
}
.plan-tag.outcome {
background: rgba(244, 63, 94, 0.15);
color: #fb7185;
}
.plan-tag.predictor {
background: rgba(34, 197, 94, 0.15);
color: #4ade80;
}
.plan-tag.grouping {
background: rgba(234, 179, 8, 0.15);
color: #facc15;
}
.plan-tag.confounder {
background: rgba(148, 163, 184, 0.15);
color: #94a3b8;
}
/* 代码区域 */
.agent-code-body {
padding: 12px 16px;

View File

@@ -391,7 +391,7 @@ export interface AgentExecutionRecord {
sessionId: string;
query: string;
planText?: string;
planSteps?: Array<{ order: number; method: string; description: string }>;
planSteps?: Array<{ order: number; method: string; description: string; rationale?: string }>;
generatedCode?: string;
partialCode?: string;
reportBlocks?: ReportBlock[];
@@ -399,6 +399,7 @@ export interface AgentExecutionRecord {
status: AgentExecutionStatus;
errorMessage?: string;
durationMs?: number;
createdAt?: string;
}
/** SSE 消息 */