Files
AIclinicalresearch/backend/src/modules/ssa/routes/chat.routes.ts
HaHafeng ac724266c1 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
2026-03-08 15:23:09 +08:00

251 lines
7.7 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/**
* Phase II — 统一对话 API 路由
*
* POST /sessions/:id/chat — 统一对话入口SSE 流式)
* GET /sessions/:id/chat/history — 获取对话历史
* GET /sessions/:id/chat/conversation — 获取 conversation 元信息
*
* SSE 格式OpenAI Compatible与前端 useAIStream 兼容)
* 心跳5 秒H1
* 竞态保护placeholder 占位H3
*/
import { FastifyInstance, FastifyRequest } from 'fastify';
import { logger } from '../../../common/logging/index.js';
import { prisma } from '../../../config/database.js';
import { conversationService } from '../services/ConversationService.js';
import { intentRouterService } from '../services/IntentRouterService.js';
import { chatHandlerService } from '../services/ChatHandlerService.js';
import { askUserService } from '../services/AskUserService.js';
function getUserId(request: FastifyRequest): string {
const userId = (request as any).user?.userId;
if (!userId) throw new Error('User not authenticated');
return userId;
}
export default async function chatRoutes(app: FastifyInstance) {
/**
* POST /sessions/:id/chat
* 统一对话入口 — SSE 流式响应
*/
app.post('/:id/chat', async (req, reply) => {
const { id: sessionId } = req.params as { id: string };
const userId = getUserId(req);
const { content, enableDeepThinking, metadata } = req.body as {
content: string;
enableDeepThinking?: boolean;
metadata?: Record<string, any>;
};
if (!content?.trim()) {
return reply.status(400).send({ error: '消息内容不能为空' });
}
// SSE 响应头
reply.raw.writeHead(200, {
'Content-Type': 'text/event-stream',
'Cache-Control': 'no-cache',
'Connection': 'keep-alive',
'Access-Control-Allow-Origin': '*',
'X-Accel-Buffering': 'no',
});
const writer = {
write: (data: string) => {
try {
return reply.raw.write(data);
} catch {
return false;
}
},
end: () => {
try { reply.raw.end(); } catch { /* ignore */ }
},
on: (event: string, handler: () => void) => {
req.raw.on(event, handler);
},
};
try {
// 1. 获取或创建 Conversation延迟创建
const conversationId = await conversationService.getOrCreateConversation(sessionId, userId);
// 2. 保存用户消息
await conversationService.saveUserMessage(conversationId, content.trim());
// ── H1 全局打断判定 ──
const pending = await askUserService.getPending(sessionId);
if (pending) {
const askUserResponse = metadata?.askUserResponse
? askUserService.parseResponse(metadata)
: null;
if (askUserResponse) {
// 正常回答问题(含 skip
const placeholderMsgId = await conversationService.createAssistantPlaceholder(
conversationId, 'chat',
);
const metaEvent = JSON.stringify({
type: 'intent_classified',
intent: 'chat',
confidence: 1,
source: 'ask_user_response',
guardTriggered: false,
});
writer.write(`data: ${metaEvent}\n\n`);
const result = await chatHandlerService.handleAskUserResponse(
sessionId, conversationId, askUserResponse, writer, placeholderMsgId,
);
logger.info('[SSA:Chat] AskUser response handled', {
sessionId, action: askUserResponse.action, success: result.success,
});
writer.end();
return;
} else {
// 用户无视卡片,强行打字转移话题
await askUserService.clearPending(sessionId);
logger.info('[SSA:Chat] 用户转移话题,已取消挂起的 ask_user 状态', { sessionId });
}
}
// ── H1 结束 ──
// 3. 执行模式:统一使用 Agent 通道QPER 已废弃 UI 入口)
const executionMode = 'agent';
// ── Agent 通道分流 ──
if (executionMode === 'agent') {
const placeholderMsgId = await conversationService.createAssistantPlaceholder(
conversationId, 'chat',
);
const metaEvent = JSON.stringify({
type: 'intent_classified',
intent: 'analyze',
confidence: 1,
source: 'agent_mode',
guardTriggered: false,
});
writer.write(`data: ${metaEvent}\n\n`);
const result = await chatHandlerService.handleAgentMode(
sessionId, conversationId, content.trim(), writer, placeholderMsgId,
metadata,
);
logger.info('[SSA:Chat] Agent mode request completed', {
sessionId, success: result.success,
});
writer.end();
return;
}
// ── QPER 通道(现有逻辑) ──
// 3. 意图分类
const intentResult = await intentRouterService.classify(content.trim(), sessionId);
// 发送意图元数据事件(前端可用于 UI 切换)
const metaEvent = JSON.stringify({
type: 'intent_classified',
intent: intentResult.intent,
confidence: intentResult.confidence,
source: intentResult.source,
guardTriggered: intentResult.guardTriggered || false,
guardMessage: intentResult.guardMessage,
});
writer.write(`data: ${metaEvent}\n\n`);
// 4. 创建 assistant placeholderH3 竞态保护)
const placeholderMsgId = await conversationService.createAssistantPlaceholder(
conversationId, intentResult.intent,
);
// 5. 分发到意图处理器
const result = await chatHandlerService.handle(
sessionId, conversationId, content.trim(),
intentResult, writer, placeholderMsgId,
);
logger.info('[SSA:Chat] Request completed', {
sessionId,
intent: result.intent,
success: result.success,
});
} catch (error: any) {
logger.error('[SSA:Chat] Unhandled error', {
sessionId,
error: error.message,
});
const errorEvent = JSON.stringify({
type: 'error',
code: 'CHAT_ERROR',
message: error.message || '处理消息时发生错误',
});
try {
writer.write(`data: ${errorEvent}\n\n`);
} catch { /* ignore */ }
} finally {
writer.end();
}
});
/**
* GET /sessions/:id/chat/history
* 获取对话历史消息
*/
app.get('/:id/chat/history', async (req, reply) => {
const { id: sessionId } = req.params as { id: string };
const conversation = await conversationService.getConversationBySession(sessionId);
if (!conversation) {
return reply.send({ messages: [], conversationId: null });
}
const messages = await conversationService.getMessages(conversation.id);
return reply.send({
conversationId: conversation.id,
messages: messages.map(m => ({
id: m.id,
role: m.role,
content: m.content,
thinkingContent: m.thinkingContent,
intent: (m.metadata as any)?.intent,
status: (m.metadata as any)?.status,
createdAt: m.createdAt,
})),
});
});
/**
* GET /sessions/:id/chat/conversation
* 获取 conversation 元信息
*/
app.get('/:id/chat/conversation', async (req, reply) => {
const { id: sessionId } = req.params as { id: string };
const conversation = await conversationService.getConversationBySession(sessionId);
if (!conversation) {
return reply.send({ conversation: null });
}
return reply.send({
conversation: {
id: conversation.id,
title: conversation.title,
messageCount: conversation.messageCount,
createdAt: conversation.createdAt,
updatedAt: conversation.updatedAt,
},
});
});
}