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 \\")