diff --git a/backend/check-documents.js b/backend/check-documents.js index ce62ab42..305dbb34 100644 --- a/backend/check-documents.js +++ b/backend/check-documents.js @@ -62,3 +62,4 @@ async function checkDocuments() { checkDocuments(); + diff --git a/backend/prisma/schema.prisma b/backend/prisma/schema.prisma index 78c90ac2..edaa3568 100644 --- a/backend/prisma/schema.prisma +++ b/backend/prisma/schema.prisma @@ -37,6 +37,7 @@ model User { knowledgeBases KnowledgeBase[] documents Document[] adminLogs AdminLog[] + generalConversations GeneralConversation[] @@index([email]) @@index([status]) @@ -187,3 +188,42 @@ model AdminLog { @@index([action]) @@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") +} diff --git a/backend/src/controllers/chatController.ts b/backend/src/controllers/chatController.ts new file mode 100644 index 00000000..3b3aa369 --- /dev/null +++ b/backend/src/controllers/chatController.ts @@ -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(); + diff --git a/backend/src/index.ts b/backend/src/index.ts index 663df1fe..9cd6e4ac 100644 --- a/backend/src/index.ts +++ b/backend/src/index.ts @@ -7,6 +7,7 @@ import { projectRoutes } from './routes/projects.js'; import { agentRoutes } from './routes/agents.js'; import { conversationRoutes } from './routes/conversations.js'; import knowledgeBaseRoutes from './routes/knowledgeBases.js'; +import { chatRoutes } from './routes/chatRoutes.js'; // 全局处理BigInt序列化 @@ -89,6 +90,9 @@ await fastify.register(conversationRoutes, { prefix: '/api/v1' }); // 注册知识库管理路由 await fastify.register(knowledgeBaseRoutes, { prefix: '/api/v1' }); +// 注册通用对话路由 +await fastify.register(chatRoutes, { prefix: '/api/v1' }); + // 启动服务器 const start = async () => { try { diff --git a/backend/src/routes/chatRoutes.ts b/backend/src/routes/chatRoutes.ts new file mode 100644 index 00000000..b2886e0b --- /dev/null +++ b/backend/src/routes/chatRoutes.ts @@ -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)); +} + diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index e7949167..ef999d33 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -2,6 +2,7 @@ import { Routes, Route, Navigate } from 'react-router-dom' import MainLayout from './layouts/MainLayout' import HomePage from './pages/HomePage' import AgentChatPage from './pages/AgentChatPage' +import ChatPage from './pages/ChatPage' import KnowledgePage from './pages/KnowledgePage' import HistoryPage from './pages/HistoryPage' @@ -10,6 +11,7 @@ function App() { }> } /> + } /> } /> } /> } /> diff --git a/frontend/src/api/chatApi.ts b/frontend/src/api/chatApi.ts new file mode 100644 index 00000000..f4181125 --- /dev/null +++ b/frontend/src/api/chatApi.ts @@ -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 { + 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> => { + const response = await fetch('/api/v1/chat/conversations') + return response.json() +} + +/** + * 删除对话 + */ +export const deleteConversation = async (id: string): Promise => { + const response = await fetch(`/api/v1/chat/conversations/${id}`, { + method: 'DELETE', + }) + return response.json() +} + +export default { + sendMessageStream, + getConversations, + deleteConversation, +} + diff --git a/frontend/src/layouts/MainLayout.tsx b/frontend/src/layouts/MainLayout.tsx index 1e103167..0c5974b1 100644 --- a/frontend/src/layouts/MainLayout.tsx +++ b/frontend/src/layouts/MainLayout.tsx @@ -5,6 +5,7 @@ import { MenuFoldOutlined, MenuUnfoldOutlined, HomeOutlined, + MessageOutlined, ExperimentOutlined, FolderOpenOutlined, HistoryOutlined, @@ -52,6 +53,11 @@ const MainLayout = () => { icon: , label: '首页', }, + { + key: '/chat', + icon: , + label: '智能问答', + }, { key: 'agents', icon: , diff --git a/frontend/src/pages/ChatPage.tsx b/frontend/src/pages/ChatPage.tsx new file mode 100644 index 00000000..1a2be092 --- /dev/null +++ b/frontend/src/pages/ChatPage.tsx @@ -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([]) + const [selectedModel, setSelectedModel] = useState('deepseek-v3') + const [sending, setSending] = useState(false) + const [streamingContent, setStreamingContent] = useState('') + const [currentConversationId, setCurrentConversationId] = useState() + + // 加载知识库列表 + 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 ( +
+ {/* 顶部工具栏 */} +
+
+ 💬 智能问答 +
+ + {/* 模型选择器 */} + +
+ + {/* 聊天区域 */} +
+ {/* 消息列表区域 */} +
+ {messages.length === 0 && !sending ? ( +
+
+
💬
+
+ 与AI自由对话 +
+
+ 直接提问,或使用@知识库引用文献 +
+
+
+ ) : ( + + )} +
+ + {/* 消息输入 */} + +
+
+ ) +} + +export default ChatPage +