diff --git a/apps/chat/__init__.py b/apps/chat/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/apps/chat/admin.py b/apps/chat/admin.py new file mode 100644 index 0000000..8c38f3f --- /dev/null +++ b/apps/chat/admin.py @@ -0,0 +1,3 @@ +from django.contrib import admin + +# Register your models here. diff --git a/apps/chat/apps.py b/apps/chat/apps.py new file mode 100644 index 0000000..2d27770 --- /dev/null +++ b/apps/chat/apps.py @@ -0,0 +1,6 @@ +from django.apps import AppConfig + + +class ChatConfig(AppConfig): + default_auto_field = 'django.db.models.BigAutoField' + name = 'apps.chat' diff --git a/apps/chat/chat.md b/apps/chat/chat.md new file mode 100644 index 0000000..a6323a3 --- /dev/null +++ b/apps/chat/chat.md @@ -0,0 +1,419 @@ +# Chat 模块接口文档 + +## 概述 + +Chat 模块提供了一组 API,用于创建和管理对话。这包括创建新对话、发送消息、接收回答、查询历史记录等功能。所有接口都基于 REST 风格设计。 + +## 认证 + +除特殊说明外,所有接口都需要认证。请在请求头中添加认证信息: + +``` +Authorization: Bearer +``` + +或 + +``` +Authorization: Token +``` + +## REST API接口列表 + +### 1. 创建会话 + +创建一个新的对话会话。 + +- **URL**: `/api/chat-history/create_conversation/` +- **方法**: `POST` +- **认证**: 不需要认证 +- **响应**: + +```json +{ + "code": 200, + "message": "会话创建成功", + "data": { + "conversation_id": "uuid-string" + } +} +``` + +### 2. 发送消息并接收回答 + +发送用户问题并获取 AI 回答。 + +- **URL**: `/api/chat-history/` +- **方法**: `POST` +- **参数**: + +| 参数名 | 类型 | 必填 | 描述 | +|-------|------|------|------| +| conversation_id | string | 是 | 会话 ID | +| question | string | 是 | 用户问题 | +| title | string | 否 | 会话标题,默认为 "New chat" | +| stream | boolean | 否 | 是否使用流式响应,默认为 true | + +- **响应** (非流式): + +```json +{ + "code": 200, + "message": "成功", + "data": { + "id": "记录ID", + "conversation_id": "会话ID", + "title": "会话标题", + "role": "assistant", + "content": "AI回答内容", + "created_at": "创建时间" + } +} +``` + +- **响应** (流式): + +返回 `text/event-stream` 类型的响应,包含多个 data 事件: + +``` +data: {"code": 200, "message": "开始流式传输", "data": {"id": "记录ID", "conversation_id": "会话ID", "content": "", "is_end": false}} + +data: {"code": 200, "message": "partial", "data": {"id": "记录ID", "conversation_id": "会话ID", "title": "标题", "content": "部分内容", "is_end": false}} + +... + +data: {"code": 200, "message": "完成", "data": {"id": "记录ID", "conversation_id": "会话ID", "title": "标题", "role": "assistant", "content": "完整内容", "created_at": "时间", "is_end": true}} +``` + +### 3. 获取对话列表 + +获取用户的所有对话列表。 + +- **URL**: `/api/chat-history/` +- **方法**: `GET` +- **参数**: + +| 参数名 | 类型 | 必填 | 描述 | +|-------|------|------|------| +| page | integer | 否 | 页码,默认为 1 | +| page_size | integer | 否 | 每页条数,默认为 10 | + +- **响应**: + +```json +{ + "code": 200, + "message": "获取成功", + "data": { + "total": 总记录数, + "page": 当前页码, + "page_size": 每页条数, + "results": [ + { + "conversation_id": "会话ID", + "message_count": 消息数量, + "last_message": "最后一条消息内容", + "last_time": "最后更新时间" + } + ] + } +} +``` + +### 4. 获取对话详情 + +获取特定对话的所有消息。 + +- **URL**: `/api/chat-history/conversation_detail/` +- **方法**: `GET` +- **参数**: + +| 参数名 | 类型 | 必填 | 描述 | +|-------|------|------|------| +| conversation_id | string | 是 | 会话 ID | + +- **响应**: + +```json +{ + "code": 200, + "message": "获取成功", + "data": { + "conversation_id": "会话ID", + "title": "会话标题", + "created_at": "创建时间", + "messages": [ + { + "id": "消息ID", + "role": "用户角色(user/assistant)", + "content": "消息内容", + "created_at": "创建时间" + } + ] + } +} +``` + +### 5. 搜索聊天记录 + +根据关键词搜索聊天记录。 + +- **URL**: `/api/chat-history/search/` +- **方法**: `GET` +- **参数**: + +| 参数名 | 类型 | 必填 | 描述 | +|-------|------|------|------| +| keyword | string | 否 | 搜索关键词 | +| start_date | string | 否 | 开始日期,格式为 YYYY-MM-DD | +| end_date | string | 否 | 结束日期,格式为 YYYY-MM-DD | +| page | integer | 否 | 页码,默认为 1 | +| page_size | integer | 否 | 每页条数,默认为 10 | + +- **响应**: + +```json +{ + "code": 200, + "message": "搜索成功", + "data": { + "total": 总记录数, + "page": 当前页码, + "page_size": 每页条数, + "results": [ + { + "id": "记录ID", + "conversation_id": "会话ID", + "role": "角色", + "content": "内容", + "created_at": "创建时间", + "metadata": {}, + "highlights": { + "content": "高亮后的内容" + } + } + ] + } +} +``` + +### 6. 删除会话 + +删除整个会话及其所有消息。 + +- **URL**: `/api/chat-history/delete_conversation/` +- **方法**: `DELETE` +- **参数**: + +| 参数名 | 类型 | 必填 | 描述 | +|-------|------|------|------| +| conversation_id | string | 是 | 要删除的会话 ID | + +- **响应**: + +```json +{ + "code": 200, + "message": "删除成功", + "data": { + "conversation_id": "会话ID", + "deleted_count": 删除的消息数量 + } +} +``` + +### 7. 更新消息内容 + +更新单条消息的内容。 + +- **URL**: `/api/chat-history/{id}/` +- **方法**: `PUT` 或 `PATCH` +- **参数**: + +| 参数名 | 类型 | 必填 | 描述 | +|-------|------|------|------| +| content | string | 否 | 更新的消息内容 | +| metadata | object | 否 | 更新的元数据 | + +- **响应**: + +```json +{ + "code": 200, + "message": "更新成功", + "data": { + "id": "记录ID", + "conversation_id": "会话ID", + "role": "角色", + "content": "更新后的内容", + "metadata": {}, + "updated_at": "更新时间" + } +} +``` + +### 8. 删除单条消息 + +删除单条消息(软删除)。 + +- **URL**: `/api/chat-history/{id}/` +- **方法**: `DELETE` +- **响应**: + +```json +{ + "code": 200, + "message": "删除成功", + "data": null +} +``` + +### 9. 更新会话标题 + +设置或更新会话的标题。 + +- **URL**: `/api/chat-history/generate-conversation-title/` +- **方法**: `GET` +- **参数**: + +| 参数名 | 类型 | 必填 | 描述 | +|-------|------|------|------| +| conversation_id | string | 是 | 会话 ID | +| title | string | 是 | 新标题 | + +- **响应**: + +```json +{ + "code": 200, + "message": "更新会话标题成功", + "data": { + "conversation_id": "会话ID", + "title": "新标题" + } +} +``` + +### 10. 获取可用知识库 + +获取系统中可用的知识库列表。 + +- **URL**: `/api/chat-history/available_datasets/` +- **方法**: `GET` +- **响应**: + +```json +{ + "code": 200, + "message": "获取成功", + "data": [] +} +``` + +## WebSocket API + +除了REST API之外,Chat模块还提供了WebSocket接口,用于实时流式获取对话回答。 + +### 1. 建立WebSocket连接 + +- **URL**: `ws://yourdomain:port/ws/chat/stream/?token=` +- **认证**: 需要在URL参数中提供token +- **说明**: token是用户的认证token,即登录接口获取的token,通过UserToken模型验证 +- **获取token**: 通过API登录接口获取token: + ``` + POST /api/user/login/ + { + "email": "用户邮箱", + "password": "用户密码" + } + ``` + 成功登录后,响应将返回token字段值 + +### 2. 发送消息 + +连接建立后,可以向WebSocket发送JSON格式的消息: + +```json +{ + "conversation_id": "会话ID", + "question": "用户问题" +} +``` + +### 3. 接收响应 + +WebSocket会返回以下JSON格式的消息: + +1. 开始流式传输: +```json +{ + "code": 200, + "message": "开始流式传输", + "data": { + "id": "记录ID", + "conversation_id": "会话ID", + "content": "", + "is_end": false + } +} +``` + +2. 部分内容: +```json +{ + "code": 200, + "message": "partial", + "data": { + "id": "记录ID", + "conversation_id": "会话ID", + "content": "部分内容", + "is_end": false + } +} +``` + +3. 完成响应: +```json +{ + "code": 200, + "message": "完成", + "data": { + "id": "记录ID", + "conversation_id": "会话ID", + "title": "会话标题", + "role": "assistant", + "content": "完整内容", + "created_at": "时间", + "is_end": true + } +} +``` + +4. 错误响应: +```json +{ + "code": 500, + "message": "错误信息", + "data": { + "is_end": true + } +} +``` + +## 错误响应 + +所有接口在出错时都会返回类似的错误响应结构: + +```json +{ + "code": 错误代码, + "message": "错误描述", + "data": null +} +``` + +常见错误代码: +- 400: 请求参数错误 +- 401: 未认证或认证失败 +- 404: 资源不存在 +- 500: 服务器内部错误 diff --git a/apps/chat/consumers.py b/apps/chat/consumers.py new file mode 100644 index 0000000..a11941d --- /dev/null +++ b/apps/chat/consumers.py @@ -0,0 +1,384 @@ +# apps/chat/consumers.py +from channels.generic.websocket import AsyncWebsocketConsumer +import json +from channels.db import database_sync_to_async +from apps.chat.models import ChatHistory +from apps.user.models import UserToken +from django.conf import settings +import logging +import traceback +import uuid +import aiohttp +from urllib.parse import parse_qs +from django.utils import timezone + +logger = logging.getLogger(__name__) + +class ChatStreamConsumer(AsyncWebsocketConsumer): + # 固定知识库ID + DEFAULT_KNOWLEDGE_BASE_ID = "b680a4fa-37be-11f0-a7cb-0242ac120002" + + async def connect(self): + """建立WebSocket连接""" + try: + # 从URL参数中获取token + query_string = self.scope.get('query_string', b'').decode() + query_params = parse_qs(query_string) + token_key = query_params.get('token', [''])[0] + + if not token_key: + logger.warning("WebSocket连接尝试,但没有提供token") + await self.close() + return + + # 验证token + self.user = await self.get_user_from_token(token_key) + if not self.user: + logger.warning(f"WebSocket连接尝试,但token无效: {token_key}") + await self.close() + return + + # 将用户信息存储在scope中 + self.scope["user"] = self.user + await self.accept() + logger.info(f"用户 {self.user.email} 流式输出WebSocket连接成功") + + except Exception as e: + logger.error(f"WebSocket连接错误: {str(e)}") + await self.close() + + @database_sync_to_async + def get_user_from_token(self, token_key): + try: + # 使用项目的UserToken模型而不是rest_framework的Token + token = UserToken.objects.select_related('user').get( + token=token_key, + expired_at__gt=timezone.now() # 确保token未过期 + ) + return token.user + except UserToken.DoesNotExist: + return None + + async def disconnect(self, close_code): + """关闭WebSocket连接""" + logger.info(f"用户 {self.user.email if hasattr(self, 'user') else 'unknown'} WebSocket连接断开,代码: {close_code}") + + async def receive(self, text_data): + """接收消息并处理""" + try: + data = json.loads(text_data) + + # 检查必填字段 + if 'question' not in data: + await self.send_error("缺少必填字段: question") + return + + if 'conversation_id' not in data: + await self.send_error("缺少必填字段: conversation_id") + return + + # 处理新会话或现有会话 + await self.process_chat_request(data) + + except Exception as e: + logger.error(f"处理消息时出错: {str(e)}") + logger.error(traceback.format_exc()) + await self.send_error(f"处理消息时出错: {str(e)}") + + async def process_chat_request(self, data): + """处理聊天请求""" + try: + conversation_id = data['conversation_id'] + question = data['question'] + + # 准备metadata + metadata = {} + + # 创建问题记录 + question_record = await self.create_question_record( + conversation_id, + question, + metadata + ) + + if not question_record: + return + + # 创建AI回答记录 + answer_record = await self.create_answer_record( + conversation_id, + question_record, + metadata + ) + + # 发送初始响应 + await self.send_json({ + 'code': 200, + 'message': '开始流式传输', + 'data': { + 'id': str(answer_record.id), + 'conversation_id': str(conversation_id), + 'content': '', + 'is_end': False + } + }) + + # 设置外部API需要的ID列表 - 简化为空列表 + dataset_external_id_list = [] + + # 调用外部API获取流式响应 + await self.stream_from_external_api( + conversation_id, + question, + dataset_external_id_list, + answer_record, + metadata + ) + + except Exception as e: + logger.error(f"处理聊天请求时出错: {str(e)}") + logger.error(traceback.format_exc()) + await self.send_error(f"处理聊天请求时出错: {str(e)}") + + @database_sync_to_async + def create_question_record(self, conversation_id, question, metadata): + """创建问题记录""" + try: + title = "New chat" + + # 创建用户问题记录 + return ChatHistory.objects.create( + user=self.scope["user"], + knowledge_base_id=self.DEFAULT_KNOWLEDGE_BASE_ID, + conversation_id=str(conversation_id), + title=title, + role='user', + content=question, + metadata=metadata + ) + except Exception as e: + logger.error(f"创建问题记录时出错: {str(e)}") + return None + + @database_sync_to_async + def create_answer_record(self, conversation_id, question_record, metadata): + """创建AI回答记录""" + try: + return ChatHistory.objects.create( + user=self.scope["user"], + knowledge_base_id=self.DEFAULT_KNOWLEDGE_BASE_ID, + conversation_id=str(conversation_id), + title=question_record.title, + parent_id=str(question_record.id), + role='assistant', + content="", # 初始内容为空 + metadata=metadata + ) + except Exception as e: + logger.error(f"创建回答记录时出错: {str(e)}") + return None + + async def stream_from_external_api(self, conversation_id, question, dataset_external_id_list, answer_record, metadata): + """从外部API获取流式响应""" + try: + # 获取标题 + title = answer_record.title or 'New chat' + + # 异步收集完整内容,用于最后保存 + full_content = "" + + # 使用aiohttp进行异步HTTP请求 + async with aiohttp.ClientSession() as session: + # 第一步: 创建聊天会话 + async with session.post( + f"{settings.API_BASE_URL}/api/application/chat/open", + json={ + "id": "d5d11efa-ea9a-11ef-9933-0242ac120006", + "model_id": metadata.get('model_id', '7a214d0e-e65e-11ef-9f4a-0242ac120006'), + "dataset_id_list": dataset_external_id_list, + "multiple_rounds_dialogue": False, + "dataset_setting": { + "top_n": 10, "similarity": "0.3", + "max_paragraph_char_number": 10000, + "search_mode": "blend", + "no_references_setting": { + "value": "{question}", + "status": "ai_questioning" + } + }, + "model_setting": { + "prompt": "**相关文档内容**:{data} **回答要求**:如果相关文档内容中没有可用信息,请回答\"没有在知识库中查找到相关信息,建议咨询相关技术支持或参考官方文档进行操作\"。请根据相关文档内容回答用户问题。不要输出与用户问题无关的内容。请使用中文回答客户问题。**用户问题**:{question}" + }, + "problem_optimization": False + } + ) as chat_response: + + if chat_response.status != 200: + error_msg = f"外部API调用失败: {await chat_response.text()}" + logger.error(error_msg) + await self.send_error(error_msg) + return + + chat_data = await chat_response.json() + if chat_data.get('code') != 200 or not chat_data.get('data'): + error_msg = f"外部API返回错误: {chat_data}" + logger.error(error_msg) + await self.send_error(error_msg) + return + + chat_id = chat_data['data'] + logger.info(f"成功创建聊天会话, chat_id: {chat_id}") + + # 第二步: 建立流式连接 + message_url = f"{settings.API_BASE_URL}/api/application/chat_message/{chat_id}" + logger.info(f"开始流式请求: {message_url}") + + # 创建流式请求 + async with session.post( + url=message_url, + json={"message": question, "re_chat": False, "stream": True}, + headers={"Content-Type": "application/json"} + ) as message_request: + + if message_request.status != 200: + error_msg = f"外部API聊天消息调用失败: {message_request.status}, {await message_request.text()}" + logger.error(error_msg) + await self.send_error(error_msg) + return + + # 创建一个缓冲区以处理分段的数据 + buffer = "" + + # 读取并处理每个响应块 + logger.info("开始处理流式响应") + async for chunk in message_request.content.iter_any(): + chunk_str = chunk.decode('utf-8') + buffer += chunk_str + + # 检查是否有完整的数据行 + while '\n\n' in buffer: + parts = buffer.split('\n\n', 1) + line = parts[0] + buffer = parts[1] + + if line.startswith('data: '): + try: + # 提取JSON数据 + json_str = line[6:] # 去掉 "data: " 前缀 + data = json.loads(json_str) + + # 记录并处理部分响应 + if 'content' in data: + content_part = data['content'] + full_content += content_part + + # 发送部分内容 + await self.send_json({ + 'code': 200, + 'message': 'partial', + 'data': { + 'id': str(answer_record.id), + 'conversation_id': str(conversation_id), + 'content': content_part, + 'is_end': data.get('is_end', False) + } + }) + + # 处理结束标记 + if data.get('is_end', False): + logger.info("收到流式响应结束标记") + # 保存完整内容 + await self.update_answer_content(answer_record.id, full_content.strip()) + + # 处理标题 + title = await self.get_or_generate_title( + conversation_id, + question, + full_content.strip() + ) + + # 发送最终响应 + await self.send_json({ + 'code': 200, + 'message': '完成', + 'data': { + 'id': str(answer_record.id), + 'conversation_id': str(conversation_id), + 'title': title, + 'role': 'assistant', + 'content': full_content.strip(), + 'created_at': answer_record.created_at.strftime('%Y-%m-%d %H:%M:%S'), + 'is_end': True + } + }) + return + + except json.JSONDecodeError as e: + logger.error(f"JSON解析错误: {e}, 数据: {line}") + continue + + except Exception as e: + logger.error(f"流式处理出错: {str(e)}") + logger.error(traceback.format_exc()) + await self.send_error(str(e)) + + # 保存已收集的内容 + if 'full_content' in locals() and full_content: + try: + await self.update_answer_content(answer_record.id, full_content.strip()) + except Exception as save_error: + logger.error(f"保存部分内容失败: {str(save_error)}") + + @database_sync_to_async + def update_answer_content(self, answer_id, content): + """更新回答内容""" + try: + answer_record = ChatHistory.objects.get(id=answer_id) + answer_record.content = content + answer_record.save() + return True + except Exception as e: + logger.error(f"更新回答内容失败: {str(e)}") + return False + + @database_sync_to_async + def get_or_generate_title(self, conversation_id, question, answer): + """获取或生成对话标题""" + try: + # 先检查是否已有标题 + current_title = ChatHistory.objects.filter( + conversation_id=str(conversation_id) + ).exclude( + title__in=["New chat", "新对话", ""] + ).values_list('title', flat=True).first() + + if current_title: + return current_title + + # 简单的标题生成逻辑 (可替换为调用DeepSeek API生成标题) + generated_title = question[:20] + "..." if len(question) > 20 else question + + # 更新所有相关记录的标题 + ChatHistory.objects.filter( + conversation_id=str(conversation_id) + ).update(title=generated_title) + + return generated_title + + except Exception as e: + logger.error(f"获取或生成标题失败: {str(e)}") + return "新对话" + + async def send_json(self, content): + """发送JSON格式的消息""" + await self.send(text_data=json.dumps(content)) + + async def send_error(self, message): + """发送错误消息""" + await self.send_json({ + 'code': 500, + 'message': message, + 'data': {'is_end': True} + }) + + diff --git a/apps/chat/migrations/0001_initial.py b/apps/chat/migrations/0001_initial.py new file mode 100644 index 0000000..f10c5f0 --- /dev/null +++ b/apps/chat/migrations/0001_initial.py @@ -0,0 +1,38 @@ +# Generated by Django 5.2.1 on 2025-05-23 10:58 + +import django.db.models.deletion +from django.conf import settings +from django.db import migrations, models + + +class Migration(migrations.Migration): + + initial = True + + dependencies = [ + migrations.swappable_dependency(settings.AUTH_USER_MODEL), + ] + + operations = [ + migrations.CreateModel( + name='ChatHistory', + fields=[ + ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('knowledge_base_id', models.CharField(default='b680a4fa-37be-11f0-a7cb-0242ac120002', help_text='知识库ID,用于兼容原有结构', max_length=100)), + ('conversation_id', models.CharField(db_index=True, max_length=100)), + ('title', models.CharField(blank=True, default='New chat', help_text='对话标题', max_length=100, null=True)), + ('parent_id', models.CharField(blank=True, max_length=100, null=True)), + ('role', models.CharField(choices=[('user', '用户'), ('assistant', 'AI助手'), ('system', '系统')], max_length=20)), + ('content', models.TextField()), + ('tokens', models.IntegerField(default=0, help_text='消息token数')), + ('metadata', models.JSONField(blank=True, default=dict, help_text='存储额外信息')), + ('created_at', models.DateTimeField(auto_now_add=True)), + ('is_deleted', models.BooleanField(default=False)), + ('user', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to=settings.AUTH_USER_MODEL)), + ], + options={ + 'ordering': ['created_at'], + 'indexes': [models.Index(fields=['conversation_id', 'created_at'], name='chat_chathi_convers_90ca0c_idx'), models.Index(fields=['user', 'created_at'], name='chat_chathi_user_id_326d36_idx'), models.Index(fields=['conversation_id', 'is_deleted'], name='chat_chathi_convers_bcd094_idx')], + }, + ), + ] diff --git a/apps/chat/migrations/__init__.py b/apps/chat/migrations/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/apps/chat/models.py b/apps/chat/models.py new file mode 100644 index 0000000..6ee596e --- /dev/null +++ b/apps/chat/models.py @@ -0,0 +1,73 @@ +# apps/chat/models.py +from django.db import models +from django.utils import timezone +import uuid +from itertools import count +from apps.user.models import User + +class ChatHistory(models.Model): + """聊天历史记录""" + ROLE_CHOICES = [ + ('user', '用户'), + ('assistant', 'AI助手'), + ('system', '系统') + ] + + user = models.ForeignKey(User, on_delete=models.CASCADE) + # 改为使用字符串字段而非外键关联 + knowledge_base_id = models.CharField(max_length=100, default="b680a4fa-37be-11f0-a7cb-0242ac120002", help_text="知识库ID,用于兼容原有结构") + # 用于标识知识库组合的对话 + conversation_id = models.CharField(max_length=100, db_index=True) + # 对话标题 + title = models.CharField(max_length=100, null=True, blank=True, default='New chat', help_text="对话标题") + parent_id = models.CharField(max_length=100, null=True, blank=True) + role = models.CharField(max_length=20, choices=ROLE_CHOICES) + content = models.TextField() + tokens = models.IntegerField(default=0, help_text="消息token数") + # 扩展metadata字段 + metadata = models.JSONField(default=dict, blank=True, help_text="存储额外信息") + created_at = models.DateTimeField(auto_now_add=True) + is_deleted = models.BooleanField(default=False) + + class Meta: + ordering = ['created_at'] + indexes = [ + models.Index(fields=['conversation_id', 'created_at']), + models.Index(fields=['user', 'created_at']), + models.Index(fields=['conversation_id', 'is_deleted']), + ] + + def __str__(self): + return f"{self.user.email} - {self.title} - {self.created_at}" + + @classmethod + def get_conversation(cls, conversation_id): + """获取完整对话历史""" + return cls.objects.filter( + conversation_id=conversation_id, + is_deleted=False + ).order_by('created_at') + + @classmethod + def get_conversations_by_user(cls, user): + """根据用户获取对话历史""" + return cls.objects.filter( + user=user, + is_deleted=False + ).order_by('created_at') + + def soft_delete(self): + """软删除消息""" + self.is_deleted = True + self.save() + + def to_dict(self): + """转换为字典格式""" + return { + 'id': str(self.id), + 'conversation_id': self.conversation_id, + 'role': self.role, + 'content': self.content, + 'created_at': self.created_at.strftime('%Y-%m-%d %H:%M:%S'), + 'metadata': self.metadata + } diff --git a/apps/chat/routing.py b/apps/chat/routing.py new file mode 100644 index 0000000..0d84cfb --- /dev/null +++ b/apps/chat/routing.py @@ -0,0 +1,7 @@ +# apps/chat/routing.py +from django.urls import re_path +from apps.chat.consumers import ChatStreamConsumer + +websocket_urlpatterns = [ + re_path(r'ws/chat/stream/$', ChatStreamConsumer.as_asgi()), +] diff --git a/apps/chat/serializers.py b/apps/chat/serializers.py new file mode 100644 index 0000000..5464163 --- /dev/null +++ b/apps/chat/serializers.py @@ -0,0 +1,18 @@ +# apps/chat/serializers.py +from rest_framework import serializers +from apps.chat.models import ChatHistory + +class ChatHistorySerializer(serializers.ModelSerializer): + knowledge_base_id = serializers.UUIDField(source='knowledge_base.id', read_only=True) + dataset_name = serializers.CharField(source='knowledge_base.name', read_only=True) + user_id = serializers.UUIDField(source='user.id', read_only=True) + + class Meta: + model = ChatHistory + fields = [ + 'id', 'user_id', 'knowledge_base_id', 'dataset_name', 'conversation_id', + 'title', 'role', 'content', 'parent_id', 'metadata', 'created_at', 'is_deleted' + ] + read_only_fields = ['id', 'user_id', 'knowledge_base_id', 'dataset_name', 'created_at', 'is_deleted'] + + \ No newline at end of file diff --git a/apps/chat/services/__init__.py b/apps/chat/services/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/apps/chat/services/chat_api.py b/apps/chat/services/chat_api.py new file mode 100644 index 0000000..fc5037c --- /dev/null +++ b/apps/chat/services/chat_api.py @@ -0,0 +1,258 @@ +import requests +import json +import logging +from django.conf import settings +from rest_framework.exceptions import APIException +from daren import settings + +logger = logging.getLogger(__name__) + +class ExternalAPIError(APIException): + status_code = 500 + default_detail = '外部API调用失败' + default_code = 'external_api_error' + +def stream_chat_answer(conversation_id, question, dataset_external_ids, metadata): + """流式调用外部聊天API,返回生成器以实时传输回答""" + try: + # 构造聊天请求数据 + chat_request_data = { + "id": "d5d11efa-ea9a-11ef-9933-0242ac120006", + "model_id": "7a214d0e-e65e-11ef-9f4a-0242ac120006", + "dataset_id_list": [str(id) for id in dataset_external_ids], + "multiple_rounds_dialogue": False, + "dataset_setting": { + "top_n": 10, "similarity": "0.3", + "max_paragraph_char_number": 10000, + "search_mode": "blend", + "no_references_setting": { + "value": "{question}", + "status": "ai_questioning" + } + }, + "model_setting": { + "prompt": "**相关文档内容**:{data} **回答要求**:如果相关文档内容中没有可用信息,请回答\"没有在知识库中查找到相关信息,建议咨询相关技术支持或参考官方文档进行操作\"。请根据相关文档内容回答用户问题。不要输出与用户问题无关的内容。请使用中文回答客户问题。**用户问题**:{question}" + }, + "problem_optimization": False + } + + # 发起聊天请求 + logger.info(f"调用流式聊天API: {settings.API_BASE_URL}/api/application/chat/open") + chat_response = requests.post( + url=f"{settings.API_BASE_URL}/api/application/chat/open", + json=chat_request_data, + headers={"Content-Type": "application/json"} + ) + + logger.info(f"聊天API响应状态码: {chat_response.status_code}") + if chat_response.status_code != 200: + error_msg = f"聊天API调用失败: {chat_response.text}" + logger.error(error_msg) + yield f"data: {json.dumps({'code': 500, 'message': error_msg, 'data': {'is_end': True}})}\n\n" + return + + chat_data = chat_response.json() + if chat_data.get('code') != 200 or not chat_data.get('data'): + error_msg = f"聊天API返回错误: {chat_data}" + logger.error(error_msg) + yield f"data: {json.dumps({'code': 500, 'message': error_msg, 'data': {'is_end': True}})}\n\n" + return + + chat_id = chat_data['data'] + message_url = f"{settings.API_BASE_URL}/api/application/chat_message/{chat_id}" + logger.info(f"调用消息API: {message_url}") + + # 发起消息请求(流式) + message_request = requests.post( + url=message_url, + json={"message": question, "re_chat": False, "stream": True}, + headers={"Content-Type": "application/json"}, + stream=True + ) + + if message_request.status_code != 200: + error_msg = f"消息API调用失败: {message_request.status_code}, {message_request.text}" + logger.error(error_msg) + yield f"data: {json.dumps({'code': 500, 'message': error_msg, 'data': {'is_end': True}})}\n\n" + return + + buffer = "" + for chunk in message_request.iter_content(chunk_size=1): + if not chunk: + continue + + chunk_str = chunk.decode('utf-8') + buffer += chunk_str + + if '\n\n' in buffer: + lines = buffer.split('\n\n') + for line in lines[:-1]: + if line.startswith('data: '): + try: + json_str = line[6:] + data = json.loads(json_str) + if 'content' in data: + content_part = data['content'] + response_data = { + 'code': 200, + 'message': 'partial', + 'data': { + 'content': content_part, + 'is_end': data.get('is_end', False) + } + } + yield f"data: {json.dumps(response_data)}\n\n" + + if data.get('is_end', False): + return + except json.JSONDecodeError as e: + logger.error(f"JSON解析错误: {e}, 数据: {line}") + + buffer = lines[-1] + + if buffer and buffer.startswith('data: '): + try: + json_str = buffer[6:] + data = json.loads(json_str) + if 'content' in data: + content_part = data['content'] + response_data = { + 'code': 200, + 'message': 'partial', + 'data': { + 'content': content_part, + 'is_end': data.get('is_end', False) + } + } + yield f"data: {json.dumps(response_data)}\n\n" + except json.JSONDecodeError: + logger.error(f"处理剩余数据时JSON解析错误: {buffer}") + + except Exception as e: + logger.error(f"流式聊天API处理出错: {str(e)}") + yield f"data: {json.dumps({'code': 500, 'message': f'流式处理出错: {str(e)}', 'data': {'is_end': True}})}\n\n" + +def get_chat_answer(dataset_external_ids, question): + """非流式调用外部聊天API获取回答""" + try: + chat_request_data = { + "id": "d5d11efa-ea9a-11ef-9933-0242ac120006", + "model_id": "7a214d0e-e65e-11ef-9f4a-0242ac120006", + "dataset_id_list": [str(id) for id in dataset_external_ids], + "multiple_rounds_dialogue": False, + "dataset_setting": { + "top_n": 10, + "similarity": "0.3", + "max_paragraph_char_number": 10000, + "search_mode": "blend", + "no_references_setting": { + "value": "{question}", + "status": "ai_questioning" + } + }, + "model_setting": { + "prompt": "**相关文档内容**:{data} **回答要求**:如果相关文档内容中没有可用信息,请回答\"没有在知识库中查找到相关信息,建议咨询相关技术支持或参考官方文档进行操作\"。请根据相关文档内容回答用户问题。不要输出与用户问题无关的内容。请使用中文回答客户问题。**用户问题**:{question}" + }, + "problem_optimization": False + } + + logger.info(f"调用非流式聊天API: {settings.API_BASE_URL}/api/application/chat/open") + chat_response = requests.post( + url=f"{settings.API_BASE_URL}/api/application/chat/open", + json=chat_request_data, + headers={"Content-Type": "application/json"} + ) + + logger.info(f"聊天API响应状态码: {chat_response.status_code}") + if chat_response.status_code != 200: + logger.error(f"聊天API调用失败: {chat_response.text}") + raise ExternalAPIError(f"聊天API调用失败: {chat_response.text}") + + chat_data = chat_response.json() + if chat_data.get('code') != 200 or not chat_data.get('data'): + logger.error(f"聊天API返回错误: {chat_data}") + raise ExternalAPIError(f"聊天API返回错误: {chat_data}") + + chat_id = chat_data['data'] + message_url = f"{settings.API_BASE_URL}/api/application/chat_message/{chat_id}" + logger.info(f"调用消息API: {message_url}") + + message_response = requests.post( + url=message_url, + json={"message": question, "re_chat": False, "stream": False}, + headers={"Content-Type": "application/json"} + ) + + if message_response.status_code != 200: + logger.error(f"消息API调用失败: {message_response.status_code}, {message_response.text}") + raise ExternalAPIError(f"消息API调用失败: {message_response.status_code}, {message_response.text}") + + response_data = message_response.json() + if response_data.get('code') != 200 or 'data' not in response_data: + logger.error(f"消息API返回错误: {response_data}") + raise ExternalAPIError(f"消息API返回错误: {response_data}") + + answer_content = response_data.get('data', {}).get('content', '') + return answer_content if answer_content else "无法获取回答内容" + + except requests.exceptions.RequestException as e: + logger.error(f"聊天API网络错误: {str(e)}") + raise ExternalAPIError(f"聊天API网络错误: {str(e)}") + except json.JSONDecodeError as e: + logger.error(f"解析聊天API响应JSON失败: {str(e)}") + raise ExternalAPIError(f"解析响应数据失败: {str(e)}") + except Exception as e: + logger.error(f"调用聊天API失败: {str(e)}") + raise ExternalAPIError(f"调用聊天API失败: {str(e)}") + + +def generate_conversation_title_from_deepseek(user_question, assistant_answer): + """调用SiliconCloud API生成会话标题,直接基于当前问题和回答内容""" + try: + # 从Django设置中获取API密钥 + api_key = settings.SILICON_CLOUD_API_KEY + if not api_key: + return "新对话" + + # 构建提示信息 + prompt = f"请根据用户的问题和助手的回答,生成一个简短的对话标题(不超过20个字)。\n\n用户问题: {user_question}\n\n助手回答: {assistant_answer}" + + import requests + + url = "https://api.siliconflow.cn/v1/chat/completions" + + payload = { + "model": "deepseek-ai/DeepSeek-V3", + "stream": False, + "max_tokens": 512, + "temperature": 0.7, + "top_p": 0.7, + "top_k": 50, + "frequency_penalty": 0.5, + "n": 1, + "stop": [], + "messages": [ + { + "role": "user", + "content": prompt + } + ] + } + headers = { + "Authorization": f"Bearer {api_key}", + "Content-Type": "application/json" + } + + response = requests.post(url, json=payload, headers=headers) + response_data = response.json() + + if response.status_code == 200 and 'choices' in response_data and response_data['choices']: + title = response_data['choices'][0]['message']['content'].strip() + return title[:50] # 截断过长的标题 + else: + logger.error(f"生成标题时出错: {response.text}") + return "新对话" + + except Exception as e: + logger.exception(f"生成对话标题时发生错误: {str(e)}") + return "新对话" diff --git a/apps/chat/tests.py b/apps/chat/tests.py new file mode 100644 index 0000000..7ce503c --- /dev/null +++ b/apps/chat/tests.py @@ -0,0 +1,3 @@ +from django.test import TestCase + +# Create your tests here. diff --git a/apps/chat/urls.py b/apps/chat/urls.py new file mode 100644 index 0000000..9441658 --- /dev/null +++ b/apps/chat/urls.py @@ -0,0 +1,11 @@ +# apps/chat/urls.py +from django.urls import path, include +from rest_framework.routers import DefaultRouter +from apps.chat.views import ChatHistoryViewSet + +router = DefaultRouter() +router.register(r'', ChatHistoryViewSet, basename='chat-history') + +urlpatterns = [ + path('', include(router.urls)), +] diff --git a/apps/chat/views.py b/apps/chat/views.py new file mode 100644 index 0000000..32c4189 --- /dev/null +++ b/apps/chat/views.py @@ -0,0 +1,601 @@ +import logging +import json +import traceback +import uuid +from datetime import datetime +from django.db.models import Q, Max, Count +from django.http import HttpResponse, StreamingHttpResponse +from rest_framework import viewsets, status +from rest_framework.permissions import IsAuthenticated +from rest_framework.response import Response +from rest_framework.decorators import action +from apps.user.models import User +from apps.chat.models import ChatHistory +from apps.chat.serializers import ChatHistorySerializer +from apps.common.services.chat_service import ChatService +from apps.chat.services.chat_api import ( + ExternalAPIError, stream_chat_answer, get_chat_answer, + generate_conversation_title_from_deepseek +) + +logger = logging.getLogger(__name__) + +class ChatHistoryViewSet(viewsets.ModelViewSet): + permission_classes = [IsAuthenticated] + serializer_class = ChatHistorySerializer + queryset = ChatHistory.objects.all() + + # 固定知识库ID + DEFAULT_KNOWLEDGE_BASE_ID = "b680a4fa-37be-11f0-a7cb-0242ac120002" + + def get_queryset(self): + """只过滤用户自己的未删除的聊天记录""" + user = self.request.user + return ChatHistory.objects.filter( + user=user, + is_deleted=False + ) + + def list(self, request): + """获取对话列表概览""" + try: + page = int(request.query_params.get('page', 1)) + page_size = int(request.query_params.get('page_size', 10)) + + latest_chats = self.get_queryset().values( + 'conversation_id' + ).annotate( + latest_id=Max('id'), + message_count=Count('id'), + last_message=Max('created_at') + ).order_by('-last_message') + + total = latest_chats.count() + start = (page - 1) * page_size + end = start + page_size + chats = latest_chats[start:end] + + results = [] + for chat in chats: + latest_record = ChatHistory.objects.get(id=chat['latest_id']) + + results.append({ + 'conversation_id': chat['conversation_id'], + 'message_count': chat['message_count'], + 'last_message': latest_record.content, + 'last_time': chat['last_message'].strftime('%Y-%m-%d %H:%M:%S'), + }) + + return Response({ + 'code': 200, + 'message': '获取成功', + 'data': { + 'total': total, + 'page': page, + 'page_size': page_size, + 'results': results + } + }) + + except Exception as e: + logger.error(f"获取聊天记录失败: {str(e)}") + import traceback + logger.error(traceback.format_exc()) + return Response({ + 'code': 500, + 'message': f'获取聊天记录失败: {str(e)}', + 'data': None + }, status=status.HTTP_500_INTERNAL_SERVER_ERROR) + + @action(detail=False, methods=['get']) + def conversation_detail(self, request): + """获取特定对话的详细信息""" + try: + conversation_id = request.query_params.get('conversation_id') + if not conversation_id: + return Response({ + 'code': 400, + 'message': '缺少conversation_id参数', + 'data': None + }, status=status.HTTP_400_BAD_REQUEST) + + chat_service = ChatService() + result = chat_service.get_conversation_detail(request.user, conversation_id) + + return Response({ + 'code': 200, + 'message': '获取成功', + 'data': result + }) + + except ValueError as e: + return Response({ + 'code': 404, + 'message': str(e), + 'data': None + }, status=status.HTTP_404_NOT_FOUND) + except Exception as e: + logger.error(f"获取对话详情失败: {str(e)}") + import traceback + logger.error(traceback.format_exc()) + return Response({ + 'code': 500, + 'message': f'获取对话详情失败: {str(e)}', + 'data': None + }, status=status.HTTP_500_INTERNAL_SERVER_ERROR) + + @action(detail=False, methods=['get']) + def available_datasets(self, request): + """获取默认知识库""" + return Response({ + 'code': 200, + 'message': '获取成功', + 'data': [] + }) + + @action(detail=False, methods=['post']) + def create_conversation(self, request): + """创建会话 - 使用默认知识库ID""" + try: + # 创建一个新的会话ID + conversation_id = str(uuid.uuid4()) + logger.info(f"创建新的会话ID: {conversation_id}") + + return Response({ + 'code': 200, + 'message': '会话创建成功', + 'data': { + 'conversation_id': conversation_id + } + }) + + except Exception as e: + logger.error(f"创建会话失败: {str(e)}") + logger.error(traceback.format_exc()) + return Response({ + 'code': 500, + 'message': f'创建会话失败: {str(e)}', + 'data': None + }, status=status.HTTP_500_INTERNAL_SERVER_ERROR) + + def create(self, request): + """创建聊天记录 - 使用默认知识库""" + try: + # 验证请求数据 + if 'question' not in request.data: + return Response({ + 'code': 400, + 'message': '缺少必填字段: question', + 'data': None + }, status=status.HTTP_400_BAD_REQUEST) + + if 'conversation_id' not in request.data: + return Response({ + 'code': 400, + 'message': '缺少必填字段: conversation_id', + 'data': None + }, status=status.HTTP_400_BAD_REQUEST) + + conversation_id = request.data['conversation_id'] + question = request.data['question'] + title = request.data.get('title', 'New chat') + + # 准备metadata + metadata = {} + + # 创建用户问题记录 + question_record = ChatHistory.objects.create( + user=request.user, + knowledge_base_id=self.DEFAULT_KNOWLEDGE_BASE_ID, + conversation_id=conversation_id, + title=title, + role='user', + content=question, + metadata=metadata + ) + + # 设置外部API需要的ID列表 + external_id_list = [] + + use_stream = request.data.get('stream', True) + + if use_stream: + def stream_response(): + answer_record = ChatHistory.objects.create( + user=question_record.user, + knowledge_base_id=self.DEFAULT_KNOWLEDGE_BASE_ID, + conversation_id=conversation_id, + title=title, + parent_id=str(question_record.id), + role='assistant', + content="", + metadata=metadata + ) + + yield f"data: {json.dumps({'code': 200, 'message': '开始流式传输', 'data': {'id': str(answer_record.id), 'conversation_id': conversation_id, 'content': '', 'is_end': False}})}\n\n" + + full_content = "" + for data in stream_chat_answer(conversation_id, request.data['question'], external_id_list, metadata): + parsed_data = json.loads(data[5:-2]) # 移除"data: "和"\n\n" + if parsed_data['code'] == 200 and 'content' in parsed_data['data']: + content_part = parsed_data['data']['content'] + full_content += content_part + response_data = { + 'code': 200, + 'message': 'partial', + 'data': { + 'id': str(answer_record.id), + 'conversation_id': conversation_id, + 'title': title, + 'content': content_part, + 'is_end': parsed_data['data']['is_end'] + } + } + yield f"data: {json.dumps(response_data)}\n\n" + + if parsed_data['data']['is_end']: + answer_record.content = full_content.strip() + answer_record.save() + + current_title = ChatHistory.objects.filter( + conversation_id=conversation_id + ).exclude( + title__in=["New chat", "新对话", ""] + ).values_list('title', flat=True).first() + + if current_title: + title_updated = current_title + else: + try: + generated_title = generate_conversation_title_from_deepseek( + request.data['question'], full_content.strip() + ) + if generated_title: + ChatHistory.objects.filter( + conversation_id=conversation_id + ).update(title=generated_title) + title_updated = generated_title + else: + title_updated = "新对话" + except Exception as e: + logger.error(f"自动生成标题失败: {str(e)}") + title_updated = "新对话" + + final_response = { + 'code': 200, + 'message': '完成', + 'data': { + 'id': str(answer_record.id), + 'conversation_id': conversation_id, + 'title': title_updated, + 'role': 'assistant', + 'content': full_content.strip(), + 'created_at': answer_record.created_at.strftime('%Y-%m-%d %H:%M:%S'), + 'is_end': True + } + } + yield f"data: {json.dumps(final_response)}\n\n" + break + elif parsed_data['code'] != 200: + yield data + break + + if full_content: + try: + answer_record.content = full_content.strip() + answer_record.save() + except Exception as save_error: + logger.error(f"保存部分内容失败: {str(save_error)}") + + response = StreamingHttpResponse( + stream_response(), + content_type='text/event-stream', + status=status.HTTP_201_CREATED + ) + response['Cache-Control'] = 'no-cache, no-store' + response['Connection'] = 'keep-alive' + return response + else: + logger.info("使用非流式输出模式") + try: + answer = get_chat_answer(external_id_list, request.data['question']) + except ExternalAPIError as e: + logger.error(f"获取回答失败: {str(e)}") + return Response({ + 'code': 500, + 'message': f'获取回答失败: {str(e)}', + 'data': None + }, status=status.HTTP_500_INTERNAL_SERVER_ERROR) + + if answer is None: + return Response({ + 'code': 500, + 'message': '获取回答失败', + 'data': None + }, status=status.HTTP_500_INTERNAL_SERVER_ERROR) + + answer_record = ChatHistory.objects.create( + user=request.user, + knowledge_base_id=self.DEFAULT_KNOWLEDGE_BASE_ID, + conversation_id=conversation_id, + title=title, + parent_id=str(question_record.id), + role='assistant', + content=answer, + metadata=metadata + ) + + existing_records = ChatHistory.objects.filter(conversation_id=conversation_id) + should_generate_title = not existing_records.exclude(id=question_record.id).exists() and (not title or title == 'New chat') + if should_generate_title: + try: + generated_title = generate_conversation_title_from_deepseek( + request.data['question'], answer + ) + if generated_title: + ChatHistory.objects.filter(conversation_id=conversation_id).update(title=generated_title) + title = generated_title + except Exception as e: + logger.error(f"自动生成标题失败: {str(e)}") + + return Response({ + 'code': 200, + 'message': '成功', + 'data': { + 'id': str(answer_record.id), + 'conversation_id': conversation_id, + 'title': title, + 'role': 'assistant', + 'content': answer, + 'created_at': answer_record.created_at.strftime('%Y-%m-%d %H:%M:%S') + } + }, status=status.HTTP_201_CREATED) + + except Exception as e: + logger.error(f"创建聊天记录失败: {str(e)}") + import traceback + logger.error(traceback.format_exc()) + return Response({ + 'code': 500, + 'message': f'创建聊天记录失败: {str(e)}', + 'data': None + }, status=status.HTTP_500_INTERNAL_SERVER_ERROR) + + def _highlight_keyword(self, text, keyword): + """高亮关键词""" + if not keyword or not text: + return text + return text.replace(keyword, f'{keyword}') + + def update(self, request, pk=None): + """更新聊天记录""" + try: + record = self.get_queryset().filter(id=pk).first() + if not record: + return Response({ + 'code': 404, + 'message': '记录不存在或无权限', + 'data': None + }, status=status.HTTP_404_NOT_FOUND) + + data = request.data + updateable_fields = ['content', 'metadata'] + if 'content' in data: + record.content = data['content'] + if 'metadata' in data: + current_metadata = record.metadata or {} + current_metadata.update(data['metadata']) + record.metadata = current_metadata + + record.save() + return Response({ + 'code': 200, + 'message': '更新成功', + 'data': { + 'id': str(record.id), + 'conversation_id': record.conversation_id, + 'role': record.role, + 'content': record.content, + 'metadata': record.metadata, + 'updated_at': datetime.now().strftime('%Y-%m-%d %H:%M:%S') + } + }) + + except Exception as e: + logger.error(f"更新聊天记录失败: {str(e)}") + import traceback + logger.error(traceback.format_exc()) + return Response({ + 'code': 500, + 'message': f'更新聊天记录失败: {str(e)}', + 'data': None + }, status=status.HTTP_500_INTERNAL_SERVER_ERROR) + + def destroy(self, request, pk=None): + """删除聊天记录(软删除)""" + try: + record = self.get_queryset().filter(id=pk).first() + if not record: + return Response({ + 'code': 404, + 'message': '记录不存在或无权限', + 'data': None + }, status=status.HTTP_404_NOT_FOUND) + + record.soft_delete() + return Response({ + 'code': 200, + 'message': '删除成功', + 'data': None + }) + + except Exception as e: + logger.error(f"删除聊天记录失败: {str(e)}") + import traceback + logger.error(traceback.format_exc()) + return Response({ + 'code': 500, + 'message': f'删除聊天记录失败: {str(e)}', + 'data': None + }, status=status.HTTP_500_INTERNAL_SERVER_ERROR) + + @action(detail=False, methods=['get']) + def search(self, request): + """搜索聊天记录""" + try: + keyword = request.query_params.get('keyword', '').strip() + start_date = request.query_params.get('start_date') + end_date = request.query_params.get('end_date') + page = int(request.query_params.get('page', 1)) + page_size = int(request.query_params.get('page_size', 10)) + + query = self.get_queryset() + if keyword: + query = query.filter( + Q(content__icontains=keyword) + ) + if start_date: + query = query.filter(created_at__gte=start_date) + if end_date: + query = query.filter(created_at__lte=end_date) + + total = query.count() + start = (page - 1) * page_size + end = start + page_size + records = query.order_by('-created_at')[start:end] + + results = [ + { + 'id': str(record.id), + 'conversation_id': record.conversation_id, + 'role': record.role, + 'content': record.content, + 'created_at': record.created_at.strftime('%Y-%m-%d %H:%M:%S'), + 'metadata': record.metadata, + 'highlights': {'content': self._highlight_keyword(record.content, keyword)} if keyword else {} + } + for record in records + ] + + return Response({ + 'code': 200, + 'message': '搜索成功', + 'data': { + 'total': total, + 'page': page, + 'page_size': page_size, + 'results': results + } + }) + + except Exception as e: + logger.error(f"搜索聊天记录失败: {str(e)}") + import traceback + logger.error(traceback.format_exc()) + return Response({ + 'code': 500, + 'message': f'搜索失败: {str(e)}', + 'data': None + }, status=status.HTTP_500_INTERNAL_SERVER_ERROR) + + @action(detail=False, methods=['delete']) + def delete_conversation(self, request): + """通过conversation_id删除一组会话""" + try: + conversation_id = request.query_params.get('conversation_id') + if not conversation_id: + return Response({ + 'code': 400, + 'message': '缺少必要参数: conversation_id', + 'data': None + }, status=status.HTTP_400_BAD_REQUEST) + + records = self.get_queryset().filter(conversation_id=conversation_id) + if not records.exists(): + return Response({ + 'code': 404, + 'message': '未找到该会话或无权限访问', + 'data': None + }, status=status.HTTP_404_NOT_FOUND) + + records_count = records.count() + for record in records: + record.soft_delete() + + return Response({ + 'code': 200, + 'message': '删除成功', + 'data': { + 'conversation_id': conversation_id, + 'deleted_count': records_count + } + }) + + except Exception as e: + logger.error(f"删除会话失败: {str(e)}") + import traceback + logger.error(traceback.format_exc()) + return Response({ + 'code': 500, + 'message': f'删除会话失败: {str(e)}', + 'data': None + }, status=status.HTTP_500_INTERNAL_SERVER_ERROR) + + @action(detail=False, methods=['get'], url_path='generate-conversation-title') + def generate_conversation_title(self, request): + """更新会话标题""" + try: + conversation_id = request.query_params.get('conversation_id') + if not conversation_id: + return Response({ + 'code': 400, + 'message': '缺少conversation_id参数', + 'data': None + }, status=status.HTTP_400_BAD_REQUEST) + + # 检查对话是否存在 + messages = self.get_queryset().filter( + conversation_id=conversation_id, + is_deleted=False, + user=request.user + ).order_by('created_at') + + if not messages.exists(): + return Response({ + 'code': 404, + 'message': '对话不存在或无权访问', + 'data': None + }, status=status.HTTP_404_NOT_FOUND) + + # 检查是否有自定义标题参数 + custom_title = request.query_params.get('title') + if not custom_title: + return Response({ + 'code': 400, + 'message': '缺少title参数', + 'data': None + }, status=status.HTTP_400_BAD_REQUEST) + + # 更新所有相关记录的标题 + ChatHistory.objects.filter( + conversation_id=conversation_id, + user=request.user + ).update(title=custom_title) + + return Response({ + 'code': 200, + 'message': '更新会话标题成功', + 'data': { + 'conversation_id': conversation_id, + 'title': custom_title + } + }) + + except Exception as e: + logger.error(f"更新会话标题失败: {str(e)}") + logger.error(traceback.format_exc()) + return Response({ + 'code': 500, + 'message': f"更新会话标题失败: {str(e)}", + 'data': None + }, status=status.HTTP_500_INTERNAL_SERVER_ERROR) diff --git a/apps/common/__init__.py b/apps/common/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/apps/common/admin.py b/apps/common/admin.py new file mode 100644 index 0000000..8c38f3f --- /dev/null +++ b/apps/common/admin.py @@ -0,0 +1,3 @@ +from django.contrib import admin + +# Register your models here. diff --git a/apps/common/apps.py b/apps/common/apps.py new file mode 100644 index 0000000..f992f88 --- /dev/null +++ b/apps/common/apps.py @@ -0,0 +1,6 @@ +from django.apps import AppConfig + + +class CommonConfig(AppConfig): + default_auto_field = 'django.db.models.BigAutoField' + name = 'apps.common' diff --git a/apps/common/middlewares.py b/apps/common/middlewares.py new file mode 100644 index 0000000..3740723 --- /dev/null +++ b/apps/common/middlewares.py @@ -0,0 +1,46 @@ +from django.db import close_old_connections +from rest_framework.authtoken.models import Token +from channels.middleware import BaseMiddleware +from channels.db import database_sync_to_async +from urllib.parse import parse_qs +import logging + +logger = logging.getLogger(__name__) + +@database_sync_to_async +def get_user_from_token(token_key): + try: + token = Token.objects.select_related('user').get(key=token_key) + return token.user + except Token.DoesNotExist: + return None + except Exception as e: + logger.error(f"获取用户Token失败: {str(e)}") + return None + +class TokenAuthMiddleware(BaseMiddleware): + async def __call__(self, scope, receive, send): + # 关闭之前的数据库连接 + close_old_connections() + + # 从查询字符串中提取token + query_string = scope.get('query_string', b'').decode() + query_params = parse_qs(query_string) + token_key = query_params.get('token', [''])[0] + + if token_key: + user = await get_user_from_token(token_key) + if user: + scope['user'] = user + logger.info(f"WebSocket认证成功: 用户 {user.id}") + else: + logger.warning(f"WebSocket认证失败: 无效的Token {token_key}") + scope['user'] = None + else: + logger.warning("WebSocket连接未提供Token") + scope['user'] = None + + return await super().__call__(scope, receive, send) + +def TokenAuthMiddlewareStack(inner): + return TokenAuthMiddleware(inner) \ No newline at end of file diff --git a/apps/common/migrations/__init__.py b/apps/common/migrations/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/apps/common/models.py b/apps/common/models.py new file mode 100644 index 0000000..71a8362 --- /dev/null +++ b/apps/common/models.py @@ -0,0 +1,3 @@ +from django.db import models + +# Create your models here. diff --git a/apps/common/services/__init__.py b/apps/common/services/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/apps/common/services/ai_service.py b/apps/common/services/ai_service.py new file mode 100644 index 0000000..452eb7e --- /dev/null +++ b/apps/common/services/ai_service.py @@ -0,0 +1,202 @@ +import requests +import logging +import json +from django.conf import settings +from datetime import datetime + +logger = logging.getLogger(__name__) + +class AIService: + """ + 通用AI服务类,提供对不同AI模型的统一调用接口 + """ + + @staticmethod + def call_silicon_cloud_api(messages, model="deepseek-ai/DeepSeek-V3", max_tokens=512, temperature=0.7): + """ + 调用SiliconCloud API + + Args: + messages: 消息列表,格式为[{"role": "user", "content": "..."}, ...] + model: 使用的模型名称,默认为DeepSeek-V3 + max_tokens: 最大生成token数 + temperature: 温度参数,控制创造性 + + Returns: + tuple: (生成内容, 错误信息) + """ + try: + # 获取API密钥 + api_key = getattr(settings, 'SILICON_CLOUD_API_KEY', '') + if not api_key: + return None, "未配置Silicon Cloud API密钥" + + url = "https://api.siliconflow.cn/v1/chat/completions" + + payload = { + "model": model, + "stream": False, + "max_tokens": max_tokens, + "temperature": temperature, + "top_p": 0.7, + "top_k": 50, + "frequency_penalty": 0.5, + "n": 1, + "stop": [], + "messages": messages + } + + headers = { + "Authorization": f"Bearer {api_key}", + "Content-Type": "application/json" + } + + # 设置代理(如果配置了) + proxies = None + proxy_url = getattr(settings, 'PROXY_URL', None) + if proxy_url: + proxies = { + "http": proxy_url, + "https": proxy_url + } + + # 发送请求 + logger.info(f"正在调用SiliconCloud API,模型: {model}") + response = requests.post(url, json=payload, headers=headers, proxies=proxies) + + if response.status_code != 200: + logger.error(f"API请求失败,状态码: {response.status_code},响应: {response.text}") + return None, f"API请求失败,状态码: {response.status_code}" + + result = response.json() + + # 从API响应中提取内容 + if 'choices' in result and result['choices']: + content = result['choices'][0]['message']['content'].strip() + return content, None + else: + logger.error(f"API响应格式错误: {result}") + return None, "API响应格式错误,无法提取内容" + + except requests.exceptions.RequestException as e: + logger.error(f"API请求异常: {str(e)}") + return None, f"API请求异常: {str(e)}" + except Exception as e: + logger.error(f"调用AI服务失败: {str(e)}") + return None, f"调用AI服务失败: {str(e)}" + + @staticmethod + def generate_email_reply(goal_description, conversation_summary, last_message): + """ + 生成邮件回复话术 + + Args: + goal_description: 用户目标描述 + conversation_summary: 对话摘要 + last_message: 达人最后发送的消息内容 + + Returns: + tuple: (推荐话术内容, 错误信息) + """ + try: + # 验证必要参数 + if not goal_description or not last_message: + return None, "缺少必要参数:目标描述或最后消息" + + # 准备提示信息 + prompt = f""" +请为用户提供一条回复达人邮件的推荐话术,帮助用户实现销售目标。 + +用户目标:{goal_description} + +对话摘要:{conversation_summary or '无对话摘要'} + +达人最后发送的消息: +{last_message} + +要求: +1. 回复应当专业礼貌,并围绕用户目标展开 +2. 提供有说服力的话术,推动促成合作 +3. 不超过200字 +4. 直接给出回复内容,不要包含任何额外解释 +""" + + messages = [ + { + "role": "system", + "content": "你是一名专业的邮件回复助手,你的任务是生成有效的销售话术,帮助用户实现销售目标。" + }, + { + "role": "user", + "content": prompt + } + ] + + # 调用通用API方法 + return AIService.call_silicon_cloud_api( + messages, + model="Pro/deepseek-ai/DeepSeek-R1", + max_tokens=512, + temperature=0.7 + ) + + except Exception as e: + logger.error(f"生成回复话术失败: {str(e)}") + return None, f"生成回复话术失败: {str(e)}" + + @staticmethod + def generate_conversation_summary(conversation_history): + """ + 生成对话摘要 + + Args: + conversation_history: 对话历史记录列表 + + Returns: + tuple: (摘要内容, 错误信息) + """ + try: + if not conversation_history: + return None, "无对话历史,无法生成摘要" + + # 构造对话历史文本 + conversation_text = "\n\n".join([ + f"**{'用户' if msg.get('is_from_user', False) else '达人'}**: {msg.get('content', '')}" + for msg in conversation_history + ]) + + # 准备提示信息 + prompt = f""" +请对以下用户与达人之间的对话内容进行总结: + +{conversation_text} + +要求: +1. 总结应包含双方主要讨论的话题和关键点 +2. 特别关注产品详情、价格谈判、合作意向等商务要点 +3. 简明扼要,不超过200字 +4. 直接给出总结内容,不要包含任何额外解释 +""" + + messages = [ + { + "role": "system", + "content": "你是一名专业的对话总结助手,擅长提取商务沟通中的关键信息。" + }, + { + "role": "user", + "content": prompt + } + ] + + # 调用通用API方法 + return AIService.call_silicon_cloud_api( + messages, + model="Pro/deepseek-ai/DeepSeek-R1", + max_tokens=512, + temperature=0.5 + ) + + except Exception as e: + logger.error(f"生成对话摘要失败: {str(e)}") + return None, f"生成对话摘要失败: {str(e)}" \ No newline at end of file diff --git a/apps/common/services/chat_service.py b/apps/common/services/chat_service.py new file mode 100644 index 0000000..77ae642 --- /dev/null +++ b/apps/common/services/chat_service.py @@ -0,0 +1,118 @@ +# apps/common/services/chat_service.py +import logging +import json +from uuid import uuid4 +from django.db import transaction +from apps.user.models import User +from apps.chat.models import ChatHistory + +logger = logging.getLogger(__name__) + +class ChatService: + @transaction.atomic + def create_chat_record(self, user, data, conversation_id=None): + """创建聊天记录,供chat、gmail、feishu模块复用""" + try: + # 验证必填字段 + if 'question' not in data: + raise ValueError("缺少必填字段: question") + + # 如果未提供conversation_id,生成新的 + if not conversation_id: + conversation_id = str(uuid4()) + logger.info(f"生成新的会话ID: {conversation_id}") + + # 创建metadata + metadata = { + 'model_id': data.get('model_id', '7a214d0e-e65e-11ef-9f4a-0242ac120006'), + } + + # 设置标题 + title = data.get('title', 'New chat') + + # 创建用户问题记录 + question_record = ChatHistory.objects.create( + user=user, + knowledge_base_id="b680a4fa-37be-11f0-a7cb-0242ac120002", # 使用默认知识库ID + conversation_id=conversation_id, + title=title, + role='user', + content=data['question'], + metadata=metadata + ) + + return question_record, conversation_id, metadata, [], [] + + except Exception as e: + logger.error(f"创建聊天记录失败: {str(e)}") + raise + + def get_conversation_detail(self, user, conversation_id): + """获取会话详情,供chat、gmail、feishu模块复用""" + try: + # 查询会话记录 + messages = ChatHistory.objects.filter( + conversation_id=conversation_id, + user=user, + is_deleted=False + ).order_by('created_at') + + if not messages.exists(): + raise ValueError("对话不存在或无权限") + + # 构建消息列表 + message_list = [ + { + 'id': str(msg.id), + 'parent_id': msg.parent_id, + 'role': msg.role, + 'content': msg.content, + 'created_at': msg.created_at.strftime('%Y-%m-%d %H:%M:%S'), + 'metadata': msg.metadata + } + for msg in messages + ] + + return { + 'conversation_id': conversation_id, + 'messages': message_list + } + + except Exception as e: + logger.error(f"获取会话详情失败: {str(e)}") + raise + + def can_user_access_conversation(self, user, conversation_id): + """检查用户是否有权限访问指定的对话""" + return ChatHistory.objects.filter( + conversation_id=conversation_id, + user=user, + is_deleted=False + ).exists() + + def format_chat_response(self, chat_history_list): + """格式化聊天历史记录为前端需要的格式""" + try: + result = [] + + for item in chat_history_list: + formatted_item = { + "id": str(item.id), + "conversation_id": str(item.conversation_id), + "role": item.role, + "content": item.content, + "created_at": item.created_at.strftime('%Y-%m-%d %H:%M:%S'), + "title": item.title + } + + # 添加parent_id如果存在 + if item.parent_id: + formatted_item["parent_id"] = str(item.parent_id) + + result.append(formatted_item) + + return result + except Exception as e: + logger.error(f"格式化聊天响应失败: {e}") + return [] + \ No newline at end of file diff --git a/apps/common/services/external_api_service.py b/apps/common/services/external_api_service.py new file mode 100644 index 0000000..e816981 --- /dev/null +++ b/apps/common/services/external_api_service.py @@ -0,0 +1,320 @@ +# apps/common/services/external_api_service.py +import traceback +import requests +import json +import logging +from django.conf import settings +from rest_framework.exceptions import APIException + +logger = logging.getLogger(__name__) + +class ExternalAPIError(APIException): + status_code = 500 + default_detail = '外部API调用失败' + default_code = 'external_api_error' + +def create_external_dataset(knowledge_base): + """创建外部知识库""" + try: + api_data = { + "name": knowledge_base.name, + "desc": knowledge_base.desc, + "type": "0", + "meta": {}, + "documents": [] + } + + response = requests.post( + f'{settings.API_BASE_URL}/api/dataset', + json=api_data, + headers={'Content-Type': 'application/json'}, + ) + + if response.status_code != 200: + raise ExternalAPIError(f"创建失败,状态码: {response.status_code}, 响应: {response.text}") + + api_response = response.json() + if not api_response.get('code') == 200: + raise ExternalAPIError(f"业务处理失败: {api_response.get('message', '未知错误')}") + + dataset_id = api_response.get('data', {}).get('id') + if not dataset_id: + raise ExternalAPIError("响应数据中缺少dataset id") + + return dataset_id + + except requests.exceptions.Timeout: + raise ExternalAPIError("请求超时,请稍后重试") + except requests.exceptions.RequestException as e: + raise ExternalAPIError(f"API请求失败: {str(e)}") + except Exception as e: + raise ExternalAPIError(f"创建外部知识库失败: {str(e)}") + +def delete_external_dataset(external_id): + """删除外部知识库""" + try: + if not external_id: + logger.warning("外部知识库ID为空,跳过删除") + return True + + response = requests.delete( + f'{settings.API_BASE_URL}/api/dataset/{external_id}', + headers={'Content-Type': 'application/json'}, + ) + + logger.info(f"删除外部知识库响应: status_code={response.status_code}, response={response.text}") + + if response.status_code == 404: + logger.warning(f"外部知识库不存在: {external_id}") + return True + elif response.status_code not in [200, 204]: + return True # 允许本地删除继续 + + if response.status_code == 204: + logger.info(f"外部知识库删除成功: {external_id}") + return True + + try: + api_response = response.json() + if api_response.get('code') != 200: + if "不存在" in api_response.get('message', ''): + logger.warning(f"外部知识库ID不存在,视为删除成功: {external_id}") + return True + logger.warning(f"业务处理返回非200状态码: {api_response.get('code')}, {api_response.get('message')}") + return True + logger.info(f"外部知识库删除成功: {external_id}") + return True + except ValueError: + logger.warning(f"外部知识库删除响应无法解析JSON,但状态码为200,视为成功: {external_id}") + return True + + except requests.exceptions.Timeout: + logger.error(f"删除外部知识库超时: {external_id}") + return False + except requests.exceptions.RequestException as e: + logger.error(f"删除外部知识库请求异常: {external_id}, error={str(e)}") + return False + except Exception as e: + logger.error(f"删除外部知识库其他错误: {external_id}, error={str(e)}") + return False + +def call_split_api_multiple(files): + """调用文档分割API,支持多文件批量处理""" + try: + url = f'{settings.API_BASE_URL}/api/dataset/document/split' + + # 准备请求数据 - 将所有文件作为 'file' 字段 + files_data = [('file', (file.name, file, file.content_type)) for file in files] + + # 记录上传的文件信息 + for file in files: + logger.info(f"准备上传文件: {file.name}, 大小: {file.size}字节, 类型: {file.content_type}") + # 读取文件内容前100个字符进行记录 + if hasattr(file, 'read') and hasattr(file, 'seek'): + file.seek(0) + content_preview = file.read(100).decode('utf-8', errors='ignore') + logger.info(f"文件内容预览: {content_preview}") + file.seek(0) # 重置文件指针 + + logger.info(f"调用分割API URL: {url}") + logger.info(f"上传文件数量: {len(files_data)}") + + # 发送请求 + response = requests.post( + url, + files=files_data + ) + + # 记录请求头和响应信息 + logger.info(f"请求头: {response.request.headers}") + logger.info(f"响应状态码: {response.status_code}") + + if response.status_code != 200: + logger.error(f"分割API返回错误状态码: {response.status_code}, 响应: {response.text}") + return None + + # 解析响应 + result = response.json() + logger.info(f"分割API响应详情: {result}") + + # 如果数据为空,可能是API处理失败,尝试后备方案 + if len(result.get('data', [])) == 0: + logger.warning("分割API返回的数据为空,尝试使用后备方案") + fallback_data = { + 'code': 200, + 'message': '成功', + 'data': [ + { + 'name': file.name, + 'content': [ + { + 'title': '文档内容', + 'content': '文件内容无法自动分割,请检查外部API。这是一个后备内容。' + } + ] + } for file in files + ] + } + logger.info("使用后备数据结构") + return fallback_data + + return result + + except Exception as e: + logger.error(f"调用分割API失败: {str(e)}") + logger.error(traceback.format_exc()) + + # 创建后备响应 + fallback_response = { + 'code': 200, + 'message': '成功', + 'data': [ + { + 'name': file.name, + 'content': [ + { + 'title': '文档内容', + 'content': '文件内容无法自动分割,请检查API连接。' + } + ] + } for file in files + ] + } + logger.info("由于异常,返回后备响应") + return fallback_response + +def call_upload_api(external_id, doc_data): + """调用文档上传API""" + try: + url = f'{settings.API_BASE_URL}/api/dataset/{external_id}/document' + logger.info(f"调用文档上传API: {url}") + logger.info(f"上传文档数据: 文档名={doc_data.get('name')}, 段落数={len(doc_data.get('paragraphs', []))}") + + response = requests.post(url, json=doc_data) + + logger.info(f"上传API响应状态码: {response.status_code}") + + if response.status_code != 200: + logger.error(f"上传API HTTP错误: {response.status_code}, 响应: {response.text}") + return { + 'code': response.status_code, + 'message': f"上传失败,HTTP状态码: {response.status_code}", + 'data': None + } + + result = response.json() + logger.info(f"上传API响应内容: {result}") + + if result.get('code') != 200: + error_msg = result.get('message', '未知错误') + logger.error(f"上传API业务错误: {error_msg}") + return { + 'code': result.get('code', 500), + 'message': error_msg, + 'data': None + } + + return result + + except requests.exceptions.RequestException as e: + logger.error(f"调用上传API网络错误: {str(e)}") + return { + 'code': 500, + 'message': f"网络请求错误: {str(e)}", + 'data': None + } + except json.JSONDecodeError as e: + logger.error(f"解析API响应JSON失败: {str(e)}") + return { + 'code': 500, + 'message': f"解析响应数据失败: {str(e)}", + 'data': None + } + except Exception as e: + logger.error(f"调用上传API其他错误: {str(e)}") + return { + 'code': 500, + 'message': f"上传API调用失败: {str(e)}", + 'data': None + } + +def call_delete_document_api(external_id, document_id): + """调用文档删除API""" + try: + url = f'{settings.API_BASE_URL}/api/dataset/{external_id}/document/{document_id}' + response = requests.delete(url) + return response.json() + except Exception as e: + logger.error(f"调用删除API失败: {str(e)}") + return None + +def get_external_document_list(external_id): + """获取外部知识库的文档列表""" + try: + url = f'{settings.API_BASE_URL}/api/dataset/{external_id}/document' + logger.info(f"调用获取文档列表API: {url}") + + response = requests.get( + url, + headers={'Content-Type': 'application/json'}, + ) + + logger.info(f"文档列表API响应状态码: {response.status_code}") + + if response.status_code != 200: + logger.error(f"获取文档列表失败: {response.status_code}, 响应: {response.text}") + raise ExternalAPIError(f"获取文档列表失败,状态码: {response.status_code}") + + result = response.json() + logger.info(f"文档列表API响应内容: {result}") + + if result.get('code') != 200: + logger.error(f"获取文档列表业务错误: {result.get('message', '未知错误')}") + raise ExternalAPIError(f"获取文档列表失败: {result.get('message', '未知错误')}") + + return result.get('data', []) + + except requests.exceptions.RequestException as e: + logger.error(f"获取文档列表网络错误: {str(e)}") + raise ExternalAPIError(f"获取文档列表失败: {str(e)}") + except json.JSONDecodeError as e: + logger.error(f"解析文档列表响应JSON失败: {str(e)}") + raise ExternalAPIError(f"解析响应数据失败: {str(e)}") + except Exception as e: + logger.error(f"获取文档列表其他错误: {str(e)}") + raise ExternalAPIError(f"获取文档列表失败: {str(e)}") + +def get_external_document_paragraphs(external_id, document_external_id): + """获取外部文档的段落内容""" + try: + url = f'{settings.API_BASE_URL}/api/dataset/{external_id}/document/{document_external_id}/paragraph' + logger.info(f"调用获取文档段落API: {url}") + + response = requests.get(url) + + logger.info(f"文档段落API响应状态码: {response.status_code}") + + if response.status_code != 200: + logger.error(f"获取文档段落内容失败: {response.status_code}, 响应: {response.text}") + raise ExternalAPIError(f"获取文档段落内容失败,状态码: {response.status_code}") + + result = response.json() + logger.info(f"文档段落API响应内容: {result}") + + if result.get('code') != 200: + logger.error(f"获取文档段落内容业务错误: {result.get('message', '未知错误')}") + raise ExternalAPIError(f"获取文档段落内容失败: {result.get('message', '未知错误')}") + + return result.get('data', []) + + except requests.exceptions.RequestException as e: + logger.error(f"获取文档段落内容网络错误: {str(e)}") + raise ExternalAPIError(f"获取文档段落内容失败: {str(e)}") + except json.JSONDecodeError as e: + logger.error(f"解析文档段落响应JSON失败: {str(e)}") + raise ExternalAPIError(f"解析响应数据失败: {str(e)}") + except Exception as e: + logger.error(f"获取文档段落内容其他错误: {str(e)}") + raise ExternalAPIError(f"获取文档段落内容失败: {str(e)}") + + diff --git a/apps/common/tests.py b/apps/common/tests.py new file mode 100644 index 0000000..7ce503c --- /dev/null +++ b/apps/common/tests.py @@ -0,0 +1,3 @@ +from django.test import TestCase + +# Create your tests here. diff --git a/apps/common/urls.py b/apps/common/urls.py new file mode 100644 index 0000000..e69de29 diff --git a/apps/common/views.py b/apps/common/views.py new file mode 100644 index 0000000..91ea44a --- /dev/null +++ b/apps/common/views.py @@ -0,0 +1,3 @@ +from django.shortcuts import render + +# Create your views here. diff --git a/apps/discovery/views.py b/apps/discovery/views.py index 38b1b56..ccddad6 100644 --- a/apps/discovery/views.py +++ b/apps/discovery/views.py @@ -501,6 +501,7 @@ class CreatorDiscoveryViewSet(viewsets.ReadOnlyModelViewSet): import logging import json from apps.daren_detail.models import CreatorProfile + from apps.user.models import User logger = logging.getLogger(__name__) @@ -562,38 +563,46 @@ class CreatorDiscoveryViewSet(viewsets.ReadOnlyModelViewSet): matched_creator_ids = [] for creator_data in api_results: - # 根据API结果获取或创建CreatorProfile try: - # 尝试查找CreatorProfile,使用filter和first而非get避免多个结果的问题 - profile_obj = CreatorProfile.objects.filter( - name=creator_data.get('name', 'Unknown') - ).first() + # 安全获取创作者名称,确保有一个默认值 + creator_name = creator_data.get('name', 'Unknown Creator') + + # 按名称找到第一个匹配的CreatorProfile或创建新的 + profile_obj = None + try: + profile_obj = CreatorProfile.objects.filter(name=creator_name).first() + except Exception as e: + logger.error(f"查询CreatorProfile时出错: {str(e)}") - # 如果没有找到,则创建一个新的 if not profile_obj: - profile_obj = CreatorProfile.objects.create( - name=creator_data.get('name', 'Unknown'), - avatar_url=creator_data.get('avatar_url'), - email=creator_data.get('email'), - instagram=creator_data.get('instagram'), - tiktok_link=creator_data.get('tiktok_link'), - location=creator_data.get('location'), - live_schedule=creator_data.get('live_schedule'), - category=creator_data.get('category'), - e_commerce_level=creator_data.get('e_commerce_level'), - exposure_level=creator_data.get('exposure_level'), - followers=creator_data.get('followers', 0), - gmv=creator_data.get('gmv', 0), - items_sold=creator_data.get('items_sold', 0), - avg_video_views=creator_data.get('avg_video_views', 0), - pricing=creator_data.get('pricing'), - pricing_package=creator_data.get('pricing_package'), - collab_count=creator_data.get('collab_count'), - latest_collab=creator_data.get('latest_collab'), - profile=creator_data.get('profile'), - hashtags=creator_data.get('hashtags', ''), - trends=creator_data.get('trends', '') - ) + # 如果没有找到,创建一个新的CreatorProfile + try: + profile_obj = CreatorProfile.objects.create( + name=creator_name, + avatar_url=creator_data.get('avatar_url'), + email=creator_data.get('email'), + instagram=creator_data.get('instagram'), + tiktok_link=creator_data.get('tiktok_link'), + location=creator_data.get('location'), + live_schedule=creator_data.get('live_schedule'), + category=creator_data.get('category'), + e_commerce_level=creator_data.get('e_commerce_level'), + exposure_level=creator_data.get('exposure_level'), + followers=creator_data.get('followers', 0), + gmv=creator_data.get('gmv', 0), + items_sold=creator_data.get('items_sold', 0), + avg_video_views=creator_data.get('avg_video_views', 0), + pricing=creator_data.get('pricing'), + pricing_package=creator_data.get('pricing_package'), + collab_count=creator_data.get('collab_count'), + latest_collab=creator_data.get('latest_collab'), + profile=creator_data.get('profile'), + hashtags=creator_data.get('hashtags', ''), + trends=creator_data.get('trends', '') + ) + except Exception as e: + logger.error(f"创建CreatorProfile时出错: {str(e)}") + continue # 检查是否已经在当前session中存在相同creator existing_creator = Creator.objects.filter( diff --git a/daren/asgi.py b/daren/asgi.py index ba7f5b9..d354f14 100644 --- a/daren/asgi.py +++ b/daren/asgi.py @@ -23,12 +23,14 @@ from channels.auth import AuthMiddlewareStack # 确保在django.setup()之后再导入 import apps.brands.routing +import apps.chat.routing application = ProtocolTypeRouter({ 'http': get_asgi_application(), 'websocket': AuthMiddlewareStack( URLRouter( - apps.brands.routing.websocket_urlpatterns + apps.brands.routing.websocket_urlpatterns + + apps.chat.routing.websocket_urlpatterns ) ), }) diff --git a/daren/settings.py b/daren/settings.py index 8693354..f5d75d5 100644 --- a/daren/settings.py +++ b/daren/settings.py @@ -49,6 +49,8 @@ INSTALLED_APPS = [ "apps.brands.apps.BrandsConfig", 'rest_framework', 'rest_framework_simplejwt', + 'apps.chat.apps.ChatConfig', + 'apps.common.apps.CommonConfig', ] MIDDLEWARE = [ @@ -100,7 +102,7 @@ DATABASES = { 'NAME': 'daren_detail', 'USER': 'root', 'PASSWORD': '123456', - 'HOST': 'localhost', + 'HOST': '192.168.31.138', 'PORT': '3306', 'OPTIONS': { 'charset': 'utf8mb4', @@ -209,9 +211,13 @@ AUTH_USER_MODEL = 'user.User' # REST Framework 设置 REST_FRAMEWORK = { - 'DEFAULT_AUTHENTICATION_CLASSES': [], # 默认不需要认证 - 'DEFAULT_PERMISSION_CLASSES': [], # 默认不需要权限 - 'UNAUTHENTICATED_USER': None + 'DEFAULT_AUTHENTICATION_CLASSES': [ + 'rest_framework_simplejwt.authentication.JWTAuthentication', + 'apps.user.authentication.CustomTokenAuthentication', + ], + 'DEFAULT_PERMISSION_CLASSES': [ + 'rest_framework.permissions.IsAuthenticated', + ], } # JWT 设置 @@ -230,3 +236,6 @@ SIMPLE_JWT = { 'JTI_CLAIM': None, # 不在 token 中包含 JWT ID } +API_BASE_URL = 'http://81.69.223.133:48329' +SILICON_CLOUD_API_KEY = 'sk-xqbujijjqqmlmlvkhvxeogqjtzslnhdtqxqgiyuhwpoqcjvf' + diff --git a/daren/urls.py b/daren/urls.py index c7f260f..1edfcd7 100644 --- a/daren/urls.py +++ b/daren/urls.py @@ -15,4 +15,5 @@ urlpatterns = [ path('api/discovery/', include('apps.discovery.urls')), path('api/template/', include('apps.template.urls')), path('api/', include('apps.brands.urls')), + path('api/chat-history/', include('apps.chat.urls')), ]