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:
2026-03-07 22:32:32 +08:00
parent 87655ea7e6
commit 52989cd03f
18 changed files with 1334 additions and 230 deletions

View File

@@ -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', {

View File

@@ -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);

View File

@@ -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({