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:
@@ -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>
|
||||
)}
|
||||
|
||||
{/* 计划确认操作按钮 */}
|
||||
|
||||
@@ -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> = ({
|
||||
|
||||
@@ -1,12 +1,154 @@
|
||||
/**
|
||||
* SSACodeModal - R 代码模态框 (Unified Record Architecture)
|
||||
*
|
||||
* 从 currentRecord.steps 聚合所有步骤的可复现代码。
|
||||
* 导出时自动包裹 Export Wrapper:
|
||||
* 1. 数据加载层(read.csv / read_excel + 文件名自动填入)
|
||||
* 2. 平台辅助函数 Polyfill(make_*_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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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') => {
|
||||
|
||||
@@ -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(),
|
||||
}]);
|
||||
|
||||
|
||||
@@ -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({
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
/* ========================================== */
|
||||
/* 主容器 */
|
||||
/* ========================================== */
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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 消息 */
|
||||
|
||||
Reference in New Issue
Block a user