From 315b3776cf9506ee8992360090ffdc5f94e078f2 Mon Sep 17 00:00:00 2001 From: SuperManTouX <93423476+SuperManTouX@users.noreply.github.com> Date: Fri, 6 Mar 2026 18:00:06 +0800 Subject: [PATCH] =?UTF-8?q?refactor(session):=20=E9=87=8D=E6=9E=84?= =?UTF-8?q?=E6=97=A7=E4=BC=9A=E8=AF=9D=E7=AE=A1=E7=90=86=E9=80=BB=E8=BE=91?= =?UTF-8?q?=E4=BB=A5=E6=8F=90=E5=8D=87=E5=8F=AF=E7=BB=B4=E6=8A=A4=E6=80=A7?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- server/api/chat_routes.py | 1081 ----------------------------- server/api/chat_routes_glm.py | 150 ---- server/api/conversation_routes.py | 200 ++++++ server/main.py | 36 +- 4 files changed, 214 insertions(+), 1253 deletions(-) delete mode 100644 server/api/chat_routes.py delete mode 100644 server/api/chat_routes_glm.py create mode 100644 server/api/conversation_routes.py diff --git a/server/api/chat_routes.py b/server/api/chat_routes.py deleted file mode 100644 index 8145131..0000000 --- a/server/api/chat_routes.py +++ /dev/null @@ -1,1081 +0,0 @@ -""" -API 路由定义(阿里云百炼 / DashScope 平台) -所有 DashScope 相关逻辑均集中在此文件,main.py 无感知任何平台细节。 -""" - -import json -import os -import uuid -from datetime import datetime -from pathlib import Path -from typing import Dict, List - -import dashscope -from dashscope import Generation, MultiModalConversation -from fastapi import File, HTTPException, UploadFile -from fastapi.responses import JSONResponse, StreamingResponse - - -def init(): - """ - 初始化百炼后端:设置 DashScope API Key。 - 由 main.py 在启动时调用(若 LLM_BACKEND=dashscope)。 - """ - api_key = os.getenv("ALIYUN_API_KEY") - if not api_key: - raise ValueError("dashscope 模式需要设置环境变量 ALIYUN_API_KEY") - dashscope.api_key = api_key - print(f"[DashScope] 初始化完成,ALIYUN_API_KEY 已配置") - - -# 导入模型和工具函数(使用绝对路径) -import sys -from pathlib import Path - -sys.path.append(str(Path(__file__).parent.parent)) - -from models.chat_models import ChatRequest, ModelInfo -from utils.helpers import ( - extract_delta_content, - format_api_response, - generate_unique_id, - get_current_timestamp, -) -from utils.logger import log_error, log_exception, log_info - -# 模拟数据库 - 实际应用中应使用持久化存储 -conversations_db: Dict[str, dict] = {} - -# 配置上传目录 -upload_dir = Path("uploads") -upload_dir.mkdir(exist_ok=True) - - -def _extract_text_from_docmind(obj, depth: int = 0) -> str: - """ - 递归提取 DocMind JSON 结构中的可读文本。 - DashScopeParse 返回的 text 字段是 JSON 字符串,内部为文档智能解析的树形结构。 - """ - if depth > 15: - return "" - - if isinstance(obj, str): - s = obj.strip() - # 过滤极短、URL、base64 等非正文字符串 - if len(s) > 3 and not s.startswith(("http://", "https://", "data:", "oss://")): - return s - return "" - - if isinstance(obj, list): - parts = [_extract_text_from_docmind(item, depth + 1) for item in obj] - return "\n".join(p for p in parts if p) - - if isinstance(obj, dict): - # 优先处理文本相关字段 - priority_keys = ["content", "text", "paragraph", "caption", "value", "title"] - # 跳过纯元数据字段 - skip_keys = { - "backlink", - "pos", - "index", - "style", - "font", - "color", - "size", - "hash", - "id_", - "id", - "layouts", - "type", - "link", - } - parts = [] - for key in priority_keys: - if key in obj: - t = _extract_text_from_docmind(obj[key], depth + 1) - if t: - parts.append(t) - for key, val in obj.items(): - if key not in priority_keys and key not in skip_keys: - t = _extract_text_from_docmind(val, depth + 1) - if t: - parts.append(t) - return "\n".join(parts) - - return "" - - -def _read_file_content(file_url: str): - """ - 【路线一:本地文本提取】对纯文本格式文件,直接读取内容注入消息。 - 【路线二:DashScopeParse】对 doc/docx/pdf 文件,返回 (local_path, suffix) 供异步调用。 - - 返回值: - - str:文本内容(路线一成功) - - tuple(Path, str):(本地路径, 扩展名),需异步调用 DashScopeParse(路线二) - - None:不支持的文件类型 - """ - try: - from urllib.parse import urlparse - - parsed = urlparse(file_url) - relative_path = parsed.path.lstrip("/") - local_path = Path(relative_path) - - if not local_path.exists(): - return f"[文件不存在: {local_path}]" - - suffix = local_path.suffix.lower() - - # 路线一:纯文本格式直接读取 - text_extensions = { - ".txt", - ".md", - ".csv", - ".json", - ".xml", - ".yaml", - ".yml", - ".log", - ".py", - ".js", - ".ts", - ".html", - ".css", - } - if suffix in text_extensions: - with open(local_path, "r", encoding="utf-8", errors="replace") as f: - content = f.read() - max_len = 8000 - if len(content) > max_len: - content = ( - content[:max_len] - + f"\n\n[...文件内容过长,已截断,共 {len(content)} 字符]" - ) - return content - - # 路线二:doc/docx/pdf 使用 DashScopeParse 云端解析 - dashscope_extensions = {".doc", ".docx", ".pdf"} - if suffix in dashscope_extensions: - return (local_path, suffix) # 交给异步函数处理 - - # 其余格式(xlsx、pptx 等)暂不支持内容读取 - return None - - except Exception as e: - print(f"[WARNING] 读取文件内容失败: {e}") - return f"[文件读取失败: {str(e)}]" - - -async def _parse_with_dashscope(local_path: Path) -> str: - """ - 【路线二:DashScopeParse】使用阿里云文档智能解析 doc/docx/pdf 文件。 - 在线程池中运行(避免阻塞 FastAPI 事件循环)。 - 仅支持 .doc/.docx/.pdf,文件大小 ≤100MB,页数 ≤1000 页。 - """ - import asyncio - - def _sync_parse(): - try: - import json - - from llama_index.readers.dashscope.base import DashScopeParse - from llama_index.readers.dashscope.utils import ResultType - - api_key = os.getenv("ALIYUN_API_KEY") - parser = DashScopeParse( - result_type=ResultType.DASHSCOPE_DOCMIND, - api_key=api_key, - num_workers=1, - ) - print(f"[INFO] DashScopeParse: 开始解析 {local_path.name} ...") - documents = parser.load_data(file_path=[str(local_path)]) - - if not documents: - return f"[DashScopeParse: {local_path.name} 解析结果为空]" - - texts = [] - for doc in documents: - try: - content = json.loads(doc.text) - extracted = _extract_text_from_docmind(content) - texts.append(extracted if extracted else doc.text[:6000]) - except Exception: - texts.append(doc.text[:6000] if doc.text else "") - - result = "\n\n".join(t for t in texts if t) - print( - f"[INFO] DashScopeParse: {local_path.name} 解析完成,提取 {len(result)} 字符" - ) - return result or f"[DashScopeParse: {local_path.name} 未能提取到文本内容]" - - except ImportError: - return "[错误:DashScopeParse 未安装,请运行: pip install llama-index-core llama-index-readers-dashscope]" - except Exception as e: - print(f"[ERROR] DashScopeParse 解析失败: {e}") - return f"[DashScopeParse 解析失败: {str(e)}]" - - loop = asyncio.get_event_loop() - return await loop.run_in_executor(None, _sync_parse) - - -async def _inject_files_into_messages(messages: list, files: list) -> list: - """ - 将文件内容异步注入到消息列表中。 - - 文本类文件(路线一):读取内容并追加到最后一条 user 消息中 - - doc/docx/pdf(路线二):调用 DashScopeParse 云端解析后注入 - - 其他二进制文件:仅告知 AI 文件名和类型 - """ - if not files: - return messages - - file_context_parts = [] - for file_url in files: - from urllib.parse import urlparse - - parsed = urlparse(file_url) - filename = parsed.path.split("/")[-1] - suffix = Path(filename).suffix.lower() - - result = _read_file_content(file_url) - - if isinstance(result, str): - # 路线一:文本内容,直接嵌入 - file_context_parts.append( - f"--- 附件文件内容({filename})---\n{result}\n--- 附件结束 ---" - ) - elif isinstance(result, tuple): - # 路线二:doc/docx/pdf → 调用 DashScopeParse - local_path, _ = result - print(f"[INFO] 路线二:调用 DashScopeParse 解析 {filename}") - parsed_text = await _parse_with_dashscope(local_path) - file_context_parts.append( - f"--- 附件文件内容({filename},阿里云文档智能解析)---\n" - f"{parsed_text}\n--- 附件结束 ---" - ) - else: - # 其他不支持的格式:仅告知文件信息 - file_context_parts.append( - f"[用户上传了一个文件: {filename},类型: {suffix},暂不支持自动读取内容,请告知用户。]" - ) - - if not file_context_parts: - return messages - - file_context_text = "\n\n" + "\n\n".join(file_context_parts) - - # 把文件内容追加到最后一条 user 消息 - messages = list(messages) # 复制,避免修改原始列表 - for i in range(len(messages) - 1, -1, -1): - msg = messages[i] - if isinstance(msg, dict) and msg.get("role") == "user": - content = msg.get("content", "") - if isinstance(content, str): - messages[i] = dict(msg, content=content + file_context_text) - elif isinstance(content, list): - # 找到现有的 text 项,追加内容 - new_content = list(content) - appended = False - for j, item in enumerate(new_content): - if isinstance(item, dict) and item.get("type") == "text": - new_content[j] = dict( - item, text=item["text"] + file_context_text - ) - appended = True - break - if not appended: - new_content.append({"type": "text", "text": file_context_text}) - messages[i] = dict(msg, content=new_content) - break - - return messages - - -async def chat_endpoint_handler(body: dict): - """ - 聊天接口处理器 - 与阿里云百炼API兼容的接口 - 这个端点会接收前端的聊天请求并转发到阿里云百炼API - """ - try: - # 确保 body 是字典类型 - if not isinstance(body, dict): - print(f"[ERROR] Request body is not a dictionary: {type(body)}") - raise HTTPException( - status_code=400, - detail=f"Request body must be a JSON object, got {type(body).__name__}: {body}", - ) - - # 检查请求格式并适配 - # 如果是OpenAI兼容格式 (来自streamChat) - if "messages" in body: - messages = body.get("messages", []) - model = body.get("model", "qwen-plus") - stream = body.get("stream", True) - temperature = body.get("temperature", 0.7) - max_tokens = body.get("max_tokens", 2000) - deepSearch = body.get("deepSearch", False) - webSearch = body.get("webSearch", False) - deepThinking = body.get("deepThinking", False) - - log_info( - f"POST /api/chat-ui/chat | 模型: {model} | 流式: {stream} | 联网搜索: {webSearch} | 深度搜索: {deepSearch} | 深度思考: {deepThinking}" - ) - - # 处理 files 附件:将文件内容注入到最后一条 user 消息中 - files = body.get("files", []) - if files: - messages = await _inject_files_into_messages(messages, files) - # 调试:打印注入后最后一条 user 消息的内容(截断显示 500 字) - for msg in reversed(messages): - if isinstance(msg, dict) and msg.get("role") == "user": - content_preview = str(msg.get("content", ""))[:500] - print( - f"[DEBUG] 注入文件后 user 消息内容预览: {content_preview}" - ) - break - else: - # 否则是前端简化格式 (来自chat函数) - message_text = body.get("message", "") - - # 检查message是否已经是格式化的列表(带图片的情况) - if isinstance(message_text, list): - user_content = message_text - else: - user_content = [{"type": "text", "text": message_text}] - - messages = [ - { - "role": "system", - "content": body.get( - "systemPrompt", - "你是一个智能助手,可以分析用户发送的文本和文件内容。", - ), - }, - {"role": "user", "content": user_content}, - ] - model = body.get("model", "qwen-plus") - stream = body.get("stream", False) - temperature = body.get("temperature", 0.7) - max_tokens = body.get("maxTokens", 2000) - deepSearch = body.get("deepSearch", False) - webSearch = body.get("webSearch", False) - deepThinking = body.get("deepThinking", False) - - # 检查是否包含图像内容,如果是多模态请求,使用MultiModalConversation - has_images = any( - isinstance(msg, dict) - and isinstance(msg.get("content"), list) - and any( - isinstance(item, dict) and item.get("type") == "image_url" - for item in msg.get("content", []) - ) - for msg in messages - if isinstance(msg, dict) - ) - - if has_images: - # 使用多模态API处理图像 - return await multimodal_chat_handler( - messages, model, stream, temperature, max_tokens - ) - else: - # 构建 DashScope 额外参数 - dashscope_kwargs = {} - if deepSearch: - dashscope_kwargs["enable_search"] = True - dashscope_kwargs["search_options"] = {"search_strategy": "max"} - # 只有特定的思考模型版本支持部分高级 agent,但目前我们保持使用基础模型 + max 策略 - elif webSearch: - dashscope_kwargs["enable_search"] = True - dashscope_kwargs["search_options"] = {"search_strategy": "turbo"} - - if deepThinking: - dashscope_kwargs["enable_thinking"] = True - dashscope_kwargs["result_format"] = ( - "message" # enable_thinking 必须配合 result_format=message - ) - dashscope_kwargs["incremental_output"] = ( - True # 流式模式下 enable_thinking 还必须配合 incremental_output=True - ) - - # 使用常规聊天API - if stream: - # 流式响应 - async def event_generator(): - try: - responses = Generation.call( - model=model, - messages=messages, - stream=True, - max_tokens=max_tokens, - temperature=temperature, - **dashscope_kwargs, - ) - - full_content = "" # 用于累计完整内容 - full_reasoning_content = "" # 用于累计完整思考内容 - - for idx, response in enumerate(responses): - if response.status_code == 200: - content = None - - # 尝试从 output.choices 获取内容 - if ( - hasattr(response, "output") - and response.output - and hasattr(response.output, "choices") - and response.output.choices is not None - and len(response.output.choices) > 0 - and "message" in response.output.choices[0] - ): - - msg_dict = response.output.choices[0]["message"] - # incremental_output=True 时,每个 chunk 的 content/reasoning_content 已是增量片段 - # 直接使用,无需与 full_* 做对比 - content = msg_dict.get("content") or "" - reasoning_content = ( - msg_dict.get("reasoning_content") or "" - ) - - delta_str = "" - - # 处理思考过程片段 - if reasoning_content: - if not full_reasoning_content: - # 第一个思考片段,添加 开始标签 - delta_str += "" - full_reasoning_content += reasoning_content - delta_str += reasoning_content - - # 处理正式回复片段 - if content: - if not full_content and full_reasoning_content: - # 思考结束后首个正式回复,关闭 标签 - delta_str += "\n\n" - full_content += content - delta_str += content - - if delta_str: - data = { - "id": f"chatcmpl-{generate_unique_id()}", - "object": "chat.completion.chunk", - "created": get_current_timestamp(), - "model": model, - "choices": [ - { - "index": 0, - "delta": {"content": delta_str}, - "finish_reason": None, - } - ], - } - - yield f"data: {json.dumps(data, ensure_ascii=False)}\n\n" - # 否则尝试从 output.text 获取内容(DashScope特定格式) - elif ( - hasattr(response, "output") - and response.output - and "text" in response.output - ): - - content = response.output.get("text") - - # 只有当内容发生变化时才发送增量 - if len(content) > len(full_content): - delta_content = extract_delta_content( - content, full_content - ) - full_content = content - - if ( - delta_content.strip() - ): # 只有当有非空白新内容时才发送 - # 构建 SSE 数据块 - data = { - "id": f"chatcmpl-{generate_unique_id()}", - "object": "chat.completion.chunk", - "created": get_current_timestamp(), - "model": model, - "choices": [ - { - "index": 0, - "delta": { - "content": delta_content - }, - "finish_reason": None, - } - ], - } - - yield f"data: {json.dumps(data, ensure_ascii=False)}\n\n" - else: - # 错误处理:写入 logger,方便排查 - log_error( - f"DashScope API 返回错误: chunk status={response.status_code}, code={response.code}, msg={response.message}" - ) - error_data = { - "error": { - "message": f"API Error: {response.code} - {response.message}", - "type": "api_error", - "param": None, - "code": response.code, - } - } - yield f"data: {json.dumps(error_data, ensure_ascii=False)}\n\n" - break - - # 发送结束信号 - finish_data = { - "id": f"chatcmpl-{generate_unique_id()}", - "object": "chat.completion.chunk", - "created": get_current_timestamp(), - "model": model, - "choices": [ - {"index": 0, "delta": {}, "finish_reason": "stop"} - ], - } - yield f"data: {json.dumps(finish_data, ensure_ascii=False)}\n\n" - yield "data: [DONE]\n\n" - except Exception as e: - log_exception(f"流式生成器异常: {e}") - error_data = { - "error": {"message": str(e), "type": "server_error"} - } - yield f"data: {json.dumps(error_data, ensure_ascii=False)}\n\n" - - return StreamingResponse( - event_generator(), media_type="text/event-stream" - ) - else: - # 非流式响应 - response = Generation.call( - model=model, - messages=messages, - stream=False, - max_tokens=max_tokens, - temperature=temperature, - **dashscope_kwargs, - ) - - if response.status_code == 200: - # 检查响应是否包含预期的内容 - # DashScope API的响应结构可能是 output.choices 或 output.text - content = None - - # 尝试从 output.choices 获取内容 - if ( - hasattr(response, "output") - and response.output - and hasattr(response.output, "choices") - and response.output.choices is not None - and len(response.output.choices) > 0 - and "message" in response.output.choices[0] - ): - - msg_dict = response.output.choices[0]["message"] - content = msg_dict.get("content", "") - rc = msg_dict.get("reasoning_content", "") - if rc: - content = f"{rc}\n\n{content}" - # 否则尝试从 output.text 获取内容(DashScope特定格式) - elif ( - hasattr(response, "output") - and response.output - and "text" in response.output - ): - - content = response.output.get("text") - - if content: - # 构建前端期望的响应格式 - chat_response = format_api_response( - content=content, - conversation_id=body.get("conversationId"), - model=model, - ) - - if hasattr(response, "usage") and response.usage: - chat_response["usage"] = { - "promptTokens": response.usage.input_tokens, - "completionTokens": response.usage.output_tokens, - "totalTokens": response.usage.total_tokens, - } - - return JSONResponse(content=chat_response, ensure_ascii=False) - else: - raise HTTPException( - status_code=500, - detail="API Response does not contain expected content", - ) - else: - raise HTTPException( - status_code=500, - detail=f"API Error: {response.code} - {response.message}", - ) - - except Exception as e: - print(f"[ERROR] Error in chat endpoint: {str(e)}") - import traceback - - print(f"[ERROR] Traceback: {traceback.format_exc()}") - raise HTTPException(status_code=500, detail=str(e)) - - -async def multimodal_chat_handler(messages, model, stream, temperature, max_tokens): - """ - 多模态聊天处理器 - 处理包含图像的消息 - """ - try: - # 将OpenAI格式的消息转换为DashScope MultiModalConversation格式 - dashscope_messages = [] - for i, msg in enumerate(messages): - # 验证 msg 是否为字典类型,如果不是则跳过或处理为字符串 - if not isinstance(msg, dict): - # 如果消息不是字典,将其作为纯文本处理 - dashscope_content = [{"text": str(msg)}] - dashscope_messages.append( - {"role": "user", "content": dashscope_content} - ) - continue - - role = msg.get("role", "user") - content = msg.get("content", "") - - if isinstance(content, str): - # 纯文本内容 - dashscope_content = [{"text": content}] - elif isinstance(content, list): - # 包含图像和文本的内容 - dashscope_content = [] - for j, item in enumerate(content): - if isinstance(item, dict): - if item.get("type") == "text": - dashscope_content.append({"text": item.get("text", "")}) - elif item.get("type") == "image_url": - # 处理 image_url 可能是字符串或字典两种情况 - image_url_value = item.get("image_url", "") - if isinstance(image_url_value, str): - # 如果 image_url 是字符串,直接使用 - img_url = image_url_value - elif ( - isinstance(image_url_value, dict) - and "url" in image_url_value - ): - # 如果 image_url 是字典,从中获取 url - img_url = image_url_value.get("url", "") - else: - # 其他情况视为错误或空值 - img_url = "" - - # 如果URL是http格式,提取文件名并转换为file://格式 - if img_url.startswith("http://") or img_url.startswith( - "https://" - ): - # 提取URL中的文件名部分 (例如从 http://localhost:8000/uploads/filename.jpg 提取 uploads/filename.jpg) - from urllib.parse import urlparse - - parsed_url = urlparse(img_url) - path_parts = parsed_url.path.split("/") - - # 从路径中找到uploads部分及后面的文件名 - try: - uploads_index = path_parts.index("uploads") - filename = "/".join( - path_parts[uploads_index:] - ) # 例如: uploads/filename.jpg - img_url = f"file://{filename}" - except ValueError: - # 如果路径中没有uploads部分,使用原始路径 - img_url = f"file://{parsed_url.path.lstrip('/')}" - elif not img_url.startswith("file://"): - # 如果既不是网络URL也不是file://协议,假设是相对路径 - img_url = f"file://{img_url}" - - if img_url.startswith("file://"): - # 确保本地文件存在 - import os - - local_path = img_url[7:] # 移除 "file://" 前缀 - if not os.path.exists(local_path): - print( - f"[WARNING] Image file does not exist: {local_path}" - ) - - dashscope_content.append({"image": img_url}) - else: - # 将非字典内容转换为文本 - dashscope_content.append({"text": str(item)}) - else: - # 其他情况转换为文本 - dashscope_content = [{"text": str(content)}] - - dashscope_messages.append({"role": role, "content": dashscope_content}) - - if stream: - # 多模态流式响应 - async def multimodal_event_generator(): - try: - responses = MultiModalConversation.call( - model=( - model.replace("qwen-", "qwen-vl-") - if "qwen-" in model - else "qwen-vl-max" - ), - messages=dashscope_messages, - stream=True, - max_tokens=max_tokens, - temperature=temperature, - ) - - full_content = "" - - for response in responses: - if response.status_code == 200: - content = None - - # 从多模态响应中提取内容 - if ( - hasattr(response, "output") - and response.output - and hasattr(response.output, "choices") - and response.output.choices is not None - and len(response.output.choices) > 0 - and "message" in response.output.choices[0] - ): - - message = response.output.choices[0]["message"] - if "content" in message: - content_items = message["content"] - - # 从内容项中提取文本 - extracted_text = "" - for item in content_items: - if isinstance(item, dict) and "text" in item: - extracted_text += item["text"] - - content = extracted_text - - # 只有当内容发生变化时才发送增量 - if len(content) > len(full_content): - delta_content = extract_delta_content( - content, full_content - ) - full_content = content - - if delta_content.strip(): - data = { - "id": f"chatcmpl-{generate_unique_id()}", - "object": "chat.completion.chunk", - "created": get_current_timestamp(), - "model": model, - "choices": [ - { - "index": 0, - "delta": { - "content": delta_content - }, - "finish_reason": None, - } - ], - } - - yield f"data: {json.dumps(data, ensure_ascii=False)}\n\n" - else: - error_data = { - "error": { - "message": f"Multimodal API Error: {response.code} - {response.message}", - "type": "api_error", - "param": None, - "code": response.code, - } - } - yield f"data: {json.dumps(error_data, ensure_ascii=False)}\n\n" - break - - finish_data = { - "id": f"chatcmpl-{generate_unique_id()}", - "object": "chat.completion.chunk", - "created": get_current_timestamp(), - "model": model, - "choices": [{"index": 0, "delta": {}, "finish_reason": "stop"}], - } - yield f"data: {json.dumps(finish_data, ensure_ascii=False)}\n\n" - yield "data: [DONE]\n\n" - except Exception as e: - error_data = {"error": {"message": str(e), "type": "server_error"}} - yield f"data: {json.dumps(error_data, ensure_ascii=False)}\n\n" - - return StreamingResponse( - multimodal_event_generator(), media_type="text/event-stream" - ) - else: - # 多模态非流式响应 - response = MultiModalConversation.call( - model=( - model.replace("qwen-", "qwen-vl-") - if "qwen-" in model - else "qwen-vl-max" - ), - messages=dashscope_messages, - stream=False, - max_tokens=max_tokens, - temperature=temperature, - ) - - if response.status_code == 200: - content = None - - if ( - hasattr(response, "output") - and response.output - and hasattr(response.output, "choices") - and response.output.choices is not None - and len(response.output.choices) > 0 - and "message" in response.output.choices[0] - ): - - message = response.output.choices[0]["message"] - if "content" in message: - content_items = message["content"] - - # 从内容项中提取文本 - extracted_text = "" - for item in content_items: - if isinstance(item, dict) and "text" in item: - extracted_text += item["text"] - - content = extracted_text - - if content: - return JSONResponse(content={"result": content}, ensure_ascii=False) - else: - raise HTTPException( - status_code=500, - detail="Multimodal API Response does not contain expected content", - ) - else: - raise HTTPException( - status_code=500, - detail=f"Multimodal API Error: {response.code} - {response.message}", - ) - - except Exception as e: - print(f"[ERROR] Error in multimodal chat handler: {str(e)}") - import traceback - - print(f"[ERROR] Traceback: {traceback.format_exc()}") - raise HTTPException(status_code=500, detail=str(e)) - - -async def get_models_handler(): - """获取模型列表处理器""" - models = [ - ModelInfo( - id="qwen-max", - name="通义千问 Max", - description="最强大的模型", - maxTokens=8192, - provider="Aliyun", - ), - ModelInfo( - id="qwen-plus", - name="通义千问 Plus", - description="能力均衡", - maxTokens=8192, - provider="Aliyun", - ), - ModelInfo( - id="qwen-turbo", - name="通义千问 Turbo", - description="速度更快、成本更低", - maxTokens=8192, - provider="Aliyun", - ), - ModelInfo( - id="qwen-vl-max", - name="通义万相 VL-Max", - description="支持视觉理解的多模态模型", - maxTokens=8192, - provider="Aliyun", - ), - ModelInfo( - id="qwen-vl-plus", - name="通义万相 VL-Plus", - description="支持视觉理解的多模态模型", - maxTokens=8192, - provider="Aliyun", - ), - ] - return [model.dict() for model in models] - - -async def get_conversations_handler(): - """获取所有对话处理器""" - return list(conversations_db.values()) - - -async def get_conversation_handler(conversation_id: str): - """获取特定对话处理器""" - conversation = conversations_db.get(conversation_id) - if not conversation: - raise HTTPException(status_code=404, detail="对话不存在") - return conversation - - -async def save_conversation_handler(data: dict): - """保存或更新对话处理器""" - try: - conversation_id = data.get("id") or generate_unique_id() - - conversation = { - "id": conversation_id, - "title": data.get("title", "新对话"), - "messages": data.get("messages", []), - "updatedAt": datetime.utcnow().isoformat(), - "createdAt": data.get("createdAt", datetime.utcnow().isoformat()), - } - - conversations_db[conversation_id] = conversation - return conversation - - except Exception as e: - print(f"[ERROR] Error saving conversation: {str(e)}") - raise HTTPException(status_code=500, detail=str(e)) - - -async def delete_conversation_handler(conversation_id: str): - """删除对话处理器""" - if conversation_id in conversations_db: - del conversations_db[conversation_id] - return {"success": True, "message": "删除成功"} - else: - raise HTTPException(status_code=404, detail="对话不存在") - - -async def upload_file_handler(file: UploadFile = File(...)): - """文件上传处理器""" - try: - # 允许的 MIME 类型(宽松策略) - allowed_types = { - # 图片 - "image/jpeg", - "image/png", - "image/gif", - "image/webp", - "image/bmp", - "image/svg+xml", - # 文本类 - "text/plain", - "text/csv", - "text/markdown", - "text/html", - "text/xml", - "application/json", - "application/xml", - # PDF - "application/pdf", - # Office 文档 - "application/msword", - "application/vnd.openxmlformats-officedocument.wordprocessingml.document", - "application/vnd.ms-excel", - "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet", - "application/vnd.ms-powerpoint", - "application/vnd.openxmlformats-officedocument.presentationml.presentation", - } - - # 允许的扩展名(兜底:MIME 类型可能被浏览器误判) - allowed_extensions = { - ".jpg", - ".jpeg", - ".png", - ".gif", - ".webp", - ".bmp", - ".txt", - ".md", - ".csv", - ".json", - ".xml", - ".yaml", - ".yml", - ".log", - ".pdf", - ".doc", - ".docx", - ".xls", - ".xlsx", - ".ppt", - ".pptx", - ".py", - ".js", - ".ts", - ".html", - ".css", - } - - file_extension = Path(file.filename).suffix.lower() - - if ( - file.content_type not in allowed_types - and file_extension not in allowed_extensions - ): - raise HTTPException( - status_code=400, - detail=f"不支持的文件类型: {file.content_type}({file_extension})", - ) - - # 生成唯一文件名 - unique_filename = f"{int(datetime.utcnow().timestamp())}_{generate_unique_id()}{file_extension}" - file_path = upload_dir / unique_filename - - # 保存文件到本地(临时缓存) - content = await file.read() - with open(file_path, "wb") as f: - f.write(content) - - # 文件关闭后再上传到 OSS - from utils.oss_uploader import upload_file as oss_upload - - oss_result = oss_upload(str(file_path)) - file_url = oss_result["url"] - - # 返回文件信息 - result = { - "url": file_url, - "name": file.filename, - "size": len(content), - "mimeType": file.content_type, - } - - print(f"[INFO] File uploaded: {result}") - return result - - except Exception as e: - print(f"[ERROR] Upload error: {str(e)}") - raise HTTPException(status_code=500, detail=f"上传失败: {str(e)}") - - -def serve_upload_handler(filename: str): - """提供上传文件访问处理器""" - file_path = upload_dir / filename - if not file_path.exists(): - raise HTTPException(status_code=404, detail="文件不存在") - - from fastapi.responses import FileResponse - - return FileResponse(str(file_path)) - - -async def stop_generation_handler(message_id: str = None): - """停止生成处理器""" - message = ( - f"已发出停止指令,消息ID: {message_id}" if message_id else "已发出停止指令" - ) - return {"success": True, "message": message} - - -# ── 平台统一接口别名(供 main.py 的 _platform 动态调用)───────────── -# main.py 通过 _platform.chat_handler / _platform.models_handler 调用, -# 各平台模块需暴露相同名称的函数。 -chat_handler = chat_endpoint_handler # 聊天接口别名 -models_handler = get_models_handler # 模型列表别名 diff --git a/server/api/chat_routes_glm.py b/server/api/chat_routes_glm.py deleted file mode 100644 index d8eae69..0000000 --- a/server/api/chat_routes_glm.py +++ /dev/null @@ -1,150 +0,0 @@ -""" -GLM-4.6V 平台路由处理器(zai-sdk) -所有智谱 GLM 相关逻辑均集中在此文件,main.py 无感知任何平台细节。 -""" - -import json -import os -import sys -from pathlib import Path - -from fastapi import HTTPException -from fastapi.responses import JSONResponse, StreamingResponse - -from utils.helpers import generate_unique_id, get_current_timestamp -from utils.logger import log_info - - -def init(): - """ - 初始化 GLM 后端:验证 API Key 是否配置。 - 由 main.py 在启动时调用(若 LLM_BACKEND=glm)。 - """ - api_key = os.getenv("ZHIPU_API_KEY") or os.getenv("GLM_API_KEY") - if not api_key: - raise ValueError( - "GLM 模式需要设置环境变量 ZHIPU_API_KEY(在 https://open.bigmodel.cn 申请)" - ) - log_info(f"[GLM] 初始化完成,ZHIPU_API_KEY 已配置") - - -async def chat_handler(body: dict): - """ - GLM 聊天处理器(对外接口与百炼 chat_endpoint_handler 完全兼容)。 - 流式/非流式自动适配,支持图像、文档附件、联网搜索、深度思考。 - """ - from utils.glm_adapter import glm_chat_sync, glm_stream_generator - - if not isinstance(body, dict): - raise HTTPException(status_code=400, detail="请求体必须是 JSON 对象") - - messages = body.get("messages", []) - model = body.get("model", "glm-4.6v") - stream = body.get("stream", True) - temperature = body.get("temperature", 0.7) - max_tokens = body.get("max_tokens", body.get("maxTokens", 2000)) - # 区分搜索模式:深度搜索 > 简单搜索 > 不搜索 - if body.get("deepSearch", False): - web_search = "deep" - elif body.get("webSearch", False): - web_search = "simple" - else: - web_search = False - deep_think = body.get("deepThinking", False) - files = body.get("files", []) - - # 兼容前端简化格式(非 messages 结构) - if not messages: - msg_text = body.get("message", "") - sys_prompt = body.get("systemPrompt", "你是一个智能助手。") - user_content = ( - msg_text - if isinstance(msg_text, list) - else [{"type": "text", "text": msg_text}] - ) - messages = [ - {"role": "system", "content": sys_prompt}, - {"role": "user", "content": user_content}, - ] - - log_info( - f"[GLM] model={model} stream={stream} web_search={web_search} " - f"thinking={deep_think} files={len(files)} msgs={len(messages)}" - ) - - if stream: - return StreamingResponse( - glm_stream_generator( - messages=messages, - model=model, - temperature=temperature, - max_tokens=max_tokens, - files=files or None, - web_search=web_search, - deep_thinking=deep_think, - ), - media_type="text/event-stream", - ) - - result = glm_chat_sync( - messages=messages, - model=model, - temperature=temperature, - max_tokens=max_tokens, - files=files or None, - web_search=web_search, - deep_thinking=deep_think, - ) - resp = { - "id": f"chatcmpl-{generate_unique_id()}", - "object": "chat.completion", - "created": get_current_timestamp(), - "model": result["model"], - "choices": [ - { - "index": 0, - "message": {"role": "assistant", "content": result["content"]}, - "finish_reason": "stop", - } - ], - } - if result.get("usage"): - resp["usage"] = result["usage"] - return JSONResponse(content=resp) - - -def models_handler(): - """返回 GLM 可用模型列表""" - return { - "data": [ - { - "id": "glm-4.6v", - "name": "GLM-4.6V(推荐)", - "description": "最新旗舰模型,支持文本/图像/文档/深度思考", - "maxTokens": 128000, - "provider": "ZhipuAI", - }, - { - "id": "glm-4-flash", - "name": "GLM-4 Flash", - "description": "高性价比文本模型(0.2元/千token)", - "maxTokens": 128000, - "provider": "ZhipuAI", - }, - { - "id": "glm-4v-plus-0111", - "name": "GLM-4V Plus", - "description": "图像 + PDF/DOCX 原生多模态", - "maxTokens": 128000, - "provider": "ZhipuAI", - }, - { - "id": "glm-z1-flash", - "name": "GLM-Z1 Flash", - "description": "深度思考推理模型", - "maxTokens": 128000, - "provider": "ZhipuAI", - }, - ], - "object": "list", - } diff --git a/server/api/conversation_routes.py b/server/api/conversation_routes.py new file mode 100644 index 0000000..ee4971c --- /dev/null +++ b/server/api/conversation_routes.py @@ -0,0 +1,200 @@ +""" +会话管理路由 - 对话 CRUD 和文件上传 + +与平台无关的通用功能,不涉及任何 LLM 平台细节。 +""" + +import json +import os +from datetime import datetime +from pathlib import Path +from typing import Dict + +from fastapi import File, HTTPException, UploadFile +from fastapi.responses import FileResponse + +import sys +sys.path.append(str(Path(__file__).parent.parent)) + +from database import get_db +from utils.helpers import generate_unique_id +from utils.logger import log_error, log_exception, log_info + +# 配置上传目录 +upload_dir = Path(__file__).parent.parent / "uploads" +upload_dir.mkdir(exist_ok=True) + + +# ── 会话管理 ───────────────────────────────────────────────────── + + +async def get_conversations_handler(): + """获取所有对话处理器""" + db = get_db() + return db.list_conversations() + + +async def get_conversation_handler(conversation_id: str): + """获取特定对话处理器""" + db = get_db() + conversation = db.get_conversation(conversation_id) + if not conversation: + raise HTTPException(status_code=404, detail="对话不存在") + return conversation + + +async def save_conversation_handler(data: dict): + """保存或更新对话处理器""" + try: + db = get_db() + conversation_id = data.get("id") + + # 检查是否已存在 + existing = db.get_conversation(conversation_id) if conversation_id else None + + if existing: + # 更新现有会话 + return db.update_conversation(conversation_id, data) + else: + # 创建新会话 + return db.create_conversation(data) + + except Exception as e: + log_error(f"Error saving conversation: {str(e)}") + raise HTTPException(status_code=500, detail=str(e)) + + +async def delete_conversation_handler(conversation_id: str): + """删除对话处理器""" + db = get_db() + success = db.delete_conversation(conversation_id) + if success: + return {"success": True, "message": "删除成功"} + else: + raise HTTPException(status_code=404, detail="对话不存在") + + +# ── 文件上传 ───────────────────────────────────────────────────── + + +async def upload_file_handler(file: UploadFile = File(...)): + """文件上传处理器""" + try: + # 允许的 MIME 类型(宽松策略) + allowed_types = { + # 图片 + "image/jpeg", + "image/png", + "image/gif", + "image/webp", + "image/bmp", + "image/svg+xml", + # 文本类 + "text/plain", + "text/csv", + "text/markdown", + "text/html", + "text/xml", + "application/json", + "application/xml", + # PDF + "application/pdf", + # Office 文档 + "application/msword", + "application/vnd.openxmlformats-officedocument.wordprocessingml.document", + "application/vnd.ms-excel", + "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet", + "application/vnd.ms-powerpoint", + "application/vnd.openxmlformats-officedocument.presentationml.presentation", + } + + # 允许的扩展名(兜底:MIME 类型可能被浏览器误判) + allowed_extensions = { + ".jpg", + ".jpeg", + ".png", + ".gif", + ".webp", + ".bmp", + ".txt", + ".md", + ".csv", + ".json", + ".xml", + ".yaml", + ".yml", + ".log", + ".pdf", + ".doc", + ".docx", + ".xls", + ".xlsx", + ".ppt", + ".pptx", + ".py", + ".js", + ".ts", + ".html", + ".css", + } + + file_extension = Path(file.filename).suffix.lower() + + if ( + file.content_type not in allowed_types + and file_extension not in allowed_extensions + ): + raise HTTPException( + status_code=400, + detail=f"不支持的文件类型: {file.content_type}({file_extension})", + ) + + # 生成唯一文件名 + unique_filename = f"{int(datetime.utcnow().timestamp())}_{generate_unique_id()}{file_extension}" + file_path = upload_dir / unique_filename + + # 保存文件到本地(临时缓存) + content = await file.read() + with open(file_path, "wb") as f: + f.write(content) + + # 文件关闭后再上传到 OSS + from utils.oss_uploader import upload_file as oss_upload + + oss_result = oss_upload(str(file_path)) + file_url = oss_result["url"] + + # 返回文件信息 + result = { + "url": file_url, + "name": file.filename, + "size": len(content), + "mimeType": file.content_type, + } + + log_info(f"File uploaded: {result}") + return result + + except Exception as e: + log_error(f"Upload error: {str(e)}") + raise HTTPException(status_code=500, detail=f"上传失败: {str(e)}") + + +def serve_upload_handler(filename: str): + """提供上传文件访问处理器""" + file_path = upload_dir / filename + if not file_path.exists(): + raise HTTPException(status_code=404, detail="文件不存在") + + return FileResponse(str(file_path)) + + +# ── 停止生成 ───────────────────────────────────────────────────── + + +async def stop_generation_handler(message_id: str = None): + """停止生成处理器""" + message = ( + f"已发出停止指令,消息ID: {message_id}" if message_id else "已发出停止指令" + ) + return {"success": True, "message": message} \ No newline at end of file diff --git a/server/main.py b/server/main.py index 4d53ea6..a5ff1b3 100644 --- a/server/main.py +++ b/server/main.py @@ -45,28 +45,22 @@ from utils.logger import get_logger logger = get_logger() +# ── 初始化数据库 ─────────────────────────────────────────────────────── +from database import init_db + +init_db() + # ── 加载环境变量 ────────────────────────────────────────────────────── load_dotenv() -LLM_BACKEND = os.getenv("LLM_BACKEND", "dashscope").lower().strip() -if LLM_BACKEND not in {"dashscope", "glm"}: - logger.warning(f"未知的 LLM_BACKEND='{LLM_BACKEND}',回退到 dashscope") - LLM_BACKEND = "dashscope" - -# ── 动态加载平台模块 ────────────────────────────────────────────────── -if LLM_BACKEND == "glm": - import api.chat_routes_glm as _platform -else: - import api.chat_routes as _platform - -_platform.init() # 各平台自行完成初始化(API Key 校验等) - -# 通用路由处理器(文件上传、会话管理等,与平台无关,统一用百炼路由中的实现) -from api.chat_routes import (delete_conversation_handler, - get_conversation_handler, - get_conversations_handler, - save_conversation_handler, serve_upload_handler, - stop_generation_handler, upload_file_handler) +# ── 会话管理路由处理器 ──────────────────────────────────────────────── +from api.conversation_routes import (delete_conversation_handler, + get_conversation_handler, + get_conversations_handler, + save_conversation_handler, + serve_upload_handler, + stop_generation_handler, + upload_file_handler) # ── OpenAI 兼容网关初始化 ─────────────────────────────────────────────── from api.openai_gateway import init_adapters, router as openai_router @@ -110,12 +104,11 @@ async def health_check(): return { "status": "healthy", "version": "4.0.0", - "default_backend": LLM_BACKEND, "available_providers": get_available_providers(), "endpoints": { "openai_compatible": "/v1/chat/completions", - "legacy": "/api/chat-ui/chat", "models": "/v1/models", + "conversations": "/api/chat-ui/conversations", }, "timestamp": datetime.now(timezone.utc).isoformat(), } @@ -234,7 +227,6 @@ if __name__ == "__main__": print(f" 模型列表 : http://localhost:{port}/v1/models") print("-" * 60) print(f" 可用平台 : {', '.join(available) or '无(请配置 API Key)'}") - print(f" 默认平台 : {LLM_BACKEND} (向后兼容模式)") print("-" * 60) print(" 使用方法:") print(" curl -X POST http://localhost:8000/v1/chat/completions \\")