feat(ssa): Agent channel UX optimization (Solution B) + Plan-and-Execute architecture design
SSA Agent channel improvements (12 code files, +931/-203 lines): - Solution B: left/right separation of concerns (gaze guiding + state mutex + time-travel) - JWT token refresh mechanism (ensureFreshToken) to fix HTTP 401 during pipeline - Code truncation fix: LLM maxTokens 4000->8000 + CSS max-height 60vh - Retry streaming code generation with generateCodeStream() - R Docker structured errors: 20+ pattern matching + format_agent_error + line extraction - Prompt iron rules: strict output format in CoderAgent System Prompt - parseCode robustness: XML/Markdown/inference 3-tier matching + length validation - consoleOutput type defense: handle both array and scalar from R Docker unboxedJSON - Agent progress bar sync: derive phase from agentExecution.status - Export report / view code buttons restored for Agent mode - ExecutingProgress component: real-time timer + dynamic tips + step pulse animation Architecture design (3 review reports): - Plan-and-Execute step-by-step execution architecture approved - Code accumulation strategy (R Docker stays stateless) - 5 engineering guardrails: XML tags, AST pre-check, defensive prompts, high-fidelity schema, error classification circuit breaker Docs: update SSA module status v4.1, system status v6.7, deployment changelist Made-with: Cursor
This commit is contained in:
@@ -139,6 +139,7 @@ export default async function chatRoutes(app: FastifyInstance) {
|
||||
|
||||
const result = await chatHandlerService.handleAgentMode(
|
||||
sessionId, conversationId, content.trim(), writer, placeholderMsgId,
|
||||
metadata,
|
||||
);
|
||||
|
||||
logger.info('[SSA:Chat] Agent mode request completed', {
|
||||
|
||||
@@ -29,18 +29,19 @@ export interface GeneratedCode {
|
||||
export class AgentCoderService {
|
||||
|
||||
/**
|
||||
* 非流式生成(重试场景使用)
|
||||
* 非流式生成(备用)
|
||||
*/
|
||||
async generateCode(
|
||||
sessionId: string,
|
||||
plan: AgentPlan,
|
||||
errorFeedback?: string,
|
||||
previousCode?: string,
|
||||
): Promise<GeneratedCode> {
|
||||
const dataContext = await this.buildDataContext(sessionId);
|
||||
const systemPrompt = this.buildSystemPrompt(dataContext);
|
||||
|
||||
const userMessage = errorFeedback
|
||||
? this.buildRetryMessage(plan, errorFeedback)
|
||||
? this.buildRetryMessage(plan, errorFeedback, previousCode)
|
||||
: this.buildFirstMessage(plan);
|
||||
|
||||
const messages: LLMMessage[] = [
|
||||
@@ -57,7 +58,7 @@ export class AgentCoderService {
|
||||
const adapter = LLMFactory.getAdapter(MODEL as any);
|
||||
const response = await adapter.chat(messages, {
|
||||
temperature: 0.2,
|
||||
maxTokens: 4000,
|
||||
maxTokens: 8000,
|
||||
});
|
||||
|
||||
const content = response.content || '';
|
||||
@@ -80,12 +81,13 @@ export class AgentCoderService {
|
||||
plan: AgentPlan,
|
||||
onProgress: (accumulated: string) => void,
|
||||
errorFeedback?: string,
|
||||
previousCode?: string,
|
||||
): Promise<GeneratedCode> {
|
||||
const dataContext = await this.buildDataContext(sessionId);
|
||||
const systemPrompt = this.buildSystemPrompt(dataContext);
|
||||
|
||||
const userMessage = errorFeedback
|
||||
? this.buildRetryMessage(plan, errorFeedback)
|
||||
? this.buildRetryMessage(plan, errorFeedback, previousCode)
|
||||
: this.buildFirstMessage(plan);
|
||||
|
||||
const messages: LLMMessage[] = [
|
||||
@@ -104,7 +106,7 @@ export class AgentCoderService {
|
||||
|
||||
for await (const chunk of adapter.chatStream(messages, {
|
||||
temperature: 0.2,
|
||||
maxTokens: 4000,
|
||||
maxTokens: 8000,
|
||||
})) {
|
||||
if (chunk.content) {
|
||||
fullContent += chunk.content;
|
||||
@@ -206,8 +208,12 @@ car, lmtest, survival, meta, base64enc, glue, jsonlite, cowplot
|
||||
6. 所有数字结果必须用 tryCatch 包裹,防止 NA/NaN 导致崩溃
|
||||
7. 禁止使用 pROC, nortest, exact2x2 等未安装的包
|
||||
|
||||
## 输出格式
|
||||
请在 \`\`\`r ... \`\`\` 代码块中输出完整 R 代码,代码块外简要说明。`;
|
||||
## 输出格式(铁律!违反即视为失败)
|
||||
1. 必须在 \`\`\`r ... \`\`\` 代码块中输出完整 R 代码
|
||||
2. 代码块外仅限简要说明(1-3 句话)
|
||||
3. **绝对禁止**在代码块内混入中文解释性文字或自然语言段落
|
||||
4. 代码必须是可直接执行的 R 脚本,不能有伪代码或占位符
|
||||
5. 代码最后必须返回包含 report_blocks 的 list`;
|
||||
}
|
||||
|
||||
private buildFirstMessage(plan: AgentPlan): string {
|
||||
@@ -233,30 +239,67 @@ ${plan.assumptions.join('\n') || '无特殊假设'}
|
||||
请生成完整、可直接执行的 R 代码。`;
|
||||
}
|
||||
|
||||
private buildRetryMessage(plan: AgentPlan, errorFeedback: string): string {
|
||||
return `上一次生成的 R 代码执行失败,错误信息如下:
|
||||
private buildRetryMessage(plan: AgentPlan, errorFeedback: string, previousCode?: string): string {
|
||||
const codeSection = previousCode
|
||||
? `## 上次失败的完整代码(供参考,请在此基础上修正后输出完整新代码)
|
||||
\`\`\`r
|
||||
${previousCode}
|
||||
\`\`\``
|
||||
: '';
|
||||
|
||||
return `上一次生成的 R 代码执行失败。
|
||||
|
||||
${codeSection}
|
||||
|
||||
## 错误信息
|
||||
\`\`\`
|
||||
${errorFeedback}
|
||||
\`\`\`
|
||||
|
||||
请修复代码中的问题并重新生成完整的 R 代码。
|
||||
## 分析计划(不变)
|
||||
- 标题:${plan.title}
|
||||
- 研究设计:${plan.designType}
|
||||
- 结局变量:${plan.variables.outcome.join(', ') || '未指定'}
|
||||
- 分组变量:${plan.variables.grouping || '无'}
|
||||
|
||||
原分析计划:${plan.title}
|
||||
研究设计:${plan.designType}
|
||||
结局变量:${plan.variables.outcome.join(', ') || '未指定'}
|
||||
分组变量:${plan.variables.grouping || '无'}
|
||||
|
||||
请确保:
|
||||
1. 修复上述错误
|
||||
2. 使用 tryCatch 包裹关键计算步骤
|
||||
3. 处理可能的 NA/NaN 值
|
||||
4. 保持 report_blocks 输出格式`;
|
||||
## 修复要求
|
||||
1. **仔细分析上面的错误信息**,找到报错的根本原因
|
||||
2. 针对错误原因做精确修复,输出完整的、可直接执行的 R 代码
|
||||
3. 对可能出错的关键步骤使用 tryCatch 包裹
|
||||
4. 用 safe_test 模式包裹统计检验,处理 NA/NaN/Inf
|
||||
5. 检查所有 library() 调用是否在预装包列表内
|
||||
6. 保持 report_blocks 输出格式不变`;
|
||||
}
|
||||
|
||||
private parseCode(content: string): GeneratedCode {
|
||||
const codeMatch = content.match(/```r\s*([\s\S]*?)```/);
|
||||
const code = codeMatch ? codeMatch[1].trim() : content;
|
||||
const codeMatch = content.match(/```r\s*([\s\S]*?)```/)
|
||||
|| content.match(/```R\s*([\s\S]*?)```/)
|
||||
|| content.match(/```\s*([\s\S]*?)```/);
|
||||
|
||||
let code: string;
|
||||
if (codeMatch) {
|
||||
code = codeMatch[1].trim();
|
||||
} else {
|
||||
const lines = content.split('\n');
|
||||
const rLines = lines.filter(l => {
|
||||
const t = l.trim();
|
||||
return t.startsWith('library(') || t.startsWith('df') || t.includes('<-')
|
||||
|| t.startsWith('#') || t.startsWith('blocks') || t.startsWith('list(')
|
||||
|| t.startsWith('tryCatch') || t.startsWith('result');
|
||||
});
|
||||
if (rLines.length >= 3) {
|
||||
code = content.trim();
|
||||
} else {
|
||||
throw new Error(
|
||||
'LLM 返回内容中未找到有效的 R 代码块。请确保在 ```r ... ``` 中输出代码。'
|
||||
+ ` (收到 ${content.length} 字符, 首 100 字: ${content.slice(0, 100)})`
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
if (code.length < 20) {
|
||||
throw new Error(`解析到的 R 代码过短 (${code.length} 字符),可能生成失败`);
|
||||
}
|
||||
|
||||
const packageRegex = /library\((\w+)\)/g;
|
||||
const packages: string[] = [];
|
||||
@@ -266,7 +309,8 @@ ${errorFeedback}
|
||||
}
|
||||
|
||||
const explanation = content
|
||||
.replace(/```r[\s\S]*?```/g, '')
|
||||
.replace(/```r[\s\S]*?```/gi, '')
|
||||
.replace(/```[\s\S]*?```/g, '')
|
||||
.trim()
|
||||
.slice(0, 500);
|
||||
|
||||
|
||||
@@ -409,9 +409,34 @@ export class ChatHandlerService {
|
||||
userContent: string,
|
||||
writer: StreamWriter,
|
||||
placeholderMessageId: string,
|
||||
metadata?: Record<string, any>,
|
||||
): Promise<HandleResult> {
|
||||
try {
|
||||
// 1. 检查是否有等待确认的执行记录
|
||||
const agentAction = metadata?.agentAction as string | undefined;
|
||||
|
||||
// 1. 右侧工作区按钮操作(confirm_plan / confirm_code / cancel)
|
||||
if (agentAction) {
|
||||
const activeExec = await (prisma as any).ssaAgentExecution.findFirst({
|
||||
where: { sessionId, status: { in: ['plan_pending', 'code_pending'] } },
|
||||
orderBy: { createdAt: 'desc' },
|
||||
});
|
||||
|
||||
if (!activeExec) {
|
||||
return await this.handleAgentChat(sessionId, conversationId, writer, placeholderMessageId, null);
|
||||
}
|
||||
|
||||
if (agentAction === 'confirm_plan' && activeExec.status === 'plan_pending') {
|
||||
return await this.agentStreamCode(activeExec, sessionId, conversationId, writer, placeholderMessageId);
|
||||
}
|
||||
if (agentAction === 'confirm_code' && activeExec.status === 'code_pending') {
|
||||
return await this.agentExecuteCode(activeExec, sessionId, conversationId, writer, placeholderMessageId);
|
||||
}
|
||||
if (agentAction === 'cancel') {
|
||||
return await this.agentCancel(activeExec, sessionId, conversationId, writer, placeholderMessageId);
|
||||
}
|
||||
}
|
||||
|
||||
// 2. 检查是否有等待确认的执行记录(用户在对话区直接打字确认)
|
||||
const activeExec = await (prisma as any).ssaAgentExecution.findFirst({
|
||||
where: { sessionId, status: { in: ['plan_pending', 'code_pending'] } },
|
||||
orderBy: { createdAt: 'desc' },
|
||||
@@ -419,27 +444,18 @@ export class ChatHandlerService {
|
||||
|
||||
if (activeExec) {
|
||||
const action = this.parseAgentAction(userContent);
|
||||
|
||||
if (activeExec.status === 'plan_pending') {
|
||||
if (action === 'confirm') {
|
||||
return await this.agentStreamCode(activeExec, sessionId, conversationId, writer, placeholderMessageId);
|
||||
}
|
||||
if (action === 'cancel') {
|
||||
return await this.agentCancel(activeExec, sessionId, conversationId, writer, placeholderMessageId);
|
||||
}
|
||||
if (activeExec.status === 'plan_pending' && action === 'confirm') {
|
||||
return await this.agentStreamCode(activeExec, sessionId, conversationId, writer, placeholderMessageId);
|
||||
}
|
||||
|
||||
if (activeExec.status === 'code_pending') {
|
||||
if (action === 'confirm') {
|
||||
return await this.agentExecuteCode(activeExec, sessionId, conversationId, writer, placeholderMessageId);
|
||||
}
|
||||
if (action === 'cancel') {
|
||||
return await this.agentCancel(activeExec, sessionId, conversationId, writer, placeholderMessageId);
|
||||
}
|
||||
if (activeExec.status === 'code_pending' && action === 'confirm') {
|
||||
return await this.agentExecuteCode(activeExec, sessionId, conversationId, writer, placeholderMessageId);
|
||||
}
|
||||
if (action === 'cancel') {
|
||||
return await this.agentCancel(activeExec, sessionId, conversationId, writer, placeholderMessageId);
|
||||
}
|
||||
}
|
||||
|
||||
// 2. 无挂起确认 — 检查是否是分析请求
|
||||
// 3. 无挂起确认 — 检查是否是分析请求
|
||||
const blackboard = await sessionBlackboardService.get(sessionId);
|
||||
const hasData = !!blackboard?.dataOverview;
|
||||
|
||||
@@ -455,10 +471,9 @@ export class ChatHandlerService {
|
||||
}
|
||||
}
|
||||
|
||||
/** 解析用户回复中的确认/取消意图 */
|
||||
private parseAgentAction(content: string): 'confirm' | 'cancel' | 'other' {
|
||||
const lc = content.toLowerCase();
|
||||
if (lc.includes('agent_confirm') || lc.includes('确认') || lc.includes('执行代码') || lc.includes('开始生成')) return 'confirm';
|
||||
if (lc.includes('agent_confirm') || lc.includes('确认') || lc.includes('执行代码') || lc.includes('开始生成') || lc.includes('执行')) return 'confirm';
|
||||
if (lc.includes('agent_cancel') || lc.includes('取消')) return 'cancel';
|
||||
return 'other';
|
||||
}
|
||||
@@ -501,26 +516,18 @@ export class ChatHandlerService {
|
||||
planText: plan.rawText,
|
||||
});
|
||||
|
||||
// 流式解释计划
|
||||
// 简短视线牵引语(方案 B:左侧只输出简短提示,详细内容在右侧工作区)
|
||||
const hint = [
|
||||
`[系统指令] 你刚刚制定了分析计划「${plan.title}」,包含 ${plan.steps.length} 个步骤。`,
|
||||
'请用简洁的自然语言解释这个计划,然后告知用户可以确认后开始生成 R 代码。',
|
||||
'【禁止】不要编造数值或结果。',
|
||||
`[系统指令——严格遵守] 分析计划已生成(${plan.steps.length} 步),标题「${plan.title}」。`,
|
||||
'你只需回复 1-2 句话,引导用户去右侧工作区查看。',
|
||||
'示例回复:「我已为您拟定了详细的分析计划,请在右侧工作区核对并点击确认。」',
|
||||
'【铁律】禁止在对话中重复计划内容、禁止编造任何数值。回复不超过 2 句话。',
|
||||
].join('\n');
|
||||
|
||||
const msgs = await conversationService.buildContext(sessionId, conversationId, 'analyze', hint);
|
||||
const sr = await conversationService.streamToSSE(msgs, writer, { temperature: 0.5, maxTokens: 800 });
|
||||
const sr = await conversationService.streamToSSE(msgs, writer, { temperature: 0.3, maxTokens: 150 });
|
||||
await conversationService.finalizeAssistantMessage(placeholderMessageId, sr.content, sr.thinking, sr.tokens);
|
||||
|
||||
sendEvent('ask_user', {
|
||||
questionId: `agent_plan_${execution.id}`,
|
||||
text: '请确认分析计划',
|
||||
options: [
|
||||
{ id: 'agent_confirm_plan', label: '确认方案,生成代码' },
|
||||
{ id: 'agent_cancel', label: '取消' },
|
||||
],
|
||||
});
|
||||
|
||||
return { messageId: placeholderMessageId, intent: 'analyze', success: true };
|
||||
}
|
||||
|
||||
@@ -564,26 +571,18 @@ export class ChatHandlerService {
|
||||
explanation: generated.explanation,
|
||||
});
|
||||
|
||||
// 流式说明代码
|
||||
// 简短视线牵引语
|
||||
const hint = [
|
||||
`[系统指令] R 代码已生成(${generated.code.split('\n').length} 行),使用 ${generated.requiredPackages.join(', ') || '基础包'}。`,
|
||||
'请简要说明代码逻辑,告知用户可以确认执行。',
|
||||
'【禁止】不要在对话中贴代码(工作区已展示),不要编造结果。',
|
||||
`[系统指令——严格遵守] R 代码已生成(${generated.code.split('\n').length} 行),使用 ${generated.requiredPackages.join(', ') || '基础包'}。`,
|
||||
'你只需回复 1-2 句话,引导用户去右侧工作区核对代码并点击执行。',
|
||||
'示例回复:「R 代码已生成,请在右侧工作区核对代码并点击 "确认并执行"。」',
|
||||
'【铁律】禁止在对话中贴代码、禁止编造结果。回复不超过 2 句话。',
|
||||
].join('\n');
|
||||
|
||||
const msgs = await conversationService.buildContext(sessionId, conversationId, 'analyze', hint);
|
||||
const sr = await conversationService.streamToSSE(msgs, writer, { temperature: 0.5, maxTokens: 600 });
|
||||
const sr = await conversationService.streamToSSE(msgs, writer, { temperature: 0.3, maxTokens: 150 });
|
||||
await conversationService.finalizeAssistantMessage(placeholderMessageId, sr.content, sr.thinking, sr.tokens);
|
||||
|
||||
sendEvent('ask_user', {
|
||||
questionId: `agent_code_${execution.id}`,
|
||||
text: '代码已生成,请确认执行',
|
||||
options: [
|
||||
{ id: 'agent_confirm_code', label: '执行代码' },
|
||||
{ id: 'agent_cancel', label: '取消' },
|
||||
],
|
||||
});
|
||||
|
||||
return { messageId: placeholderMessageId, intent: 'analyze', success: true };
|
||||
}
|
||||
|
||||
@@ -655,12 +654,42 @@ export class ChatHandlerService {
|
||||
}
|
||||
|
||||
lastError = execResult.error || '执行失败';
|
||||
const rawConsole = execResult.consoleOutput;
|
||||
const consoleArr = Array.isArray(rawConsole) ? rawConsole : (rawConsole ? [String(rawConsole)] : []);
|
||||
const consoleSnippet = consoleArr.slice(-20).join('\n');
|
||||
|
||||
if (attempt < codeRunnerService.maxRetries) {
|
||||
sendEvent('code_error', { executionId: execution.id, message: lastError, willRetry: true });
|
||||
sendEvent('code_retry', { executionId: execution.id, retryCount: attempt + 1, message: `错误: ${lastError},正在修复代码...` });
|
||||
const errorDetail = consoleSnippet
|
||||
? `${lastError}\n\n--- R console output (last 20 lines) ---\n${consoleSnippet}`
|
||||
: lastError;
|
||||
|
||||
const retry = await agentCoderService.generateCode(sessionId, plan, lastError);
|
||||
sendEvent('code_error', {
|
||||
executionId: execution.id,
|
||||
message: lastError,
|
||||
consoleOutput: consoleSnippet || undefined,
|
||||
willRetry: true,
|
||||
retryCount: attempt + 1,
|
||||
});
|
||||
|
||||
sendEvent('code_retry', {
|
||||
executionId: execution.id,
|
||||
retryCount: attempt + 1,
|
||||
message: `第 ${attempt + 1} 次执行失败,Agent 正在重新生成代码...`,
|
||||
previousError: lastError,
|
||||
});
|
||||
|
||||
const retry = await agentCoderService.generateCodeStream(
|
||||
sessionId,
|
||||
plan,
|
||||
(accumulated) => {
|
||||
sendEvent('code_generating', {
|
||||
executionId: execution.id,
|
||||
partialCode: accumulated,
|
||||
});
|
||||
},
|
||||
errorDetail,
|
||||
currentCode,
|
||||
);
|
||||
currentCode = retry.code;
|
||||
|
||||
await (prisma as any).ssaAgentExecution.update({
|
||||
|
||||
Reference in New Issue
Block a user