feat: add general chat feature (without project/agent concept)

This commit is contained in:
AI Clinical Dev Team
2025-10-12 09:48:49 +08:00
parent 07704817ba
commit 348af7fee9
9 changed files with 688 additions and 0 deletions

View File

@@ -62,3 +62,4 @@ async function checkDocuments() {
checkDocuments(); checkDocuments();

View File

@@ -37,6 +37,7 @@ model User {
knowledgeBases KnowledgeBase[] knowledgeBases KnowledgeBase[]
documents Document[] documents Document[]
adminLogs AdminLog[] adminLogs AdminLog[]
generalConversations GeneralConversation[]
@@index([email]) @@index([email])
@@index([status]) @@index([status])
@@ -187,3 +188,42 @@ model AdminLog {
@@index([action]) @@index([action])
@@map("admin_logs") @@map("admin_logs")
} }
// ==================== 通用对话模块 ====================
model GeneralConversation {
id String @id @default(uuid())
userId String @map("user_id")
title String
modelName String? @map("model_name")
createdAt DateTime @default(now()) @map("created_at")
updatedAt DateTime @default(now()) @updatedAt @map("updated_at")
deletedAt DateTime? @map("deleted_at")
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
messages GeneralMessage[]
@@index([userId])
@@index([createdAt])
@@index([updatedAt])
@@map("general_conversations")
}
model GeneralMessage {
id String @id @default(uuid())
conversationId String @map("conversation_id")
role String
content String @db.Text
model String?
metadata Json?
tokens Int?
createdAt DateTime @default(now()) @map("created_at")
conversation GeneralConversation @relation(fields: [conversationId], references: [id], onDelete: Cascade)
@@index([conversationId])
@@index([createdAt])
@@map("general_messages")
}

View File

@@ -0,0 +1,303 @@
import { FastifyRequest, FastifyReply } from 'fastify';
import { ModelType } from '../adapters/types.js';
import { LLMFactory } from '../adapters/LLMFactory.js';
import * as knowledgeBaseService from '../services/knowledgeBaseService.js';
import { prisma } from '../config/database.js';
interface SendChatMessageBody {
content: string;
modelType: ModelType;
knowledgeBaseIds?: string[];
conversationId?: string; // 可选:续接已有对话
}
/**
* 通用聊天Controller
* 无需项目和智能体,纯大模型对话
*/
export class ChatController {
/**
* 发送消息(流式输出)
*/
async sendMessageStream(
request: FastifyRequest<{ Body: SendChatMessageBody }>,
reply: FastifyReply
) {
try {
// TODO: 从JWT token获取userId
const userId = 'user-mock-001';
const { content, modelType, knowledgeBaseIds, conversationId } = request.body;
console.log('💬 [ChatController] 收到通用对话请求', {
content,
modelType,
knowledgeBaseIds: knowledgeBaseIds || [],
conversationId,
});
// 验证modelType
if (modelType !== 'deepseek-v3' && modelType !== 'qwen3-72b' && modelType !== 'gemini-pro') {
reply.code(400).send({
success: false,
message: `不支持的模型类型: ${modelType}`,
});
return;
}
// 获取或创建对话记录
let conversation;
if (conversationId) {
// 验证对话是否存在且属于当前用户
conversation = await prisma.generalConversation.findFirst({
where: {
id: conversationId,
userId,
deletedAt: null,
},
});
if (!conversation) {
reply.code(404).send({
success: false,
message: '对话不存在',
});
return;
}
} else {
// 创建新对话
conversation = await prisma.generalConversation.create({
data: {
userId,
title: content.substring(0, 50), // 用第一条消息的前50字作为标题
modelName: modelType,
},
});
console.log('✅ [ChatController] 创建新对话', { conversationId: conversation.id });
}
// 检索知识库上下文
let knowledgeBaseContext = '';
if (knowledgeBaseIds && knowledgeBaseIds.length > 0) {
console.log('📚 [ChatController] 开始检索知识库');
const knowledgeResults: string[] = [];
for (const kbId of knowledgeBaseIds) {
try {
const searchResult = await knowledgeBaseService.searchKnowledgeBase(
userId,
kbId,
content,
3
);
if (searchResult.records && searchResult.records.length > 0) {
const kbInfo = await prisma.knowledgeBase.findUnique({
where: { id: kbId },
select: { name: true },
});
knowledgeResults.push(
`【知识库:${kbInfo?.name || '未命名'}\n` +
searchResult.records
.map((record: any, index: number) => {
const score = (record.score * 100).toFixed(1);
return `${index + 1}. [相关度${score}%] ${record.segment.content}`;
})
.join('\n\n')
);
}
} catch (error) {
console.error(`❌ [ChatController] 检索知识库失败 ${kbId}:`, error);
}
}
if (knowledgeResults.length > 0) {
knowledgeBaseContext = knowledgeResults.join('\n\n---\n\n');
console.log(`💾 [ChatController] 知识库上下文: ${knowledgeBaseContext.length} 字符`);
}
}
// 获取历史消息最近20条
const historyMessages = await prisma.generalMessage.findMany({
where: {
conversationId: conversation.id,
},
orderBy: {
createdAt: 'desc',
},
take: 20,
});
historyMessages.reverse();
console.log(`📜 [ChatController] 历史消息数: ${historyMessages.length}`);
// 组装消息上下文
const messages: any[] = [
{
role: 'system',
content: '你是一个专业、友好的AI助手。当用户提供参考资料时请优先基于参考资料回答。',
},
];
// 添加历史消息
for (const msg of historyMessages) {
messages.push({
role: msg.role,
content: msg.content,
});
}
// 添加当前用户消息
let userContent = content;
if (knowledgeBaseContext) {
userContent = `${content}\n\n## 参考资料(来自知识库)\n${knowledgeBaseContext}`;
}
messages.push({
role: 'user',
content: userContent,
});
// 设置SSE响应头
reply.raw.writeHead(200, {
'Content-Type': 'text/event-stream',
'Cache-Control': 'no-cache',
Connection: 'keep-alive',
'Access-Control-Allow-Origin': '*',
});
// 保存用户消息
await prisma.generalMessage.create({
data: {
conversationId: conversation.id,
role: 'user',
content,
metadata: {
knowledgeBaseIds,
},
},
});
// 流式输出
const adapter = LLMFactory.getAdapter(modelType);
let fullContent = '';
let usage: any = null;
for await (const chunk of adapter.chatStream(messages, {
temperature: 0.7,
maxTokens: 2000,
})) {
fullContent += chunk.content;
if (chunk.usage) {
usage = chunk.usage;
}
// 发送SSE数据
reply.raw.write(`data: ${JSON.stringify(chunk)}\n\n`);
}
// 保存助手消息
await prisma.generalMessage.create({
data: {
conversationId: conversation.id,
role: 'assistant',
content: fullContent,
model: modelType,
tokens: usage?.totalTokens,
metadata: {
usage,
},
},
});
// 更新对话
await prisma.generalConversation.update({
where: { id: conversation.id },
data: {
updatedAt: new Date(),
},
});
// 发送完成信号
reply.raw.write(`data: [DONE]\n\n`);
reply.raw.end();
console.log('✅ [ChatController] 对话完成');
} catch (error: any) {
console.error('❌ [ChatController] 错误:', error);
reply.code(500).send({
success: false,
message: error.message || '服务器错误',
});
}
}
/**
* 获取对话列表
*/
async getConversations(
request: FastifyRequest,
reply: FastifyReply
) {
try {
const userId = 'user-mock-001';
const conversations = await prisma.generalConversation.findMany({
where: {
userId,
deletedAt: null,
},
orderBy: {
updatedAt: 'desc',
},
take: 50,
});
reply.send({
success: true,
data: conversations,
});
} catch (error: any) {
reply.code(500).send({
success: false,
message: error.message || '获取对话列表失败',
});
}
}
/**
* 删除对话
*/
async deleteConversation(
request: FastifyRequest<{ Params: { id: string } }>,
reply: FastifyReply
) {
try {
const userId = 'user-mock-001';
const { id } = request.params;
await prisma.generalConversation.update({
where: {
id,
userId,
},
data: {
deletedAt: new Date(),
},
});
reply.send({
success: true,
message: '删除成功',
});
} catch (error: any) {
reply.code(500).send({
success: false,
message: error.message || '删除失败',
});
}
}
}
export const chatController = new ChatController();

View File

@@ -7,6 +7,7 @@ import { projectRoutes } from './routes/projects.js';
import { agentRoutes } from './routes/agents.js'; import { agentRoutes } from './routes/agents.js';
import { conversationRoutes } from './routes/conversations.js'; import { conversationRoutes } from './routes/conversations.js';
import knowledgeBaseRoutes from './routes/knowledgeBases.js'; import knowledgeBaseRoutes from './routes/knowledgeBases.js';
import { chatRoutes } from './routes/chatRoutes.js';
// 全局处理BigInt序列化 // 全局处理BigInt序列化
@@ -89,6 +90,9 @@ await fastify.register(conversationRoutes, { prefix: '/api/v1' });
// 注册知识库管理路由 // 注册知识库管理路由
await fastify.register(knowledgeBaseRoutes, { prefix: '/api/v1' }); await fastify.register(knowledgeBaseRoutes, { prefix: '/api/v1' });
// 注册通用对话路由
await fastify.register(chatRoutes, { prefix: '/api/v1' });
// 启动服务器 // 启动服务器
const start = async () => { const start = async () => {
try { try {

View File

@@ -0,0 +1,14 @@
import { FastifyInstance } from 'fastify';
import { chatController } from '../controllers/chatController.js';
export async function chatRoutes(fastify: FastifyInstance) {
// 发送消息(流式输出)
fastify.post('/chat/stream', chatController.sendMessageStream.bind(chatController));
// 获取对话列表
fastify.get('/chat/conversations', chatController.getConversations.bind(chatController));
// 删除对话
fastify.delete('/chat/conversations/:id', chatController.deleteConversation.bind(chatController));
}

View File

@@ -2,6 +2,7 @@ import { Routes, Route, Navigate } from 'react-router-dom'
import MainLayout from './layouts/MainLayout' import MainLayout from './layouts/MainLayout'
import HomePage from './pages/HomePage' import HomePage from './pages/HomePage'
import AgentChatPage from './pages/AgentChatPage' import AgentChatPage from './pages/AgentChatPage'
import ChatPage from './pages/ChatPage'
import KnowledgePage from './pages/KnowledgePage' import KnowledgePage from './pages/KnowledgePage'
import HistoryPage from './pages/HistoryPage' import HistoryPage from './pages/HistoryPage'
@@ -10,6 +11,7 @@ function App() {
<Routes> <Routes>
<Route path="/" element={<MainLayout />}> <Route path="/" element={<MainLayout />}>
<Route index element={<HomePage />} /> <Route index element={<HomePage />} />
<Route path="chat" element={<ChatPage />} />
<Route path="agent/:agentId" element={<AgentChatPage />} /> <Route path="agent/:agentId" element={<AgentChatPage />} />
<Route path="knowledge" element={<KnowledgePage />} /> <Route path="knowledge" element={<KnowledgePage />} />
<Route path="history" element={<HistoryPage />} /> <Route path="history" element={<HistoryPage />} />

139
frontend/src/api/chatApi.ts Normal file
View File

@@ -0,0 +1,139 @@
/**
* 通用对话API
* 无需项目和智能体概念,纯大模型对话
*/
export interface GeneralConversation {
id: string
userId: string
title: string
modelName?: string
createdAt: string
updatedAt: string
}
export interface GeneralMessage {
id: string
conversationId: string
role: 'user' | 'assistant'
content: string
model?: string
tokens?: number
createdAt: string
}
export interface SendChatMessageData {
content: string
modelType: string
knowledgeBaseIds?: string[]
conversationId?: string
}
export interface ApiResponse<T = any> {
success: boolean
data?: T
message?: string
}
/**
* 发送消息(流式输出)
*/
export const sendMessageStream = async (
data: SendChatMessageData,
onChunk: (content: string) => void,
onComplete: (conversationId: string) => void,
onError: (error: Error) => void
) => {
try {
console.log('🚀 [chatApi] 发送通用对话消息', data)
const response = await fetch('/api/v1/chat/stream', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify(data),
})
if (!response.ok) {
throw new Error(`HTTP error! status: ${response.status}`)
}
const reader = response.body?.getReader()
if (!reader) {
throw new Error('Response body is null')
}
const decoder = new TextDecoder()
let buffer = ''
let conversationId = data.conversationId || ''
while (true) {
const { done, value } = await reader.read()
if (done) {
break
}
buffer += decoder.decode(value, { stream: true })
const lines = buffer.split('\n')
buffer = lines.pop() || ''
for (const line of lines) {
const trimmedLine = line.trim()
if (!trimmedLine || !trimmedLine.startsWith('data:')) {
continue
}
const dataStr = trimmedLine.slice(5).trim()
if (dataStr === '[DONE]') {
console.log('✅ [chatApi] 流式输出完成')
onComplete(conversationId)
return
}
try {
const chunk = JSON.parse(dataStr)
if (chunk.content) {
onChunk(chunk.content)
}
// 从第一个chunk中提取conversationId如果是新对话
if (!conversationId && chunk.conversationId) {
conversationId = chunk.conversationId
}
} catch (e) {
console.error('Failed to parse SSE chunk:', e)
}
}
}
} catch (error) {
console.error('❌ [chatApi] 发送消息失败:', error)
onError(error as Error)
}
}
/**
* 获取对话列表
*/
export const getConversations = async (): Promise<ApiResponse<GeneralConversation[]>> => {
const response = await fetch('/api/v1/chat/conversations')
return response.json()
}
/**
* 删除对话
*/
export const deleteConversation = async (id: string): Promise<ApiResponse> => {
const response = await fetch(`/api/v1/chat/conversations/${id}`, {
method: 'DELETE',
})
return response.json()
}
export default {
sendMessageStream,
getConversations,
deleteConversation,
}

View File

@@ -5,6 +5,7 @@ import {
MenuFoldOutlined, MenuFoldOutlined,
MenuUnfoldOutlined, MenuUnfoldOutlined,
HomeOutlined, HomeOutlined,
MessageOutlined,
ExperimentOutlined, ExperimentOutlined,
FolderOpenOutlined, FolderOpenOutlined,
HistoryOutlined, HistoryOutlined,
@@ -52,6 +53,11 @@ const MainLayout = () => {
icon: <HomeOutlined />, icon: <HomeOutlined />,
label: '首页', label: '首页',
}, },
{
key: '/chat',
icon: <MessageOutlined />,
label: '智能问答',
},
{ {
key: 'agents', key: 'agents',
icon: <ExperimentOutlined />, icon: <ExperimentOutlined />,

View File

@@ -0,0 +1,179 @@
import { useState, useEffect } from 'react'
import { message } from 'antd'
import chatApi, { type GeneralMessage } from '../api/chatApi'
import MessageList from '../components/chat/MessageList'
import MessageInput from '../components/chat/MessageInput'
import ModelSelector, { type ModelType } from '../components/chat/ModelSelector'
import { useKnowledgeBaseStore } from '../stores/useKnowledgeBaseStore'
const ChatPage = () => {
const { knowledgeBases, fetchKnowledgeBases } = useKnowledgeBaseStore()
const [messages, setMessages] = useState<GeneralMessage[]>([])
const [selectedModel, setSelectedModel] = useState<ModelType>('deepseek-v3')
const [sending, setSending] = useState(false)
const [streamingContent, setStreamingContent] = useState('')
const [currentConversationId, setCurrentConversationId] = useState<string>()
// 加载知识库列表
useEffect(() => {
fetchKnowledgeBases()
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [])
// 发送消息(流式)
const handleSendMessage = async (content: string, knowledgeBaseIds: string[]) => {
if (sending) return
console.log('🔵 [ChatPage] 发送消息', { content, knowledgeBaseIds, currentConversationId })
setSending(true)
setStreamingContent('')
// 添加用户消息到列表
const userMessage: GeneralMessage = {
id: `temp-${Date.now()}`,
conversationId: currentConversationId || 'temp',
role: 'user',
content,
createdAt: new Date().toISOString(),
}
setMessages(prev => [...prev, userMessage])
try {
let fullContent = ''
await chatApi.sendMessageStream(
{
content,
modelType: selectedModel,
knowledgeBaseIds,
conversationId: currentConversationId,
},
// onChunk
(chunk) => {
fullContent += chunk
setStreamingContent(fullContent)
},
// onComplete
(conversationId) => {
console.log('✅ [ChatPage] 对话完成', { conversationId })
// 如果是新对话保存conversationId
if (!currentConversationId && conversationId) {
setCurrentConversationId(conversationId)
}
// 添加完整的助手消息
const assistantMessage: GeneralMessage = {
id: `temp-assistant-${Date.now()}`,
conversationId: conversationId || currentConversationId || 'temp',
role: 'assistant',
content: fullContent,
model: selectedModel,
createdAt: new Date().toISOString(),
}
setMessages(prev => [...prev, assistantMessage])
setStreamingContent('')
setSending(false)
},
// onError
(error) => {
console.error('❌ [ChatPage] 发送失败:', error)
message.error('发送消息失败:' + error.message)
setStreamingContent('')
setSending(false)
}
)
} catch (err) {
console.error('❌ [ChatPage] 发送消息异常:', err)
message.error('发送消息失败')
setStreamingContent('')
setSending(false)
}
}
return (
<div style={{ height: '100%', display: 'flex', flexDirection: 'column' }}>
{/* 顶部工具栏 */}
<div style={{
padding: '12px 24px',
background: '#fff',
borderBottom: '1px solid #e8e8e8',
display: 'flex',
alignItems: 'center',
justifyContent: 'space-between',
flexShrink: 0,
}}>
<div style={{ fontSize: 16, fontWeight: 600 }}>
💬
</div>
{/* 模型选择器 */}
<ModelSelector
value={selectedModel}
onChange={setSelectedModel}
disabled={sending}
/>
</div>
{/* 聊天区域 */}
<div
style={{
flex: 1,
display: 'flex',
flexDirection: 'column',
overflow: 'hidden',
minHeight: 0,
background: '#fff',
}}
>
{/* 消息列表区域 */}
<div style={{
flex: 1,
display: 'flex',
flexDirection: 'column',
minHeight: 0,
overflow: 'hidden',
}}>
{messages.length === 0 && !sending ? (
<div style={{
flex: 1,
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
background: '#fafafa'
}}>
<div style={{ textAlign: 'center', color: '#999' }}>
<div style={{ fontSize: 48, marginBottom: 16 }}>💬</div>
<div style={{ fontSize: 18, fontWeight: 500, marginBottom: 8 }}>
AI自由对话
</div>
<div style={{ fontSize: 14 }}>
使@知识库引用文献
</div>
</div>
</div>
) : (
<MessageList
messages={messages}
loading={sending}
streamingContent={streamingContent}
/>
)}
</div>
{/* 消息输入 */}
<MessageInput
onSend={handleSendMessage}
loading={sending}
knowledgeBases={knowledgeBases}
placeholder="输入你的问题...Shift+Enter换行Enter发送"
/>
</div>
</div>
)
}
export default ChatPage