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
251 lines
7.7 KiB
TypeScript
251 lines
7.7 KiB
TypeScript
/**
|
||
* 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 placeholder(H3 竞态保护)
|
||
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,
|
||
},
|
||
});
|
||
});
|
||
}
|