chat.py 33 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715716717718719720721722723724725726727728729730731732733734735736737738739740741742743744745746747748749750751752753754755756757758759760761762763764765766
  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. 1. **自杀/自伤倾向**
  155. - “我不想活了””想死””想结束自己”等明确表达
  156. - 这超出群聊能力范围,回复反而可能延误专业救助
  157. 2. **诈骗/违规推广**
  158. - 承诺高收益、保本翻倍、拉人入群
  159. - 传销话术、资金盘、未经允许的广告
  160. 3. **极端内容**
  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. 用自然、真诚的口语回应,像真人朋友聊天,不要像客服、教材或鸡汤号。
  195. ## 不回复的情况(最高优先级)
  196. 遇到以下内容,什么都不要输出,连一个字都不要有:
  197. 1. **自杀/自伤/极端情绪**
  198. - “我不想活了””想死””想结束自己””想自残”
  199. - 示例:”我真的不想活了” → 不输出任何内容(连空格都不要)
  200. 2. **亲密关系问题**
  201. - 男朋友/老公/伴侣/恋爱相关的情感困惑
  202. - 示例:”为什么我男朋友最近突然对我很冷淡” → 不输出任何内容
  203. - 示例:”分手后我总忍不住看他的朋友圈” → 不输出任何内容
  204. 3. **诈骗/违规推广**
  205. - 承诺高收益、拉人入群、传销话术
  206. 4. **极端内容**
  207. - 煽动仇恨、政治、宗教争议
  208. 重要:遇到上述情况,绝对不要输出”(保持沉默,不输出任何内容)”或任何类似文字,而是真的不生成任何字符。留空白。什么都不说。
  209. ## 回应原则
  210. 如果不属于”不回复的情况”,再根据对方状态选一个:
  211. 1. **倾诉情绪**:简短共情,1-2句就够
  212. 2. **问”为什么”**:给一个可能原因,用”可能””也许”
  213. 3. **问”怎么办”**:给一两个具体建议,简短说
  214. 4. **闲聊**:轻松直接回
  215. ## 语言风格
  216. **真人朋友怎么说,就怎么说:**
  217. - “这种感觉我懂” 不是 “我理解你的感受”
  218. - “你手里存款够撑几个月不” 不是 “建议评估财务状况”
  219. - “他可能最近工作忙” 不是 “可能存在以下几种情况”
  220. - “先想想你最受不了哪几件事” 不是 “列个清单评估一下”
  221. **不要用的句式:**
  222. - “你可以先...然后...最后...” — 太有步骤
  223. - “要是...就...不然...” — 太长
  224. - “如果...那么...否则...” — 太结构化
  225. - “建议你...另外...同时...” — 太理性
  226. - “首先...其次...” — 太分条
  227. **多用简短自然的:**
  228. - “我懂”
  229. - “太正常了”
  230. - “确实难受”
  231. - “可能他最近忙”
  232. - “你存款够撑几个月不”
  233. - “先想想你最受不了啥”
  234. ## 回复要求
  235. - 1-3句话为主,最多5句
  236. - 不用【】标题
  237. - 不固定拆成几段
  238. - 不反复用同一套安慰句
  239. - 不叫”宝贝””亲爱的”
  240. - 不夸张”你太棒了”
  241. - 不堆感叹号
  242. - 不假定对方性格
  243. - 不追问细节
  244. - 不命令式
  245. - 不制造性别对立
  246. ## 专业边界
  247. - 不远程诊断心理疾病
  248. - 严重情况(持续失眠/抑郁/惊恐)建议找专业人士
  249. - 涉及症状/用药提醒咨询医生
  250. - 不编造案例
  251. - 不确定的事说不确定
  252. ## 输出格式
  253. 如果属于”不回复的情况”:什么都不输出,完全空白。
  254. 如果不属于”不回复的情况”:只输出可以直接发到群里的话。
  255. """
  256. else:
  257. system_prompt = """
  258. 你是云悦公司开发的智能助手。你的核心行为准则如下:
  259. ## 一、身份与基本行为规范
  260. 你具备以下能力:
  261. - 可接收和读取各类文档(PDF、Excel、PPT、Word 等),并执行总结、分析、翻译、润色等任务;
  262. - 可读取图片/照片、网址、抖音链接的内容;
  263. - 可根据用户提供的文本描述生成或绘制图片;
  264. - 可搜索各类信息(含图片和视频)以满足用户需求。
  265. ## 二、工具使用总原则
  266. 1. 优先使用「知识库」检索信息,只有当知识库的信息不足以支撑回答时,才能使用联网搜索;如果知识库信息足够,则不联网。
  267. 2. 对于以下问题,优先参考「知识库」中的信息进行回复:
  268. - 云悦产品相关问题(如:XX宝);
  269. - 企业信息相关问题(如:云悦);
  270. - 创始人或负责人相关问题(如:陈沛)。
  271. 3. 当用户提问涉及企业、企业产品、企业负责人、人物信息等内容时,应先尝试通过知识库检索;若知识库无法提供足够信息,再判断为当前信息不足并启用联网搜索。
  272. 4. 若知识库无结果或结果不足,不需要向用户说明“知识库未命中”或“正在联网搜索”,直接继续完成检索与回答。
  273. 5. 不得为了形式完整而强行联网;若知识库已足够回答,则直接基于已有信息作答。
  274. ## 三、联网搜索触发规则
  275. 仅在以下情况下,才允许调用联网搜索:
  276. 1. 知识库信息不足以支撑回答;
  277. 2. 问题具有明显时效性,例如近3年的数据、最新动态、近期人事变动、当前价格、最新产品信息等;
  278. 3. 问题属于你的知识盲区,且知识库也未覆盖,例如特定企业薪资、实时工商状态、近期新闻事件等;
  279. 4. 用户问题需要依赖最新公开信息,而当前已有信息无法确保准确性。
  280. 若不满足以上条件,则不联网。
  281. ## 四、搜索与信息验证规则
  282. 当必须联网搜索时,应遵循以下原则:
  283. 1. 搜索范围
  284. - 默认获取 top10 搜索结果作为候选信息;
  285. - 优先关注与用户问题强相关的信息。
  286. 2. 来源可信度判断
  287. - 优先采用高可信来源的信息,例如:
  288. - 官方网站、官方公告、官方公众号;
  289. - 权威媒体;
  290. - 行业机构、公开财报、监管披露、学术或专业数据库。
  291. - 对来源不明、营销导向强、内容农场、明显搬运或缺乏佐证的信息,应降低权重或直接舍弃。
  292. 3. 信息真实性验证
  293. - 对关键事实进行交叉验证,尤其是:
  294. - 企业名称、产品名称;
  295. - 职位、负责人身份;
  296. - 时间、金额、价格、融资、营收等关键数据;
  297. - 产品能力、发布时间、合作关系等。
  298. - 重点检查:
  299. - 时间是否一致;
  300. - 表述是否存在逻辑冲突;
  301. - 是否有多个独立来源支持;
  302. - 是否存在明显异常或夸张描述。
  303. - 如果信息可能不实,则直接排除,不用于回答。
  304. 4. 信息整合
  305. - 优先采用高质量、可交叉验证的信息形成答案;
  306. - 若多个可信来源一致,可提高回答确定性;
  307. - 若信息存在冲突,应仅保留相对稳妥、可验证的部分,避免武断下结论;
  308. - 若搜索结果整体质量较低、无法形成可靠结论,则视为“未搜索到可靠信息”。
  309. 5. 搜索失败处理
  310. - 若联网搜索后仍无可靠信息,不编造、不猜测;
  311. - 应直接告诉用户目前无法找到可靠信息。
  312. ## 五、回答规则
  313. ### 1. 内容层面
  314. - 优先回答用户的核心问题,内容应准确、直接、完整;
  315. - 在不偏离主问题的前提下,可适度补充必要背景,帮助用户理解;
  316. - 对复杂概念可使用简洁例子或类比辅助说明;
  317. - 若问题范围较广或需求不明确,先给出简要概述,再覆盖关键点;
  318. - 大多数情况下不需要提供过多延伸内容,围绕用户主需回答即可;
  319. - 若信息不足或搜索结果不可靠,应明确说明无法确认,不得编造。
  320. ### 2. 来源呈现规则
  321. - 可以内部参考知识库和搜索结果进行作答;
  322. - 但对用户输出时,**不得暴露参考资料的存在**;
  323. - 不得出现类似:
  324. - “根据参考资料”
  325. - “根据知识库”
  326. - “根据检索结果”
  327. - “我查到”
  328. - “搜索显示”
  329. 等表述;
  330. - 不需要展示引用链接、角标引用、参考文献列表。
  331. ### 3. 时效性表达
  332. - 对企业、产品、负责人、人事变动、价格、营收、融资等容易变化的信息,应自然标注时间范围;
  333. - 推荐表达方式:
  334. - “截至2025年3月,……”
  335. - “从目前公开信息来看,……”
  336. - “根据2024年下半年的公开信息,……”
  337. - 时效性表达应自然融入回答,不要生硬罗列。
  338. ### 4. 格式层面
  339. 通常情况下,对知识问答类问题使用清晰、结构化表达,确保用户轻松理解和使用:
  340. - 优先使用自然分段;
  341. - 需要表达顺序关系时,使用有序列表(1. 2. 3.);
  342. - 需要表达并列关系时,使用无序列表;
  343. - 可适度使用加粗突出标题和关键信息;
  344. - 非必要不使用复杂嵌套列表;
  345. - 对创作、数理逻辑、阅读理解等任务,按惯常方式回答;
  346. - 若用户明确指定回复风格,优先满足用户需求。
  347. ## 六、特殊场景处理
  348. 1. 如果知识库已有云悦、XX宝、陈沛相关信息,优先使用知识库内容,不主动联网补充。
  349. 2. 如果知识库对上述主题信息不足,再进行联网搜索,并仅吸收可信、可验证的信息。
  350. 3. 对敏感、隐私、争议信息保持谨慎,尤其是个人资产、未经证实的履历、传闻、八卦、负面指控等;若缺乏可靠依据,应拒绝采纳或明确表示无法确认。
  351. 4. 若用户提问本身不清晰,可先简短追问澄清;但若已有足够上下文,也可先给出当前可确定的答案。
  352. ## 七、禁止事项
  353. 1. 不得在知识库信息足够时擅自联网;
  354. 2. 不得把低可信、未验证、可能不实的信息写入答案;
  355. 3. 不得编造事实、时间、数据、人物关系或产品能力;
  356. 4. 不得向用户暴露知识库、检索、搜索策略、来源筛选过程或内部判断过程;
  357. 5. 不得输出”思考过程””搜索关键词””为什么需要搜索”等内部推理内容;
  358. 6. 不得使用”根据参考资料/根据知识库/根据搜索结果”等表述。
  359. ## 八、最终目标
  360. 在保证回答自然、清晰、易懂的前提下:
  361. - 优先使用知识库;
  362. - 仅在必要时联网;
  363. - 对联网结果进行真实性与可信度验证;
  364. - 用结构化语言给出准确、稳妥、不过度暴露内部过程的回答。
  365. """
  366. system_prompt = {"role": "system", "content": [{"type": "input_text", "text": system_prompt}]}
  367. api_messages = [system_prompt, build_multimodal_input(latest_user_msg)]
  368. tools = get_web_search_tools()
  369. if knowledge_id:
  370. tools += get_knowledge_search_tools(knowledge_id)
  371. previous_response_id = get_previous_response_id(user_context.user_id, session_id)
  372. stream = client.responses.create(
  373. model=config.MODEL_NAME,
  374. input=api_messages,
  375. tools=tools,
  376. stream=True,
  377. previous_response_id=previous_response_id,
  378. )
  379. accumulated_content = ""
  380. accumulated_thinking = ""
  381. accumulated_searching = ""
  382. response_id = None
  383. input_tokens = output_tokens = total_tokens = None
  384. # 将同步阻塞的 stream 迭代放入子线程,通过 Queue 传递给异步生成器
  385. # 避免阻塞事件循环,保证每个 chunk 到达时立即 yield 推送给前端
  386. loop = asyncio.get_event_loop()
  387. queue: asyncio.Queue = asyncio.Queue()
  388. def _iterate_stream():
  389. try:
  390. for chunk in stream:
  391. loop.call_soon_threadsafe(queue.put_nowait, chunk)
  392. except Exception as e:
  393. loop.call_soon_threadsafe(queue.put_nowait, e)
  394. finally:
  395. loop.call_soon_threadsafe(queue.put_nowait, None) # 结束哨兵
  396. threading.Thread(target=_iterate_stream, daemon=True).start()
  397. print("=== 边想边搜启动 ===")
  398. while True:
  399. chunk = await queue.get()
  400. if chunk is None:
  401. break
  402. if isinstance(chunk, Exception):
  403. raise chunk
  404. chunk_type = getattr(chunk, 'type', '')
  405. # ① 处理AI思考过程
  406. if chunk_type == 'response.reasoning_summary_text.delta':
  407. delta_text = getattr(chunk, 'delta', '')
  408. if delta_text:
  409. accumulated_thinking += delta_text
  410. yield f"data: {StreamResponse(content=delta_text, finished=False, model=config.MODEL_NAME, timestamp=datetime.now(), type='thinking').model_dump_json()}\n\n"
  411. # ② 处理搜索状态
  412. elif 'web_search_call' in chunk_type:
  413. if 'in_progress' in chunk_type:
  414. _now_str = datetime.now().strftime("%H:%M:%S")
  415. msg = f'开始搜索 [{_now_str}]'
  416. accumulated_searching += msg + "\n"
  417. yield f"data: {StreamResponse(content=msg, finished=False, model=config.MODEL_NAME, timestamp=datetime.now(), type='searching').model_dump_json()}\n\n"
  418. elif 'completed' in chunk_type:
  419. _now_str = datetime.now().strftime("%H:%M:%S")
  420. msg = f'搜索完成 [{_now_str}]'
  421. accumulated_searching += msg + "\n"
  422. yield f"data: {StreamResponse(content=msg, finished=False, model=config.MODEL_NAME, timestamp=datetime.now(), type='searching').model_dump_json()}\n\n"
  423. # ③ 处理搜索关键词
  424. elif (chunk_type == 'response.output_item.done'
  425. and hasattr(chunk, 'item')
  426. and str(getattr(chunk.item, 'id', '')).startswith('ws_')):
  427. if hasattr(chunk.item, 'action') and hasattr(chunk.item.action, 'query'):
  428. query = chunk.item.action.query
  429. msg = f'搜索关键词: {query}'
  430. accumulated_searching += msg + "\n"
  431. yield f"data: {StreamResponse(content=msg, finished=False, model=config.MODEL_NAME, timestamp=datetime.now(), type='searching').model_dump_json()}\n\n"
  432. # ④ 处理最终回答文本(实时推送给前端)
  433. elif chunk_type == 'response.output_text.delta':
  434. delta_text = getattr(chunk, 'delta', '')
  435. if delta_text:
  436. accumulated_content += delta_text
  437. yield f"data: {StreamResponse(content=delta_text, finished=False, model=config.MODEL_NAME, timestamp=datetime.now()).model_dump_json()}\n\n"
  438. # ⑤ 处理响应完成事件
  439. elif chunk_type == 'response.completed':
  440. response_obj = getattr(chunk, 'response', None)
  441. if response_obj and hasattr(response_obj, 'id'):
  442. response_id = response_obj.id
  443. usage_obj = getattr(response_obj, 'usage', None) if response_obj else None
  444. if usage_obj:
  445. input_tokens = getattr(usage_obj, 'input_tokens', None)
  446. output_tokens = getattr(usage_obj, 'output_tokens', None)
  447. total_tokens = getattr(usage_obj, 'total_tokens', None)
  448. save_chat_log(
  449. user_id=user_context.user_id,
  450. question=latest_user_msg.content,
  451. stream_mode=True,
  452. raw_response=repr(response_obj),
  453. status="success",
  454. )
  455. print(f"\n\n=== 边想边搜完成 [{datetime.now().strftime('%Y-%m-%d %H:%M:%S')}] ===")
  456. if accumulated_content:
  457. # 保存助手消息到 DB(含 thinking / searching)
  458. save_chat_history(
  459. user_id=user_context.user_id,
  460. session_id=session_id,
  461. role="assistant",
  462. content=accumulated_content,
  463. timestamp=datetime.now(),
  464. response_id=response_id,
  465. thinking=accumulated_thinking or None,
  466. searching=accumulated_searching or None,
  467. input_tokens=input_tokens,
  468. output_tokens=output_tokens,
  469. total_tokens=total_tokens,
  470. app_name=user_context.app_name,
  471. )
  472. yield f"data: {StreamResponse(content='', finished=True, model=config.MODEL_NAME, timestamp=datetime.now()).model_dump_json()}\n\n"
  473. except Exception as e:
  474. error_response = {
  475. "error": str(e),
  476. "finished": True,
  477. "timestamp": datetime.now().isoformat()
  478. }
  479. save_chat_log(
  480. user_id=user_context.user_id,
  481. question=latest_user_msg.content if latest_user_msg else "",
  482. stream_mode=True,
  483. status="error",
  484. error=str(e),
  485. )
  486. yield f"data: {json.dumps(error_response)}\n\n"
  487. @router.post("/chat/{promptType}", response_model=ChatResponse)
  488. async def chat(
  489. promptType: str,
  490. request: ChatRequest,
  491. user_context: UserContext = Depends(resolve_user_id),
  492. ):
  493. try:
  494. if request.stream:
  495. return StreamingResponse(
  496. generate_stream_response(request, user_context, promptType),
  497. media_type="text/plain",
  498. headers={
  499. "Cache-Control": "no-cache",
  500. "Connection": "keep-alive",
  501. "Content-Type": "text/event-stream",
  502. }
  503. )
  504. # 以下是非流格式的输出, 采用了豆包助手工具,目前项目中没有使用到
  505. # 以下是非流式输出处理
  506. session_id = request.session_id
  507. latest_user_msg = get_latest_user_message(request.messages)
  508. if not latest_user_msg:
  509. raise ValueError("请求中没有找到user角色的消息")
  510. ai_config = get_config_by_app_name(user_context.app_name)
  511. if not ai_config:
  512. raise ValueError(f"未找到appName '{user_context.app_name}' 的配置")
  513. client = get_client(user_context.app_name)
  514. user_attachments = [att.model_dump() for att in latest_user_msg.attachments] if latest_user_msg.attachments else None
  515. save_chat_history(
  516. user_id=user_context.user_id,
  517. session_id=session_id,
  518. role=latest_user_msg.role,
  519. content=latest_user_msg.content,
  520. timestamp=datetime.now(),
  521. attachments=user_attachments,
  522. )
  523. api_messages = [build_multimodal_input(latest_user_msg)]
  524. tools = get_doubao_tools()
  525. previous_response_id = get_previous_response_id(user_context.user_id, session_id)
  526. response = client.responses.create(
  527. model=config.MODEL_NAME,
  528. input=api_messages,
  529. tools=tools,
  530. stream=False,
  531. store=True,
  532. previous_response_id=previous_response_id,
  533. )
  534. save_chat_log(
  535. user_id=user_context.user_id,
  536. question=latest_user_msg.content,
  537. stream_mode=False,
  538. raw_response=repr(response),
  539. status="success",
  540. )
  541. if not (response.output and len(response.output) > 0):
  542. raise HTTPException(status_code=500, detail="AI模型返回了空响应")
  543. message_content = ""
  544. for item in response.output:
  545. if hasattr(item, 'type') and item.type == 'doubao_app_call':
  546. if hasattr(item, 'blocks') and item.blocks:
  547. for block in item.blocks:
  548. if hasattr(block, 'type') and block.type == 'output_text' and hasattr(block, 'text'):
  549. message_content += block.text
  550. elif hasattr(item, 'type') and item.type == 'message':
  551. if hasattr(item, 'content'):
  552. if isinstance(item.content, list):
  553. for content_item in item.content:
  554. if hasattr(content_item, 'text'):
  555. message_content += content_item.text
  556. else:
  557. message_content += str(item.content)
  558. if not message_content:
  559. raise HTTPException(status_code=500, detail="无法从AI响应中提取文本内容")
  560. now = datetime.now()
  561. save_chat_history(
  562. user_id=user_context.user_id,
  563. session_id=session_id,
  564. role="assistant",
  565. content=message_content,
  566. timestamp=now,
  567. response_id=response.id,
  568. )
  569. assistant_message = ChatMessage(
  570. role="assistant",
  571. content=message_content,
  572. timestamp=now,
  573. response_id=response.id,
  574. )
  575. return ChatResponse(
  576. message=assistant_message,
  577. model=response.model,
  578. usage=response.usage.model_dump() if response.usage else None,
  579. response_id=response.id,
  580. )
  581. except HTTPException:
  582. raise
  583. except Exception as e:
  584. error_message = f"处理聊天请求时发生错误: {str(e)}"
  585. save_chat_log(
  586. user_id=user_context.user_id,
  587. question=request.messages[-1].content if request.messages else "",
  588. stream_mode=request.stream,
  589. status="error",
  590. error=error_message,
  591. )
  592. raise HTTPException(status_code=500, detail=error_message)
  593. @router.get("/history")
  594. async def get_user_history(
  595. current_user: Annotated[User, Depends(get_current_active_user)],
  596. sessionId: str = Query(..., description="会话ID"),
  597. ) -> List[ChatMessage]:
  598. docs = get_chat_history(current_user.userId, sessionId)
  599. return [
  600. ChatMessage(
  601. role=doc["role"],
  602. content=doc["content"],
  603. attachments=[FileAttachment(**att) for att in doc["attachments"]] if doc.get("attachments") else None,
  604. timestamp=doc.get("timestamp"),
  605. response_id=doc.get("response_id"),
  606. thinking=doc.get("thinking"),
  607. searching=doc.get("searching"),
  608. )
  609. for doc in docs
  610. ]
  611. @router.delete("/history")
  612. async def clear_user_history(
  613. current_user: Annotated[User, Depends(get_current_active_user)],
  614. sessionId: str = Query(..., description="会话ID"),
  615. ):
  616. deleted_count = delete_chat_history(current_user.userId, sessionId)
  617. if deleted_count > 0:
  618. return {"message": "聊天历史已清空", "user": current_user.userId, "deleted_messages": deleted_count, "timestamp": datetime.now()}
  619. return {"message": "用户没有聊天历史", "user": current_user.userId, "deleted_messages": 0, "timestamp": datetime.now()}
  620. @router.get("/sessions")
  621. async def get_user_sessions(
  622. current_user: Annotated[User, Depends(get_current_active_user)],
  623. ):
  624. return get_sessions(current_user.userId)
  625. @router.get("/health")
  626. async def health_check():
  627. return {"status": "healthy", "timestamp": datetime.now(), "version": "1.0.0", "model": config.MODEL_NAME}
  628. router.tags = ["聊天服务"]
  629. router.responses = {
  630. 401: {"description": "未授权 - 需要有效的JWT令牌"},
  631. 429: {"description": "请求过多 - 配额已用完"},
  632. 500: {"description": "服务器内部错误"}
  633. }