docs: Day 12-13 completion summary and milestone update
This commit is contained in:
150
backend/src/adapters/DeepSeekAdapter.ts
Normal file
150
backend/src/adapters/DeepSeekAdapter.ts
Normal file
@@ -0,0 +1,150 @@
|
||||
import axios from 'axios';
|
||||
import { ILLMAdapter, Message, LLMOptions, LLMResponse, StreamChunk } from './types.js';
|
||||
import { config } from '../config/env.js';
|
||||
|
||||
export class DeepSeekAdapter implements ILLMAdapter {
|
||||
modelName: string;
|
||||
private apiKey: string;
|
||||
private baseURL: string;
|
||||
|
||||
constructor(modelName: string = 'deepseek-chat') {
|
||||
this.modelName = modelName;
|
||||
this.apiKey = config.deepseekApiKey || '';
|
||||
this.baseURL = 'https://api.deepseek.com/v1';
|
||||
|
||||
if (!this.apiKey) {
|
||||
throw new Error('DeepSeek API key is not configured');
|
||||
}
|
||||
}
|
||||
|
||||
// 非流式调用
|
||||
async chat(messages: Message[], options?: LLMOptions): Promise<LLMResponse> {
|
||||
try {
|
||||
const response = await axios.post(
|
||||
`${this.baseURL}/chat/completions`,
|
||||
{
|
||||
model: this.modelName,
|
||||
messages: messages,
|
||||
temperature: options?.temperature ?? 0.7,
|
||||
max_tokens: options?.maxTokens ?? 2000,
|
||||
top_p: options?.topP ?? 0.9,
|
||||
stream: false,
|
||||
},
|
||||
{
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
Authorization: `Bearer ${this.apiKey}`,
|
||||
},
|
||||
timeout: 60000, // 60秒超时
|
||||
}
|
||||
);
|
||||
|
||||
const choice = response.data.choices[0];
|
||||
|
||||
return {
|
||||
content: choice.message.content,
|
||||
model: response.data.model,
|
||||
usage: {
|
||||
promptTokens: response.data.usage.prompt_tokens,
|
||||
completionTokens: response.data.usage.completion_tokens,
|
||||
totalTokens: response.data.usage.total_tokens,
|
||||
},
|
||||
finishReason: choice.finish_reason,
|
||||
};
|
||||
} catch (error: unknown) {
|
||||
console.error('DeepSeek API Error:', error);
|
||||
if (axios.isAxiosError(error)) {
|
||||
throw new Error(
|
||||
`DeepSeek API调用失败: ${error.response?.data?.error?.message || error.message}`
|
||||
);
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
// 流式调用
|
||||
async *chatStream(
|
||||
messages: Message[],
|
||||
options?: LLMOptions,
|
||||
onChunk?: (chunk: StreamChunk) => void
|
||||
): AsyncGenerator<StreamChunk, void, unknown> {
|
||||
try {
|
||||
const response = await axios.post(
|
||||
`${this.baseURL}/chat/completions`,
|
||||
{
|
||||
model: this.modelName,
|
||||
messages: messages,
|
||||
temperature: options?.temperature ?? 0.7,
|
||||
max_tokens: options?.maxTokens ?? 2000,
|
||||
top_p: options?.topP ?? 0.9,
|
||||
stream: true,
|
||||
},
|
||||
{
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
Authorization: `Bearer ${this.apiKey}`,
|
||||
},
|
||||
responseType: 'stream',
|
||||
timeout: 60000,
|
||||
}
|
||||
);
|
||||
|
||||
const stream = response.data;
|
||||
let buffer = '';
|
||||
|
||||
for await (const chunk of stream) {
|
||||
buffer += chunk.toString();
|
||||
const lines = buffer.split('\n');
|
||||
buffer = lines.pop() || '';
|
||||
|
||||
for (const line of lines) {
|
||||
const trimmedLine = line.trim();
|
||||
if (!trimmedLine || trimmedLine === 'data: [DONE]') {
|
||||
continue;
|
||||
}
|
||||
|
||||
if (trimmedLine.startsWith('data: ')) {
|
||||
try {
|
||||
const jsonStr = trimmedLine.slice(6);
|
||||
const data = JSON.parse(jsonStr);
|
||||
|
||||
const choice = data.choices[0];
|
||||
const content = choice.delta?.content || '';
|
||||
|
||||
const streamChunk: StreamChunk = {
|
||||
content: content,
|
||||
done: choice.finish_reason === 'stop',
|
||||
model: data.model,
|
||||
};
|
||||
|
||||
if (choice.finish_reason === 'stop' && data.usage) {
|
||||
streamChunk.usage = {
|
||||
promptTokens: data.usage.prompt_tokens,
|
||||
completionTokens: data.usage.completion_tokens,
|
||||
totalTokens: data.usage.total_tokens,
|
||||
};
|
||||
}
|
||||
|
||||
if (onChunk) {
|
||||
onChunk(streamChunk);
|
||||
}
|
||||
|
||||
yield streamChunk;
|
||||
} catch (parseError) {
|
||||
console.error('Failed to parse SSE data:', parseError);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('DeepSeek Stream Error:', error);
|
||||
if (axios.isAxiosError(error)) {
|
||||
throw new Error(
|
||||
`DeepSeek流式调用失败: ${error.response?.data?.error?.message || error.message}`
|
||||
);
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
77
backend/src/adapters/LLMFactory.ts
Normal file
77
backend/src/adapters/LLMFactory.ts
Normal file
@@ -0,0 +1,77 @@
|
||||
import { ILLMAdapter, ModelType } from './types.js';
|
||||
import { DeepSeekAdapter } from './DeepSeekAdapter.js';
|
||||
import { QwenAdapter } from './QwenAdapter.js';
|
||||
|
||||
/**
|
||||
* LLM工厂类
|
||||
* 根据模型类型创建相应的适配器实例
|
||||
*/
|
||||
export class LLMFactory {
|
||||
private static adapters: Map<string, ILLMAdapter> = new Map();
|
||||
|
||||
/**
|
||||
* 获取LLM适配器实例(单例模式)
|
||||
* @param modelType 模型类型
|
||||
* @returns LLM适配器实例
|
||||
*/
|
||||
static getAdapter(modelType: ModelType): ILLMAdapter {
|
||||
// 如果已经创建过该适配器,直接返回
|
||||
if (this.adapters.has(modelType)) {
|
||||
return this.adapters.get(modelType)!;
|
||||
}
|
||||
|
||||
// 根据模型类型创建适配器
|
||||
let adapter: ILLMAdapter;
|
||||
|
||||
switch (modelType) {
|
||||
case 'deepseek-v3':
|
||||
adapter = new DeepSeekAdapter('deepseek-chat');
|
||||
break;
|
||||
|
||||
case 'qwen3-72b':
|
||||
adapter = new QwenAdapter('qwen-max'); // Qwen3-72B对应的模型名
|
||||
break;
|
||||
|
||||
case 'gemini-pro':
|
||||
// TODO: 实现Gemini适配器
|
||||
throw new Error('Gemini adapter is not implemented yet');
|
||||
|
||||
default:
|
||||
throw new Error(`Unsupported model type: ${modelType}`);
|
||||
}
|
||||
|
||||
// 缓存适配器实例
|
||||
this.adapters.set(modelType, adapter);
|
||||
return adapter;
|
||||
}
|
||||
|
||||
/**
|
||||
* 清除适配器缓存
|
||||
* @param modelType 可选,指定清除某个模型的适配器,不传则清除所有
|
||||
*/
|
||||
static clearCache(modelType?: ModelType): void {
|
||||
if (modelType) {
|
||||
this.adapters.delete(modelType);
|
||||
} else {
|
||||
this.adapters.clear();
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 检查模型是否支持
|
||||
* @param modelType 模型类型
|
||||
* @returns 是否支持
|
||||
*/
|
||||
static isSupported(modelType: string): boolean {
|
||||
return ['deepseek-v3', 'qwen3-72b', 'gemini-pro'].includes(modelType);
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取所有支持的模型列表
|
||||
* @returns 支持的模型列表
|
||||
*/
|
||||
static getSupportedModels(): ModelType[] {
|
||||
return ['deepseek-v3', 'qwen3-72b', 'gemini-pro'];
|
||||
}
|
||||
}
|
||||
|
||||
162
backend/src/adapters/QwenAdapter.ts
Normal file
162
backend/src/adapters/QwenAdapter.ts
Normal file
@@ -0,0 +1,162 @@
|
||||
import axios from 'axios';
|
||||
import { ILLMAdapter, Message, LLMOptions, LLMResponse, StreamChunk } from './types.js';
|
||||
import { config } from '../config/env.js';
|
||||
|
||||
export class QwenAdapter implements ILLMAdapter {
|
||||
modelName: string;
|
||||
private apiKey: string;
|
||||
private baseURL: string;
|
||||
|
||||
constructor(modelName: string = 'qwen-turbo') {
|
||||
this.modelName = modelName;
|
||||
this.apiKey = config.qwenApiKey || '';
|
||||
this.baseURL = 'https://dashscope.aliyuncs.com/api/v1/services/aigc/text-generation/generation';
|
||||
|
||||
if (!this.apiKey) {
|
||||
throw new Error('Qwen API key is not configured');
|
||||
}
|
||||
}
|
||||
|
||||
// 非流式调用
|
||||
async chat(messages: Message[], options?: LLMOptions): Promise<LLMResponse> {
|
||||
try {
|
||||
const response = await axios.post(
|
||||
this.baseURL,
|
||||
{
|
||||
model: this.modelName,
|
||||
input: {
|
||||
messages: messages,
|
||||
},
|
||||
parameters: {
|
||||
temperature: options?.temperature ?? 0.7,
|
||||
max_tokens: options?.maxTokens ?? 2000,
|
||||
top_p: options?.topP ?? 0.9,
|
||||
result_format: 'message',
|
||||
},
|
||||
},
|
||||
{
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
Authorization: `Bearer ${this.apiKey}`,
|
||||
},
|
||||
timeout: 60000,
|
||||
}
|
||||
);
|
||||
|
||||
const output = response.data.output;
|
||||
const usage = response.data.usage;
|
||||
|
||||
return {
|
||||
content: output.choices[0].message.content,
|
||||
model: this.modelName,
|
||||
usage: {
|
||||
promptTokens: usage.input_tokens,
|
||||
completionTokens: usage.output_tokens,
|
||||
totalTokens: usage.total_tokens || usage.input_tokens + usage.output_tokens,
|
||||
},
|
||||
finishReason: output.choices[0].finish_reason,
|
||||
};
|
||||
} catch (error: unknown) {
|
||||
console.error('Qwen API Error:', error);
|
||||
if (axios.isAxiosError(error)) {
|
||||
throw new Error(
|
||||
`Qwen API调用失败: ${error.response?.data?.message || error.message}`
|
||||
);
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
// 流式调用
|
||||
async *chatStream(
|
||||
messages: Message[],
|
||||
options?: LLMOptions,
|
||||
onChunk?: (chunk: StreamChunk) => void
|
||||
): AsyncGenerator<StreamChunk, void, unknown> {
|
||||
try {
|
||||
const response = await axios.post(
|
||||
this.baseURL,
|
||||
{
|
||||
model: this.modelName,
|
||||
input: {
|
||||
messages: messages,
|
||||
},
|
||||
parameters: {
|
||||
temperature: options?.temperature ?? 0.7,
|
||||
max_tokens: options?.maxTokens ?? 2000,
|
||||
top_p: options?.topP ?? 0.9,
|
||||
result_format: 'message',
|
||||
incremental_output: true,
|
||||
},
|
||||
},
|
||||
{
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
Authorization: `Bearer ${this.apiKey}`,
|
||||
'X-DashScope-SSE': 'enable',
|
||||
},
|
||||
responseType: 'stream',
|
||||
timeout: 60000,
|
||||
}
|
||||
);
|
||||
|
||||
const stream = response.data;
|
||||
let buffer = '';
|
||||
|
||||
for await (const chunk of stream) {
|
||||
buffer += chunk.toString();
|
||||
const lines = buffer.split('\n');
|
||||
buffer = lines.pop() || '';
|
||||
|
||||
for (const line of lines) {
|
||||
const trimmedLine = line.trim();
|
||||
if (!trimmedLine || trimmedLine.startsWith(':')) {
|
||||
continue;
|
||||
}
|
||||
|
||||
if (trimmedLine.startsWith('data:')) {
|
||||
try {
|
||||
const jsonStr = trimmedLine.slice(5).trim();
|
||||
const data = JSON.parse(jsonStr);
|
||||
|
||||
const output = data.output;
|
||||
const choice = output.choices[0];
|
||||
const content = choice.message?.content || '';
|
||||
|
||||
const streamChunk: StreamChunk = {
|
||||
content: content,
|
||||
done: choice.finish_reason === 'stop',
|
||||
model: this.modelName,
|
||||
};
|
||||
|
||||
if (choice.finish_reason === 'stop' && data.usage) {
|
||||
streamChunk.usage = {
|
||||
promptTokens: data.usage.input_tokens,
|
||||
completionTokens: data.usage.output_tokens,
|
||||
totalTokens: data.usage.total_tokens || data.usage.input_tokens + data.usage.output_tokens,
|
||||
};
|
||||
}
|
||||
|
||||
if (onChunk) {
|
||||
onChunk(streamChunk);
|
||||
}
|
||||
|
||||
yield streamChunk;
|
||||
} catch (parseError) {
|
||||
console.error('Failed to parse Qwen SSE data:', parseError);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Qwen Stream Error:', error);
|
||||
if (axios.isAxiosError(error)) {
|
||||
throw new Error(
|
||||
`Qwen流式调用失败: ${error.response?.data?.message || error.message}`
|
||||
);
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
55
backend/src/adapters/types.ts
Normal file
55
backend/src/adapters/types.ts
Normal file
@@ -0,0 +1,55 @@
|
||||
// LLM适配器类型定义
|
||||
|
||||
export interface Message {
|
||||
role: 'system' | 'user' | 'assistant';
|
||||
content: string;
|
||||
}
|
||||
|
||||
export interface LLMOptions {
|
||||
temperature?: number;
|
||||
maxTokens?: number;
|
||||
topP?: number;
|
||||
stream?: boolean;
|
||||
}
|
||||
|
||||
export interface LLMResponse {
|
||||
content: string;
|
||||
model: string;
|
||||
usage?: {
|
||||
promptTokens: number;
|
||||
completionTokens: number;
|
||||
totalTokens: number;
|
||||
};
|
||||
finishReason?: string;
|
||||
}
|
||||
|
||||
export interface StreamChunk {
|
||||
content: string;
|
||||
done: boolean;
|
||||
model?: string;
|
||||
usage?: {
|
||||
promptTokens: number;
|
||||
completionTokens: number;
|
||||
totalTokens: number;
|
||||
};
|
||||
}
|
||||
|
||||
// LLM适配器接口
|
||||
export interface ILLMAdapter {
|
||||
// 模型名称
|
||||
modelName: string;
|
||||
|
||||
// 非流式调用
|
||||
chat(messages: Message[], options?: LLMOptions): Promise<LLMResponse>;
|
||||
|
||||
// 流式调用
|
||||
chatStream(
|
||||
messages: Message[],
|
||||
options?: LLMOptions,
|
||||
onChunk?: (chunk: StreamChunk) => void
|
||||
): AsyncGenerator<StreamChunk, void, unknown>;
|
||||
}
|
||||
|
||||
// 支持的模型类型
|
||||
export type ModelType = 'deepseek-v3' | 'qwen3-72b' | 'gemini-pro';
|
||||
|
||||
@@ -1,36 +1,58 @@
|
||||
import { config as dotenvConfig } from 'dotenv';
|
||||
import dotenv from 'dotenv';
|
||||
import path from 'path';
|
||||
import { fileURLToPath } from 'url';
|
||||
|
||||
dotenvConfig();
|
||||
const __filename = fileURLToPath(import.meta.url);
|
||||
const __dirname = path.dirname(__filename);
|
||||
|
||||
// 加载.env文件
|
||||
dotenv.config({ path: path.join(__dirname, '../../.env') });
|
||||
|
||||
export const config = {
|
||||
// 服务器配置
|
||||
nodeEnv: process.env.NODE_ENV || 'development',
|
||||
port: parseInt(process.env.PORT || '3001', 10),
|
||||
host: process.env.HOST || '0.0.0.0',
|
||||
nodeEnv: process.env.NODE_ENV || 'development',
|
||||
logLevel: process.env.LOG_LEVEL || 'info',
|
||||
|
||||
// 数据库配置
|
||||
databaseUrl: process.env.DATABASE_URL || '',
|
||||
databaseUrl: process.env.DATABASE_URL || 'postgresql://postgres:postgres@localhost:5432/ai_clinical',
|
||||
|
||||
// Redis配置
|
||||
redisUrl: process.env.REDIS_URL || 'redis://localhost:6379',
|
||||
|
||||
// JWT配置
|
||||
jwtSecret: process.env.JWT_SECRET || 'your-secret-key',
|
||||
jwtSecret: process.env.JWT_SECRET || 'your-secret-key-change-in-production',
|
||||
jwtExpiresIn: process.env.JWT_EXPIRES_IN || '7d',
|
||||
|
||||
// 大模型API Keys
|
||||
// LLM API配置
|
||||
deepseekApiKey: process.env.DEEPSEEK_API_KEY || '',
|
||||
qwenApiKey: process.env.QWEN_API_KEY || '',
|
||||
geminiApiKey: process.env.GEMINI_API_KEY || '',
|
||||
|
||||
// Dify配置
|
||||
difyApiUrl: process.env.DIFY_API_URL || 'http://localhost:5001',
|
||||
difyApiKey: process.env.DIFY_API_KEY || '',
|
||||
difyApiUrl: process.env.DIFY_API_URL || 'http://localhost/v1',
|
||||
|
||||
// 文件上传配置
|
||||
uploadMaxSize: parseInt(process.env.UPLOAD_MAX_SIZE || '10485760', 10),
|
||||
uploadMaxSize: parseInt(process.env.UPLOAD_MAX_SIZE || '10485760', 10), // 10MB
|
||||
uploadDir: process.env.UPLOAD_DIR || './uploads',
|
||||
|
||||
// 日志配置
|
||||
logLevel: process.env.LOG_LEVEL || 'info',
|
||||
// CORS配置
|
||||
corsOrigin: process.env.CORS_ORIGIN || 'http://localhost:5173',
|
||||
};
|
||||
|
||||
// 验证必需的环境变量
|
||||
export function validateEnv(): void {
|
||||
const requiredVars = ['DATABASE_URL'];
|
||||
const missing = requiredVars.filter(v => !process.env[v]);
|
||||
|
||||
if (missing.length > 0) {
|
||||
console.warn(`Warning: Missing environment variables: ${missing.join(', ')}`);
|
||||
}
|
||||
|
||||
// 检查LLM API Keys
|
||||
if (!config.deepseekApiKey && !config.qwenApiKey) {
|
||||
console.warn('Warning: No LLM API keys configured. At least one of DEEPSEEK_API_KEY or QWEN_API_KEY should be set.');
|
||||
}
|
||||
}
|
||||
|
||||
263
backend/src/controllers/conversationController.ts
Normal file
263
backend/src/controllers/conversationController.ts
Normal file
@@ -0,0 +1,263 @@
|
||||
import { FastifyRequest, FastifyReply } from 'fastify';
|
||||
import { conversationService } from '../services/conversationService.js';
|
||||
import { ModelType } from '../adapters/types.js';
|
||||
|
||||
export class ConversationController {
|
||||
/**
|
||||
* 创建新对话
|
||||
*/
|
||||
async createConversation(
|
||||
request: FastifyRequest<{
|
||||
Body: {
|
||||
projectId: string;
|
||||
agentId: string;
|
||||
title?: string;
|
||||
};
|
||||
}>,
|
||||
reply: FastifyReply
|
||||
) {
|
||||
try {
|
||||
// TODO: 从JWT token获取userId
|
||||
const userId = '1'; // 临时硬编码
|
||||
|
||||
const { projectId, agentId, title } = request.body;
|
||||
|
||||
const conversation = await conversationService.createConversation({
|
||||
userId,
|
||||
projectId,
|
||||
agentId,
|
||||
title,
|
||||
});
|
||||
|
||||
reply.code(201).send({
|
||||
success: true,
|
||||
data: conversation,
|
||||
});
|
||||
} catch (error: any) {
|
||||
reply.code(400).send({
|
||||
success: false,
|
||||
message: error.message || '创建对话失败',
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取对话列表
|
||||
*/
|
||||
async getConversations(
|
||||
request: FastifyRequest<{
|
||||
Querystring: {
|
||||
projectId?: string;
|
||||
};
|
||||
}>,
|
||||
reply: FastifyReply
|
||||
) {
|
||||
try {
|
||||
// TODO: 从JWT token获取userId
|
||||
const userId = '1';
|
||||
|
||||
const projectId = request.query.projectId;
|
||||
|
||||
const conversations = await conversationService.getConversations(
|
||||
userId,
|
||||
projectId
|
||||
);
|
||||
|
||||
reply.send({
|
||||
success: true,
|
||||
data: conversations,
|
||||
});
|
||||
} catch (error: any) {
|
||||
reply.code(500).send({
|
||||
success: false,
|
||||
message: error.message || '获取对话列表失败',
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取对话详情
|
||||
*/
|
||||
async getConversationById(
|
||||
request: FastifyRequest<{
|
||||
Params: {
|
||||
id: string;
|
||||
};
|
||||
}>,
|
||||
reply: FastifyReply
|
||||
) {
|
||||
try {
|
||||
// TODO: 从JWT token获取userId
|
||||
const userId = '1';
|
||||
|
||||
const conversationId = request.params.id;
|
||||
|
||||
const conversation = await conversationService.getConversationById(
|
||||
conversationId,
|
||||
userId
|
||||
);
|
||||
|
||||
reply.send({
|
||||
success: true,
|
||||
data: conversation,
|
||||
});
|
||||
} catch (error: any) {
|
||||
reply.code(404).send({
|
||||
success: false,
|
||||
message: error.message || '对话不存在',
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 发送消息(非流式)
|
||||
*/
|
||||
async sendMessage(
|
||||
request: FastifyRequest<{
|
||||
Body: {
|
||||
conversationId: string;
|
||||
content: string;
|
||||
modelType: ModelType;
|
||||
knowledgeBaseIds?: string[];
|
||||
};
|
||||
}>,
|
||||
reply: FastifyReply
|
||||
) {
|
||||
try {
|
||||
// TODO: 从JWT token获取userId
|
||||
const userId = '1';
|
||||
|
||||
const { conversationId, content, modelType, knowledgeBaseIds } =
|
||||
request.body;
|
||||
|
||||
// 验证modelType
|
||||
if (modelType !== 'deepseek-v3' && modelType !== 'qwen3-72b' && modelType !== 'gemini-pro') {
|
||||
reply.code(400).send({
|
||||
success: false,
|
||||
message: `不支持的模型类型: ${modelType}`,
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
const result = await conversationService.sendMessage(
|
||||
{
|
||||
conversationId,
|
||||
content,
|
||||
modelType,
|
||||
knowledgeBaseIds,
|
||||
},
|
||||
userId
|
||||
);
|
||||
|
||||
reply.send({
|
||||
success: true,
|
||||
data: result,
|
||||
});
|
||||
} catch (error: any) {
|
||||
reply.code(400).send({
|
||||
success: false,
|
||||
message: error.message || '发送消息失败',
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 发送消息(流式输出,SSE)
|
||||
*/
|
||||
async sendMessageStream(
|
||||
request: FastifyRequest<{
|
||||
Body: {
|
||||
conversationId: string;
|
||||
content: string;
|
||||
modelType: ModelType;
|
||||
knowledgeBaseIds?: string[];
|
||||
};
|
||||
}>,
|
||||
reply: FastifyReply
|
||||
) {
|
||||
try {
|
||||
// TODO: 从JWT token获取userId
|
||||
const userId = '1';
|
||||
|
||||
const { conversationId, content, modelType, knowledgeBaseIds } =
|
||||
request.body;
|
||||
|
||||
// 验证modelType
|
||||
if (modelType !== 'deepseek-v3' && modelType !== 'qwen3-72b' && modelType !== 'gemini-pro') {
|
||||
reply.code(400).send({
|
||||
success: false,
|
||||
message: `不支持的模型类型: ${modelType}`,
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
// 设置SSE响应头
|
||||
reply.raw.writeHead(200, {
|
||||
'Content-Type': 'text/event-stream',
|
||||
'Cache-Control': 'no-cache',
|
||||
Connection: 'keep-alive',
|
||||
'Access-Control-Allow-Origin': '*',
|
||||
});
|
||||
|
||||
// 流式输出
|
||||
for await (const chunk of conversationService.sendMessageStream(
|
||||
{
|
||||
conversationId,
|
||||
content,
|
||||
modelType,
|
||||
knowledgeBaseIds,
|
||||
},
|
||||
userId
|
||||
)) {
|
||||
// 发送SSE数据
|
||||
reply.raw.write(`data: ${JSON.stringify(chunk)}\n\n`);
|
||||
}
|
||||
|
||||
// 发送结束标记
|
||||
reply.raw.write('data: [DONE]\n\n');
|
||||
reply.raw.end();
|
||||
} catch (error: any) {
|
||||
console.error('Stream error:', error);
|
||||
reply.raw.write(
|
||||
`data: ${JSON.stringify({
|
||||
error: error.message || '发送消息失败',
|
||||
})}\n\n`
|
||||
);
|
||||
reply.raw.end();
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 删除对话
|
||||
*/
|
||||
async deleteConversation(
|
||||
request: FastifyRequest<{
|
||||
Params: {
|
||||
id: string;
|
||||
};
|
||||
}>,
|
||||
reply: FastifyReply
|
||||
) {
|
||||
try {
|
||||
// TODO: 从JWT token获取userId
|
||||
const userId = '1';
|
||||
|
||||
const conversationId = request.params.id;
|
||||
|
||||
await conversationService.deleteConversation(conversationId, userId);
|
||||
|
||||
reply.send({
|
||||
success: true,
|
||||
message: '对话已删除',
|
||||
});
|
||||
} catch (error: any) {
|
||||
reply.code(400).send({
|
||||
success: false,
|
||||
message: error.message || '删除对话失败',
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export const conversationController = new ConversationController();
|
||||
|
||||
@@ -1,9 +1,10 @@
|
||||
import Fastify from 'fastify';
|
||||
import cors from '@fastify/cors';
|
||||
import { config } from './config/env.js';
|
||||
import { config, validateEnv } from './config/env.js';
|
||||
import { testDatabaseConnection, prisma } from './config/database.js';
|
||||
import { projectRoutes } from './routes/projects.js';
|
||||
import { agentRoutes } from './routes/agents.js';
|
||||
import { conversationRoutes } from './routes/conversations.js';
|
||||
|
||||
const fastify = Fastify({
|
||||
logger: {
|
||||
@@ -59,9 +60,15 @@ await fastify.register(projectRoutes, { prefix: '/api/v1' });
|
||||
// 注册智能体管理路由
|
||||
await fastify.register(agentRoutes, { prefix: '/api/v1' });
|
||||
|
||||
// 注册对话管理路由
|
||||
await fastify.register(conversationRoutes, { prefix: '/api/v1' });
|
||||
|
||||
// 启动服务器
|
||||
const start = async () => {
|
||||
try {
|
||||
// 验证环境变量
|
||||
validateEnv();
|
||||
|
||||
// 测试数据库连接
|
||||
console.log('🔍 正在测试数据库连接...');
|
||||
const dbConnected = await testDatabaseConnection();
|
||||
|
||||
35
backend/src/routes/conversations.ts
Normal file
35
backend/src/routes/conversations.ts
Normal file
@@ -0,0 +1,35 @@
|
||||
import { FastifyInstance, FastifyRequest, FastifyReply } from 'fastify';
|
||||
import { conversationController } from '../controllers/conversationController.js';
|
||||
|
||||
export async function conversationRoutes(fastify: FastifyInstance) {
|
||||
// 创建对话
|
||||
fastify.post('/conversations', async (request: FastifyRequest, reply: FastifyReply) => {
|
||||
return conversationController.createConversation(request as any, reply);
|
||||
});
|
||||
|
||||
// 获取对话列表
|
||||
fastify.get('/conversations', async (request: FastifyRequest, reply: FastifyReply) => {
|
||||
return conversationController.getConversations(request as any, reply);
|
||||
});
|
||||
|
||||
// 获取对话详情
|
||||
fastify.get('/conversations/:id', async (request: FastifyRequest, reply: FastifyReply) => {
|
||||
return conversationController.getConversationById(request as any, reply);
|
||||
});
|
||||
|
||||
// 发送消息(非流式)
|
||||
fastify.post('/conversations/message', async (request: FastifyRequest, reply: FastifyReply) => {
|
||||
return conversationController.sendMessage(request as any, reply);
|
||||
});
|
||||
|
||||
// 发送消息(流式输出)
|
||||
fastify.post('/conversations/message/stream', async (request: FastifyRequest, reply: FastifyReply) => {
|
||||
return conversationController.sendMessageStream(request as any, reply);
|
||||
});
|
||||
|
||||
// 删除对话
|
||||
fastify.delete('/conversations/:id', async (request: FastifyRequest, reply: FastifyReply) => {
|
||||
return conversationController.deleteConversation(request as any, reply);
|
||||
});
|
||||
}
|
||||
|
||||
384
backend/src/services/conversationService.ts
Normal file
384
backend/src/services/conversationService.ts
Normal file
@@ -0,0 +1,384 @@
|
||||
import { prisma } from '../config/database.js';
|
||||
import { LLMFactory } from '../adapters/LLMFactory.js';
|
||||
import { Message, ModelType, StreamChunk } from '../adapters/types.js';
|
||||
import { agentService } from './agentService.js';
|
||||
|
||||
interface CreateConversationData {
|
||||
userId: string;
|
||||
projectId: string;
|
||||
agentId: string;
|
||||
title?: string;
|
||||
}
|
||||
|
||||
interface SendMessageData {
|
||||
conversationId: string;
|
||||
content: string;
|
||||
modelType: ModelType;
|
||||
knowledgeBaseIds?: string[];
|
||||
}
|
||||
|
||||
export class ConversationService {
|
||||
/**
|
||||
* 创建新对话
|
||||
*/
|
||||
async createConversation(data: CreateConversationData) {
|
||||
const { userId, projectId, agentId, title } = data;
|
||||
|
||||
// 验证智能体是否存在
|
||||
const agent = agentService.getAgentById(agentId);
|
||||
if (!agent) {
|
||||
throw new Error('智能体不存在');
|
||||
}
|
||||
|
||||
// 验证项目是否存在
|
||||
const project = await prisma.project.findFirst({
|
||||
where: {
|
||||
id: projectId,
|
||||
userId: userId,
|
||||
deletedAt: null,
|
||||
},
|
||||
});
|
||||
|
||||
if (!project) {
|
||||
throw new Error('项目不存在或无权访问');
|
||||
}
|
||||
|
||||
// 创建对话
|
||||
const conversation = await prisma.conversation.create({
|
||||
data: {
|
||||
userId,
|
||||
projectId,
|
||||
agentId,
|
||||
title: title || `与${agent.name}的对话`,
|
||||
metadata: {
|
||||
agentName: agent.name,
|
||||
agentCategory: agent.category,
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
return conversation;
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取对话列表
|
||||
*/
|
||||
async getConversations(userId: string, projectId?: string) {
|
||||
const where: any = {
|
||||
userId,
|
||||
deletedAt: null,
|
||||
};
|
||||
|
||||
if (projectId) {
|
||||
where.projectId = projectId;
|
||||
}
|
||||
|
||||
const conversations = await prisma.conversation.findMany({
|
||||
where,
|
||||
include: {
|
||||
project: {
|
||||
select: {
|
||||
id: true,
|
||||
name: true,
|
||||
},
|
||||
},
|
||||
_count: {
|
||||
select: {
|
||||
messages: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
orderBy: {
|
||||
updatedAt: 'desc',
|
||||
},
|
||||
});
|
||||
|
||||
return conversations;
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取对话详情(包含消息)
|
||||
*/
|
||||
async getConversationById(conversationId: string, userId: string) {
|
||||
const conversation = await prisma.conversation.findFirst({
|
||||
where: {
|
||||
id: conversationId,
|
||||
userId,
|
||||
deletedAt: null,
|
||||
},
|
||||
include: {
|
||||
project: {
|
||||
select: {
|
||||
id: true,
|
||||
name: true,
|
||||
background: true,
|
||||
researchType: true,
|
||||
},
|
||||
},
|
||||
messages: {
|
||||
orderBy: {
|
||||
createdAt: 'asc',
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
if (!conversation) {
|
||||
throw new Error('对话不存在或无权访问');
|
||||
}
|
||||
|
||||
return conversation;
|
||||
}
|
||||
|
||||
/**
|
||||
* 组装上下文消息
|
||||
*/
|
||||
private async assembleContext(
|
||||
conversationId: string,
|
||||
agentId: string,
|
||||
projectBackground: string,
|
||||
userInput: string,
|
||||
knowledgeBaseContext?: string
|
||||
): Promise<Message[]> {
|
||||
// 获取系统Prompt
|
||||
const systemPrompt = agentService.getSystemPrompt(agentId);
|
||||
|
||||
// 获取历史消息(最近10条)
|
||||
const historyMessages = await prisma.message.findMany({
|
||||
where: {
|
||||
conversationId,
|
||||
},
|
||||
orderBy: {
|
||||
createdAt: 'desc',
|
||||
},
|
||||
take: 10,
|
||||
});
|
||||
|
||||
// 反转顺序(最早的在前)
|
||||
historyMessages.reverse();
|
||||
|
||||
// 渲染用户Prompt模板
|
||||
const renderedUserPrompt = agentService.renderUserPrompt(agentId, {
|
||||
projectBackground,
|
||||
userInput,
|
||||
knowledgeBaseContext,
|
||||
});
|
||||
|
||||
// 组装消息数组
|
||||
const messages: Message[] = [
|
||||
{
|
||||
role: 'system',
|
||||
content: systemPrompt,
|
||||
},
|
||||
];
|
||||
|
||||
// 添加历史消息
|
||||
for (const msg of historyMessages) {
|
||||
messages.push({
|
||||
role: msg.role as 'user' | 'assistant',
|
||||
content: msg.content,
|
||||
});
|
||||
}
|
||||
|
||||
// 添加当前用户输入
|
||||
messages.push({
|
||||
role: 'user',
|
||||
content: renderedUserPrompt,
|
||||
});
|
||||
|
||||
return messages;
|
||||
}
|
||||
|
||||
/**
|
||||
* 发送消息(非流式)
|
||||
*/
|
||||
async sendMessage(data: SendMessageData, userId: string) {
|
||||
const { conversationId, content, modelType, knowledgeBaseIds } = data;
|
||||
|
||||
// 获取对话信息
|
||||
const conversation = await this.getConversationById(conversationId, userId);
|
||||
|
||||
// 获取知识库上下文(如果有@知识库)
|
||||
let knowledgeBaseContext = '';
|
||||
if (knowledgeBaseIds && knowledgeBaseIds.length > 0) {
|
||||
// TODO: 调用Dify RAG获取知识库上下文
|
||||
knowledgeBaseContext = '相关文献内容...';
|
||||
}
|
||||
|
||||
// 组装上下文
|
||||
const messages = await this.assembleContext(
|
||||
conversationId,
|
||||
conversation.agentId,
|
||||
conversation.project?.background || '',
|
||||
content,
|
||||
knowledgeBaseContext
|
||||
);
|
||||
|
||||
// 获取LLM适配器
|
||||
const adapter = LLMFactory.getAdapter(modelType);
|
||||
|
||||
// 获取智能体配置的模型参数
|
||||
const agent = agentService.getAgentById(conversation.agentId);
|
||||
const modelConfig = agent?.models?.[modelType];
|
||||
|
||||
// 调用LLM
|
||||
const response = await adapter.chat(messages, {
|
||||
temperature: modelConfig?.temperature,
|
||||
maxTokens: modelConfig?.maxTokens,
|
||||
topP: modelConfig?.topP,
|
||||
});
|
||||
|
||||
// 保存用户消息
|
||||
const userMessage = await prisma.message.create({
|
||||
data: {
|
||||
conversationId,
|
||||
role: 'user',
|
||||
content,
|
||||
metadata: {
|
||||
knowledgeBaseIds,
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
// 保存助手回复
|
||||
const assistantMessage = await prisma.message.create({
|
||||
data: {
|
||||
conversationId,
|
||||
role: 'assistant',
|
||||
content: response.content,
|
||||
model: response.model,
|
||||
tokens: response.usage?.totalTokens,
|
||||
metadata: {
|
||||
usage: response.usage,
|
||||
finishReason: response.finishReason,
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
// 更新对话的最后更新时间
|
||||
await prisma.conversation.update({
|
||||
where: { id: conversationId },
|
||||
data: { updatedAt: new Date() },
|
||||
});
|
||||
|
||||
return {
|
||||
userMessage,
|
||||
assistantMessage,
|
||||
usage: response.usage,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* 发送消息(流式)
|
||||
*/
|
||||
async *sendMessageStream(
|
||||
data: SendMessageData,
|
||||
userId: string
|
||||
): AsyncGenerator<StreamChunk, void, unknown> {
|
||||
const { conversationId, content, modelType, knowledgeBaseIds } = data;
|
||||
|
||||
// 获取对话信息
|
||||
const conversation = await this.getConversationById(conversationId, userId);
|
||||
|
||||
// 获取知识库上下文(如果有@知识库)
|
||||
let knowledgeBaseContext = '';
|
||||
if (knowledgeBaseIds && knowledgeBaseIds.length > 0) {
|
||||
// TODO: 调用Dify RAG获取知识库上下文
|
||||
knowledgeBaseContext = '相关文献内容...';
|
||||
}
|
||||
|
||||
// 组装上下文
|
||||
const messages = await this.assembleContext(
|
||||
conversationId,
|
||||
conversation.agentId,
|
||||
conversation.project?.background || '',
|
||||
content,
|
||||
knowledgeBaseContext
|
||||
);
|
||||
|
||||
// 获取LLM适配器
|
||||
const adapter = LLMFactory.getAdapter(modelType);
|
||||
|
||||
// 获取智能体配置的模型参数
|
||||
const agent = agentService.getAgentById(conversation.agentId);
|
||||
const modelConfig = agent?.models?.[modelType];
|
||||
|
||||
// 保存用户消息
|
||||
await prisma.message.create({
|
||||
data: {
|
||||
conversationId,
|
||||
role: 'user',
|
||||
content,
|
||||
metadata: {
|
||||
knowledgeBaseIds,
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
// 用于累积完整的回复内容
|
||||
let fullContent = '';
|
||||
let usage: any = null;
|
||||
|
||||
// 流式调用LLM
|
||||
for await (const chunk of adapter.chatStream(messages, {
|
||||
temperature: modelConfig?.temperature,
|
||||
maxTokens: modelConfig?.maxTokens,
|
||||
topP: modelConfig?.topP,
|
||||
})) {
|
||||
fullContent += chunk.content;
|
||||
|
||||
if (chunk.usage) {
|
||||
usage = chunk.usage;
|
||||
}
|
||||
|
||||
yield chunk;
|
||||
}
|
||||
|
||||
// 流式输出完成后,保存助手回复
|
||||
await prisma.message.create({
|
||||
data: {
|
||||
conversationId,
|
||||
role: 'assistant',
|
||||
content: fullContent,
|
||||
model: modelType,
|
||||
tokens: usage?.totalTokens,
|
||||
metadata: {
|
||||
usage,
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
// 更新对话的最后更新时间
|
||||
await prisma.conversation.update({
|
||||
where: { id: conversationId },
|
||||
data: { updatedAt: new Date() },
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* 删除对话(软删除)
|
||||
*/
|
||||
async deleteConversation(conversationId: string, userId: string) {
|
||||
const conversation = await prisma.conversation.findFirst({
|
||||
where: {
|
||||
id: conversationId,
|
||||
userId,
|
||||
deletedAt: null,
|
||||
},
|
||||
});
|
||||
|
||||
if (!conversation) {
|
||||
throw new Error('对话不存在或无权访问');
|
||||
}
|
||||
|
||||
await prisma.conversation.update({
|
||||
where: { id: conversationId },
|
||||
data: { deletedAt: new Date() },
|
||||
});
|
||||
|
||||
return { success: true };
|
||||
}
|
||||
}
|
||||
|
||||
export const conversationService = new ConversationService();
|
||||
|
||||
Reference in New Issue
Block a user