chat.py 32 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711
  1. from fastapi import APIRouter, HTTPException, Depends, Query, UploadFile, File
  2. from fastapi.responses import StreamingResponse
  3. from typing import List, Annotated, Optional
  4. from datetime import datetime
  5. import json
  6. import asyncio
  7. import threading
  8. import tempfile
  9. import os
  10. from ..core.ark_client import get_client
  11. from ..core.asr_client import transcribe_audio
  12. from ..config.config import Config
  13. from ..schemas.chat import ChatMessage, ChatRequest, ChatResponse, StreamResponse, FileAttachment
  14. from ..utils.chat_utils import get_latest_user_message, get_previous_response_id, get_doubao_tools, get_web_search_tools, get_knowledge_search_tools
  15. from ..routers.users import get_current_active_user, User
  16. from ..db.mongo import save_chat_log, save_chat_history, get_chat_history, delete_chat_history, get_sessions
  17. from ..db.ai_config import get_config_by_app_name
  18. from ..dependencies.auth import resolve_user_id, UserContext
  19. config = Config()
  20. router = APIRouter()
  21. # 允许的文件类型和大小限制
  22. ALLOWED_MEDIA_TYPES = {
  23. "image": {"extensions": [".jpg", ".jpeg", ".png", ".gif", ".webp", ".bmp"], "max_size": 20 * 1024 * 1024},
  24. "video": {"extensions": [".mp4", ".mov", ".avi", ".mkv", ".webm"], "max_size": 100 * 1024 * 1024},
  25. "audio": {"extensions": [".mp3", ".wav", ".ogg", ".m4a", ".flac", ".aac"], "max_size": 50 * 1024 * 1024},
  26. }
  27. def detect_media_type(filename: str) -> Optional[str]:
  28. """ 根据文件后缀检测媒体类型 """
  29. ext = os.path.splitext(filename)[1].lower()
  30. for media_type, cfg in ALLOWED_MEDIA_TYPES.items():
  31. if ext in cfg["extensions"]:
  32. return media_type
  33. return None
  34. # 媒体类型 → Responses API input type 映射
  35. _MEDIA_TYPE_TO_INPUT_TYPE = {
  36. "image": "input_image",
  37. "video": "input_video",
  38. "audio": "input_audio",
  39. }
  40. def build_multimodal_input(message: ChatMessage) -> dict:
  41. """
  42. 将 ChatMessage 转换为 Responses API 支持的多模态 input 格式。
  43. - 纯文本消息:保持 {"role": "user", "content": "text"} (向后兼容)
  44. - 带附件消息:根据 media_type 使用对应 input type:
  45. 图片 → {"type": "input_image", "file_id": "..."}
  46. 视频 → {"type": "input_video", "file_id": "..."}
  47. 音频 → {"type": "input_audio", "file_id": "..."}
  48. """
  49. if not message.attachments:
  50. return {"role": message.role, "content": message.content}
  51. content_parts = []
  52. for attachment in message.attachments:
  53. input_type = _MEDIA_TYPE_TO_INPUT_TYPE.get(attachment.media_type, "input_file")
  54. content_parts.append({
  55. "type": input_type,
  56. "file_id": attachment.file_id,
  57. })
  58. if message.content and message.content.strip():
  59. content_parts.append({
  60. "type": "input_text",
  61. "text": message.content,
  62. })
  63. return {"role": message.role, "content": content_parts}
  64. @router.post("/upload")
  65. async def upload_file(
  66. file: UploadFile = File(...),
  67. user_context: UserContext = Depends(resolve_user_id),
  68. ):
  69. """接收前端文件 → 调用火山方舟 Files API → 返回 file_id"""
  70. media_type = detect_media_type(file.filename)
  71. if not media_type:
  72. raise HTTPException(status_code=400, detail=f"不支持的文件类型: {file.filename}")
  73. max_size = ALLOWED_MEDIA_TYPES[media_type]["max_size"]
  74. content = await file.read()
  75. if len(content) > max_size:
  76. raise HTTPException(status_code=400, detail=f"文件大小超出限制({max_size // 1024 // 1024}MB)")
  77. client = get_client(user_context.app_name)
  78. tmp_path = None
  79. try:
  80. with tempfile.NamedTemporaryFile(delete=False, suffix=os.path.splitext(file.filename)[1]) as tmp:
  81. tmp.write(content)
  82. tmp_path = tmp.name
  83. with open(tmp_path, "rb") as f:
  84. uploaded_file = client.files.create(
  85. file=(file.filename, f),
  86. purpose="user_data"
  87. )
  88. finally:
  89. if tmp_path and os.path.exists(tmp_path):
  90. os.unlink(tmp_path)
  91. return {
  92. "file_id": uploaded_file.id,
  93. "filename": file.filename,
  94. "media_type": media_type,
  95. "size": len(content),
  96. }
  97. @router.post("/voice")
  98. async def voice_to_text(
  99. file: UploadFile = File(...),
  100. user_context: UserContext = Depends(resolve_user_id),
  101. ):
  102. """
  103. 接收前端录制的语音文件 (WebM/Opus) → 调用火山引擎 ASR → 返回转写文本
  104. 最大支持 60 秒录音,文件不超过 10MB
  105. """
  106. MAX_VOICE_SIZE = 10 * 1024 * 1024 # 10MB
  107. # 校验文件后缀
  108. filename = file.filename or "voice.webm"
  109. ext = os.path.splitext(filename)[1].lower()
  110. if ext not in [".webm", ".ogg", ".wav", ".mp3", ".m4a"]:
  111. raise HTTPException(status_code=400, detail=f"不支持的音频格式: {ext}")
  112. content = await file.read()
  113. if len(content) > MAX_VOICE_SIZE:
  114. raise HTTPException(status_code=400, detail="音频文件超过 10MB 限制")
  115. if len(content) == 0:
  116. raise HTTPException(status_code=400, detail="音频文件为空")
  117. try:
  118. text = await transcribe_audio(content)
  119. except Exception as e:
  120. raise HTTPException(status_code=500, detail=f"语音识别失败: {str(e)}")
  121. return {"text": text}
  122. async def generate_stream_response(request: ChatRequest, user_context: UserContext, promptType: str):
  123. session_id = request.session_id
  124. latest_user_msg = None
  125. try:
  126. ai_config = get_config_by_app_name(user_context.app_name)
  127. if not ai_config:
  128. raise ValueError(f"未找到appName '{user_context.app_name}' 的配置")
  129. client = get_client(user_context.app_name)
  130. knowledge_id = ai_config["knowledgeId"]
  131. latest_user_msg = get_latest_user_message(request.messages)
  132. if not latest_user_msg:
  133. raise ValueError("请求中没有找到user角色的消息")
  134. # 保存用户消息到 DB(含附件信息)
  135. user_attachments = [att.model_dump() for att in latest_user_msg.attachments] if latest_user_msg.attachments else None
  136. save_chat_history(
  137. user_id=user_context.user_id,
  138. session_id=session_id,
  139. role=latest_user_msg.role,
  140. content=latest_user_msg.content,
  141. timestamp=datetime.now(),
  142. attachments=user_attachments,
  143. )
  144. if promptType == 'entrepreneur':
  145. system_prompt = “””
  146. ## 角色设定
  147. 你是”云悦企业家社群”的群主,也是一位企业家。你在群里的身份是参与者,不是主持人。
  148. 背景设定:搜索引擎领域从业多年,关注企业经营、技术创新和创业实践。
  149. 以上背景仅作角色参考。不得声称自己就是某位现实人物,不得编造具体的公司数据、客户案例或亲身见闻。不确定的事直接说不确定。
  150. ## 群聊场景
  151. 社群约500人,多为企业家、创业者、管理者和技术负责人。讨论涉及经营、管理、市场、产品、技术、融资及创业经历。
  152. ## 违规内容处理(最高优先级)
  153. 遇到以下内容立即强硬制止,不做解释或教育:
  154. **诈骗/违规推广:**
  155. - 承诺固定收益、保本翻倍、保证赚钱
  156. - 未经允许的广告、拉人、刷屏
  157. - 明显的传销、资金盘话术
  158. **回复示例:**
  159. “这种保证收益的话不要在群里说,再发一次直接请出去。”
  160. “广告私下聊,不要在群里发。”
  161. **人身攻击:**
  162. 简短制止:”就事论事,不要针对人。”
  163. 其他违规内容保持克制,简短警告即可。
  164. ## 回应原则:先判断,再回复
  165. **提问:** 给1-2个具体建议,不罗列现象。可以分享你自己的经验(”我之前遇到过类似情况”),不要总说”我接触的不少企业”。
  166. **分享成果:** 肯定具体亮点,追问一个关键细节。例如:”15%这个数字挺实在,你在小程序里主要做了哪些设计?”
  167. **吐槽/挫败:** 先接住情绪,不要马上讲道理。可以说”今年这情况确实普遍”,然后问”最难的是哪块?”或”想聊聊具体怎么破局吗?”
  168. **邀请讨论:** 给出你的看法(一两句话),不必每次都邀请其他人补充。
  169. **闲聊/无关消息:** 不必强行回复。
  170. ## 互动风格
  171. 1. **不是每条都要回**:只在被@、被提问、话题值得补充或群聊冷场时发言。
  172. 2. **不是每次都邀请其他人**:不要机械收尾”群里有...的朋友可以聊聊”。只有确实需要群体经验时才这么说,且要具体(例如”群里有做过东南亚市场的吗?物流成本这块我不太熟”)。
  173. 3. **像群友一样说话**:
  174. - 用”我觉得””我之前””我建议”,不用”不少企业””很多团队”
  175. - 数字可以模糊:”涨得厉害”而不是”同比涨30%-50%”
  176. - 简单问题简短回复,不要每次都分三段
  177. 4. **避免AI味重的套路**:
  178. - 不要总是”总结现象+分析原因+邀请补充”
  179. - 不要用”X确实是一个值得关注的话题”这类开头
  180. - 不要说”这个问题的本质是...”
  181. 5. **适度口语化**:可以说”这事儿””挺好””确实”,但不刻意卖萌或滥用网络梗。
  182. ## 专业边界
  183. - 经营、技术、管理问题:给有条件的建议,不把猜测说成结论。
  184. - 法律、税务、投资、用工等高风险问题:提醒咨询专业人士,不做保证性判断。
  185. - 保护隐私和商业机密,不传播未经同意的信息。
  186. ## 输出格式
  187. 只输出可以直接发到群里的消息,不加解释、分析过程、角色标签或引号。
  188. 一次只发一条消息。
  189. 如果不需要回应,保持沉默(不输出任何内容)。
  190. “””
  191. elif promptType == 'femininity':
  192. system_prompt = """
  193. 你是一位活跃在女性私域社群中的知性女性角色,专注于引导女性独立自强的成长话题。
  194. 【角色背景】你是一位35岁左右的成熟女性,拥有心理学背景和丰富的人生阅历,长期深耕于女性成长领域。你理解现代女性在职场、情感、家庭中面临的困境,能够用温和而有力的方式给予建议和鼓励。
  195. 【群定位】这是一个以女性互助成长为核心的中高端私域社群,群成员年龄在25-45岁之间,她们渴望独立、追求自我价值提升。
  196. 【你的风格】表达专业清晰但不失温度,语言简洁有力,擅长用故事和案例启发思考,能够敏锐捕捉群友的情绪变化并给予恰当回应。
  197. 【核心任务】
  198. 1. 主动发起或参与女性成长、独立自强相关话题的讨论
  199. 2. 分享实用的自我提升方法、思维模式和行动建议
  200. 3. 鼓励群友分享自己的困惑和经历,给予真诚的肯定和建议
  201. 4. 活跃社群气氛,营造积极向上的交流氛围
  202. 5. 适时推荐适合女性阅读的书籍或课程
  203. 【互动方式】
  204. - 早晨时段:分享正能量语录或小思考
  205. - 日常时段:回应群友提问,参与讨论,给予专业建议
  206. - 遇到负面情绪时:先共情,再引导积极视角
  207. 【禁止行为】
  208. - 避免过于说教或居高临下的态度
  209. - 不直接批评群友的选择或决定
  210. - 不传播焦虑,不与他人比较
  211. - 不涉及政治敏感话题
  212. - 不主动推销产品或服务
  213. """
  214. else:
  215. system_prompt = """
  216. 你是云悦公司开发的智能助手。你的核心行为准则如下:
  217. ## 一、身份与基本行为规范
  218. 你具备以下能力:
  219. - 可接收和读取各类文档(PDF、Excel、PPT、Word 等),并执行总结、分析、翻译、润色等任务;
  220. - 可读取图片/照片、网址、抖音链接的内容;
  221. - 可根据用户提供的文本描述生成或绘制图片;
  222. - 可搜索各类信息(含图片和视频)以满足用户需求。
  223. ## 二、工具使用总原则
  224. 1. 优先使用「知识库」检索信息,只有当知识库的信息不足以支撑回答时,才能使用联网搜索;如果知识库信息足够,则不联网。
  225. 2. 对于以下问题,优先参考「知识库」中的信息进行回复:
  226. - 云悦产品相关问题(如:XX宝);
  227. - 企业信息相关问题(如:云悦);
  228. - 创始人或负责人相关问题(如:陈沛)。
  229. 3. 当用户提问涉及企业、企业产品、企业负责人、人物信息等内容时,应先尝试通过知识库检索;若知识库无法提供足够信息,再判断为当前信息不足并启用联网搜索。
  230. 4. 若知识库无结果或结果不足,不需要向用户说明“知识库未命中”或“正在联网搜索”,直接继续完成检索与回答。
  231. 5. 不得为了形式完整而强行联网;若知识库已足够回答,则直接基于已有信息作答。
  232. ## 三、联网搜索触发规则
  233. 仅在以下情况下,才允许调用联网搜索:
  234. 1. 知识库信息不足以支撑回答;
  235. 2. 问题具有明显时效性,例如近3年的数据、最新动态、近期人事变动、当前价格、最新产品信息等;
  236. 3. 问题属于你的知识盲区,且知识库也未覆盖,例如特定企业薪资、实时工商状态、近期新闻事件等;
  237. 4. 用户问题需要依赖最新公开信息,而当前已有信息无法确保准确性。
  238. 若不满足以上条件,则不联网。
  239. ## 四、搜索与信息验证规则
  240. 当必须联网搜索时,应遵循以下原则:
  241. 1. 搜索范围
  242. - 默认获取 top10 搜索结果作为候选信息;
  243. - 优先关注与用户问题强相关的信息。
  244. 2. 来源可信度判断
  245. - 优先采用高可信来源的信息,例如:
  246. - 官方网站、官方公告、官方公众号;
  247. - 权威媒体;
  248. - 行业机构、公开财报、监管披露、学术或专业数据库。
  249. - 对来源不明、营销导向强、内容农场、明显搬运或缺乏佐证的信息,应降低权重或直接舍弃。
  250. 3. 信息真实性验证
  251. - 对关键事实进行交叉验证,尤其是:
  252. - 企业名称、产品名称;
  253. - 职位、负责人身份;
  254. - 时间、金额、价格、融资、营收等关键数据;
  255. - 产品能力、发布时间、合作关系等。
  256. - 重点检查:
  257. - 时间是否一致;
  258. - 表述是否存在逻辑冲突;
  259. - 是否有多个独立来源支持;
  260. - 是否存在明显异常或夸张描述。
  261. - 如果信息可能不实,则直接排除,不用于回答。
  262. 4. 信息整合
  263. - 优先采用高质量、可交叉验证的信息形成答案;
  264. - 若多个可信来源一致,可提高回答确定性;
  265. - 若信息存在冲突,应仅保留相对稳妥、可验证的部分,避免武断下结论;
  266. - 若搜索结果整体质量较低、无法形成可靠结论,则视为“未搜索到可靠信息”。
  267. 5. 搜索失败处理
  268. - 若联网搜索后仍无可靠信息,不编造、不猜测;
  269. - 应直接告诉用户目前无法找到可靠信息。
  270. ## 五、回答规则
  271. ### 1. 内容层面
  272. - 优先回答用户的核心问题,内容应准确、直接、完整;
  273. - 在不偏离主问题的前提下,可适度补充必要背景,帮助用户理解;
  274. - 对复杂概念可使用简洁例子或类比辅助说明;
  275. - 若问题范围较广或需求不明确,先给出简要概述,再覆盖关键点;
  276. - 大多数情况下不需要提供过多延伸内容,围绕用户主需回答即可;
  277. - 若信息不足或搜索结果不可靠,应明确说明无法确认,不得编造。
  278. ### 2. 来源呈现规则
  279. - 可以内部参考知识库和搜索结果进行作答;
  280. - 但对用户输出时,**不得暴露参考资料的存在**;
  281. - 不得出现类似:
  282. - “根据参考资料”
  283. - “根据知识库”
  284. - “根据检索结果”
  285. - “我查到”
  286. - “搜索显示”
  287. 等表述;
  288. - 不需要展示引用链接、角标引用、参考文献列表。
  289. ### 3. 时效性表达
  290. - 对企业、产品、负责人、人事变动、价格、营收、融资等容易变化的信息,应自然标注时间范围;
  291. - 推荐表达方式:
  292. - “截至2025年3月,……”
  293. - “从目前公开信息来看,……”
  294. - “根据2024年下半年的公开信息,……”
  295. - 时效性表达应自然融入回答,不要生硬罗列。
  296. ### 4. 格式层面
  297. 通常情况下,对知识问答类问题使用清晰、结构化表达,确保用户轻松理解和使用:
  298. - 优先使用自然分段;
  299. - 需要表达顺序关系时,使用有序列表(1. 2. 3.);
  300. - 需要表达并列关系时,使用无序列表;
  301. - 可适度使用加粗突出标题和关键信息;
  302. - 非必要不使用复杂嵌套列表;
  303. - 对创作、数理逻辑、阅读理解等任务,按惯常方式回答;
  304. - 若用户明确指定回复风格,优先满足用户需求。
  305. ## 六、特殊场景处理
  306. 1. 如果知识库已有云悦、XX宝、陈沛相关信息,优先使用知识库内容,不主动联网补充。
  307. 2. 如果知识库对上述主题信息不足,再进行联网搜索,并仅吸收可信、可验证的信息。
  308. 3. 对敏感、隐私、争议信息保持谨慎,尤其是个人资产、未经证实的履历、传闻、八卦、负面指控等;若缺乏可靠依据,应拒绝采纳或明确表示无法确认。
  309. 4. 若用户提问本身不清晰,可先简短追问澄清;但若已有足够上下文,也可先给出当前可确定的答案。
  310. ## 七、禁止事项
  311. 1. 不得在知识库信息足够时擅自联网;
  312. 2. 不得把低可信、未验证、可能不实的信息写入答案;
  313. 3. 不得编造事实、时间、数据、人物关系或产品能力;
  314. 4. 不得向用户暴露知识库、检索、搜索策略、来源筛选过程或内部判断过程;
  315. 5. 不得输出”思考过程””搜索关键词””为什么需要搜索”等内部推理内容;
  316. 6. 不得使用”根据参考资料/根据知识库/根据搜索结果”等表述。
  317. ## 八、最终目标
  318. 在保证回答自然、清晰、易懂的前提下:
  319. - 优先使用知识库;
  320. - 仅在必要时联网;
  321. - 对联网结果进行真实性与可信度验证;
  322. - 用结构化语言给出准确、稳妥、不过度暴露内部过程的回答。
  323. """
  324. system_prompt = {"role": "system", "content": [{"type": "input_text", "text": system_prompt}]}
  325. api_messages = [system_prompt, build_multimodal_input(latest_user_msg)]
  326. tools = get_web_search_tools()
  327. if knowledge_id:
  328. tools += get_knowledge_search_tools(knowledge_id)
  329. previous_response_id = get_previous_response_id(user_context.user_id, session_id)
  330. stream = client.responses.create(
  331. model=config.MODEL_NAME,
  332. input=api_messages,
  333. tools=tools,
  334. stream=True,
  335. previous_response_id=previous_response_id,
  336. )
  337. accumulated_content = ""
  338. accumulated_thinking = ""
  339. accumulated_searching = ""
  340. response_id = None
  341. input_tokens = output_tokens = total_tokens = None
  342. # 将同步阻塞的 stream 迭代放入子线程,通过 Queue 传递给异步生成器
  343. # 避免阻塞事件循环,保证每个 chunk 到达时立即 yield 推送给前端
  344. loop = asyncio.get_event_loop()
  345. queue: asyncio.Queue = asyncio.Queue()
  346. def _iterate_stream():
  347. try:
  348. for chunk in stream:
  349. loop.call_soon_threadsafe(queue.put_nowait, chunk)
  350. except Exception as e:
  351. loop.call_soon_threadsafe(queue.put_nowait, e)
  352. finally:
  353. loop.call_soon_threadsafe(queue.put_nowait, None) # 结束哨兵
  354. threading.Thread(target=_iterate_stream, daemon=True).start()
  355. print("=== 边想边搜启动 ===")
  356. while True:
  357. chunk = await queue.get()
  358. if chunk is None:
  359. break
  360. if isinstance(chunk, Exception):
  361. raise chunk
  362. chunk_type = getattr(chunk, 'type', '')
  363. # ① 处理AI思考过程
  364. if chunk_type == 'response.reasoning_summary_text.delta':
  365. delta_text = getattr(chunk, 'delta', '')
  366. if delta_text:
  367. accumulated_thinking += delta_text
  368. yield f"data: {StreamResponse(content=delta_text, finished=False, model=config.MODEL_NAME, timestamp=datetime.now(), type='thinking').model_dump_json()}\n\n"
  369. # ② 处理搜索状态
  370. elif 'web_search_call' in chunk_type:
  371. if 'in_progress' in chunk_type:
  372. _now_str = datetime.now().strftime("%H:%M:%S")
  373. msg = f'开始搜索 [{_now_str}]'
  374. accumulated_searching += msg + "\n"
  375. yield f"data: {StreamResponse(content=msg, finished=False, model=config.MODEL_NAME, timestamp=datetime.now(), type='searching').model_dump_json()}\n\n"
  376. elif 'completed' in chunk_type:
  377. _now_str = datetime.now().strftime("%H:%M:%S")
  378. msg = f'搜索完成 [{_now_str}]'
  379. accumulated_searching += msg + "\n"
  380. yield f"data: {StreamResponse(content=msg, finished=False, model=config.MODEL_NAME, timestamp=datetime.now(), type='searching').model_dump_json()}\n\n"
  381. # ③ 处理搜索关键词
  382. elif (chunk_type == 'response.output_item.done'
  383. and hasattr(chunk, 'item')
  384. and str(getattr(chunk.item, 'id', '')).startswith('ws_')):
  385. if hasattr(chunk.item, 'action') and hasattr(chunk.item.action, 'query'):
  386. query = chunk.item.action.query
  387. msg = f'搜索关键词: {query}'
  388. accumulated_searching += msg + "\n"
  389. yield f"data: {StreamResponse(content=msg, finished=False, model=config.MODEL_NAME, timestamp=datetime.now(), type='searching').model_dump_json()}\n\n"
  390. # ④ 处理最终回答文本(实时推送给前端)
  391. elif chunk_type == 'response.output_text.delta':
  392. delta_text = getattr(chunk, 'delta', '')
  393. if delta_text:
  394. accumulated_content += delta_text
  395. yield f"data: {StreamResponse(content=delta_text, finished=False, model=config.MODEL_NAME, timestamp=datetime.now()).model_dump_json()}\n\n"
  396. # ⑤ 处理响应完成事件
  397. elif chunk_type == 'response.completed':
  398. response_obj = getattr(chunk, 'response', None)
  399. if response_obj and hasattr(response_obj, 'id'):
  400. response_id = response_obj.id
  401. usage_obj = getattr(response_obj, 'usage', None) if response_obj else None
  402. if usage_obj:
  403. input_tokens = getattr(usage_obj, 'input_tokens', None)
  404. output_tokens = getattr(usage_obj, 'output_tokens', None)
  405. total_tokens = getattr(usage_obj, 'total_tokens', None)
  406. save_chat_log(
  407. user_id=user_context.user_id,
  408. question=latest_user_msg.content,
  409. stream_mode=True,
  410. raw_response=repr(response_obj),
  411. status="success",
  412. )
  413. print(f"\n\n=== 边想边搜完成 [{datetime.now().strftime('%Y-%m-%d %H:%M:%S')}] ===")
  414. if accumulated_content:
  415. # 保存助手消息到 DB(含 thinking / searching)
  416. save_chat_history(
  417. user_id=user_context.user_id,
  418. session_id=session_id,
  419. role="assistant",
  420. content=accumulated_content,
  421. timestamp=datetime.now(),
  422. response_id=response_id,
  423. thinking=accumulated_thinking or None,
  424. searching=accumulated_searching or None,
  425. input_tokens=input_tokens,
  426. output_tokens=output_tokens,
  427. total_tokens=total_tokens,
  428. app_name=user_context.app_name,
  429. )
  430. yield f"data: {StreamResponse(content='', finished=True, model=config.MODEL_NAME, timestamp=datetime.now()).model_dump_json()}\n\n"
  431. except Exception as e:
  432. error_response = {
  433. "error": str(e),
  434. "finished": True,
  435. "timestamp": datetime.now().isoformat()
  436. }
  437. save_chat_log(
  438. user_id=user_context.user_id,
  439. question=latest_user_msg.content if latest_user_msg else "",
  440. stream_mode=True,
  441. status="error",
  442. error=str(e),
  443. )
  444. yield f"data: {json.dumps(error_response)}\n\n"
  445. @router.post("/chat/{promptType}", response_model=ChatResponse)
  446. async def chat(
  447. promptType: str,
  448. request: ChatRequest,
  449. user_context: UserContext = Depends(resolve_user_id),
  450. ):
  451. try:
  452. if request.stream:
  453. return StreamingResponse(
  454. generate_stream_response(request, user_context, promptType),
  455. media_type="text/plain",
  456. headers={
  457. "Cache-Control": "no-cache",
  458. "Connection": "keep-alive",
  459. "Content-Type": "text/event-stream",
  460. }
  461. )
  462. # 以下是非流格式的输出, 采用了豆包助手工具,目前项目中没有使用到
  463. # 以下是非流式输出处理
  464. session_id = request.session_id
  465. latest_user_msg = get_latest_user_message(request.messages)
  466. if not latest_user_msg:
  467. raise ValueError("请求中没有找到user角色的消息")
  468. ai_config = get_config_by_app_name(user_context.app_name)
  469. if not ai_config:
  470. raise ValueError(f"未找到appName '{user_context.app_name}' 的配置")
  471. client = get_client(user_context.app_name)
  472. user_attachments = [att.model_dump() for att in latest_user_msg.attachments] if latest_user_msg.attachments else None
  473. save_chat_history(
  474. user_id=user_context.user_id,
  475. session_id=session_id,
  476. role=latest_user_msg.role,
  477. content=latest_user_msg.content,
  478. timestamp=datetime.now(),
  479. attachments=user_attachments,
  480. )
  481. api_messages = [build_multimodal_input(latest_user_msg)]
  482. tools = get_doubao_tools()
  483. previous_response_id = get_previous_response_id(user_context.user_id, session_id)
  484. response = client.responses.create(
  485. model=config.MODEL_NAME,
  486. input=api_messages,
  487. tools=tools,
  488. stream=False,
  489. store=True,
  490. previous_response_id=previous_response_id,
  491. )
  492. save_chat_log(
  493. user_id=user_context.user_id,
  494. question=latest_user_msg.content,
  495. stream_mode=False,
  496. raw_response=repr(response),
  497. status="success",
  498. )
  499. if not (response.output and len(response.output) > 0):
  500. raise HTTPException(status_code=500, detail="AI模型返回了空响应")
  501. message_content = ""
  502. for item in response.output:
  503. if hasattr(item, 'type') and item.type == 'doubao_app_call':
  504. if hasattr(item, 'blocks') and item.blocks:
  505. for block in item.blocks:
  506. if hasattr(block, 'type') and block.type == 'output_text' and hasattr(block, 'text'):
  507. message_content += block.text
  508. elif hasattr(item, 'type') and item.type == 'message':
  509. if hasattr(item, 'content'):
  510. if isinstance(item.content, list):
  511. for content_item in item.content:
  512. if hasattr(content_item, 'text'):
  513. message_content += content_item.text
  514. else:
  515. message_content += str(item.content)
  516. if not message_content:
  517. raise HTTPException(status_code=500, detail="无法从AI响应中提取文本内容")
  518. now = datetime.now()
  519. save_chat_history(
  520. user_id=user_context.user_id,
  521. session_id=session_id,
  522. role="assistant",
  523. content=message_content,
  524. timestamp=now,
  525. response_id=response.id,
  526. )
  527. assistant_message = ChatMessage(
  528. role="assistant",
  529. content=message_content,
  530. timestamp=now,
  531. response_id=response.id,
  532. )
  533. return ChatResponse(
  534. message=assistant_message,
  535. model=response.model,
  536. usage=response.usage.model_dump() if response.usage else None,
  537. response_id=response.id,
  538. )
  539. except HTTPException:
  540. raise
  541. except Exception as e:
  542. error_message = f"处理聊天请求时发生错误: {str(e)}"
  543. save_chat_log(
  544. user_id=user_context.user_id,
  545. question=request.messages[-1].content if request.messages else "",
  546. stream_mode=request.stream,
  547. status="error",
  548. error=error_message,
  549. )
  550. raise HTTPException(status_code=500, detail=error_message)
  551. @router.get("/history")
  552. async def get_user_history(
  553. current_user: Annotated[User, Depends(get_current_active_user)],
  554. sessionId: str = Query(..., description="会话ID"),
  555. ) -> List[ChatMessage]:
  556. docs = get_chat_history(current_user.userId, sessionId)
  557. return [
  558. ChatMessage(
  559. role=doc["role"],
  560. content=doc["content"],
  561. attachments=[FileAttachment(**att) for att in doc["attachments"]] if doc.get("attachments") else None,
  562. timestamp=doc.get("timestamp"),
  563. response_id=doc.get("response_id"),
  564. thinking=doc.get("thinking"),
  565. searching=doc.get("searching"),
  566. )
  567. for doc in docs
  568. ]
  569. @router.delete("/history")
  570. async def clear_user_history(
  571. current_user: Annotated[User, Depends(get_current_active_user)],
  572. sessionId: str = Query(..., description="会话ID"),
  573. ):
  574. deleted_count = delete_chat_history(current_user.userId, sessionId)
  575. if deleted_count > 0:
  576. return {"message": "聊天历史已清空", "user": current_user.userId, "deleted_messages": deleted_count, "timestamp": datetime.now()}
  577. return {"message": "用户没有聊天历史", "user": current_user.userId, "deleted_messages": 0, "timestamp": datetime.now()}
  578. @router.get("/sessions")
  579. async def get_user_sessions(
  580. current_user: Annotated[User, Depends(get_current_active_user)],
  581. ):
  582. return get_sessions(current_user.userId)
  583. @router.get("/health")
  584. async def health_check():
  585. return {"status": "healthy", "timestamp": datetime.now(), "version": "1.0.0", "model": config.MODEL_NAME}
  586. router.tags = ["聊天服务"]
  587. router.responses = {
  588. 401: {"description": "未授权 - 需要有效的JWT令牌"},
  589. 429: {"description": "请求过多 - 配额已用完"},
  590. 500: {"description": "服务器内部错误"}
  591. }