diff --git a/backend/app/api/chat.py b/backend/app/api/chat.py index 249fd42..80ee042 100644 --- a/backend/app/api/chat.py +++ b/backend/app/api/chat.py @@ -34,6 +34,7 @@ def _message_out(m: ChatMessage) -> dict: "id": m.id, "session_id": m.session_id, "role": m.role, "content": m.content, "model": m.model, "file_ids": file_ids, "feedback": m.feedback or "", "suggestions": suggestions, "edited": bool(m.edited), "regenerated": m.regenerated or 0, + "reasoning": m.reasoning_content or "", "created_at": m.created_at.isoformat(), "updated_at": m.updated_at.isoformat(), } @@ -142,7 +143,7 @@ async def regenerate(session_id: int, """REST 非流式重新生成最后一条 AI 回答(备用,前端主要走 WS 流式)。""" s = _get_owned_session(db, user, session_id) try: - async for delta, message_id in chat_service.regenerate_stream(db, s): + async for _k, _d, message_id in _regenerate_rest(chat_service.regenerate_stream(db, s)): pass # 流式丢弃,最终内容已入库 except LookupError as e: raise HTTPException(status_code=404, detail=str(e)) @@ -150,3 +151,9 @@ async def regenerate(session_id: int, raise HTTPException(status_code=502, detail=f"模型调用失败:{e}") m = db.get(ChatMessage, message_id) return ok(_message_out(m)) + + +async def _regenerate_rest(agen): + """适配 regenerate_stream 的 (kind, delta), id 产出结构。""" + async for (kind, delta), message_id in agen: + yield kind, delta, message_id diff --git a/backend/app/api/ws.py b/backend/app/api/ws.py index 964525a..470afb1 100644 --- a/backend/app/api/ws.py +++ b/backend/app/api/ws.py @@ -1,7 +1,7 @@ """WebSocket 路由:流式对话 + 重新生成。协议: 客户端 → {"type":"chat","content":"...","agent_id":null,"model":"","file_ids":[]} {"type":"regenerate"} -服务端 → {"type":"delta","message_id":1,"content":"增量文本"} +服务端 → {"type":"delta","message_id":1,"kind":"reasoning"|"content","content":"增量文本"} {"type":"done","message_id":1} {"type":"suggestions","message_id":1,"items":["..",".."]} {"type":"title","title":"..."} @@ -70,20 +70,20 @@ async def chat_ws(websocket: WebSocket): await websocket.send_json({"type": "error", "message": "消息不能为空"}) continue try: - async for delta, message_id in chat_service.chat_stream( + async for (kind, delta), message_id in chat_service.chat_stream( db, user, session, content, agent_id=data.get("agent_id"), model=data.get("model", ""), file_ids=file_ids, ): - await websocket.send_json({"type": "delta", "message_id": message_id, "content": delta}) + await websocket.send_json({"type": "delta", "message_id": message_id, "kind": kind, "content": delta}) await websocket.send_json({"type": "done", "message_id": message_id}) except Exception as e: await websocket.send_json({"type": "error", "message": f"模型调用失败:{e}"}) elif msg_type == "regenerate": try: - async for delta, message_id in chat_service.regenerate_stream(db, session): - await websocket.send_json({"type": "delta", "message_id": message_id, "content": delta}) + async for (kind, delta), message_id in chat_service.regenerate_stream(db, session): + await websocket.send_json({"type": "delta", "message_id": message_id, "kind": kind, "content": delta}) await websocket.send_json({"type": "done", "message_id": message_id}) except LookupError as e: await websocket.send_json({"type": "error", "message": str(e)}) diff --git a/backend/app/core/llm.py b/backend/app/core/llm.py index 9566796..6ca5d7e 100644 --- a/backend/app/core/llm.py +++ b/backend/app/core/llm.py @@ -41,8 +41,10 @@ async def chat_completion_stream( model: Optional[str] = None, temperature: float = 0.7, max_tokens: int = 4096, -) -> AsyncIterator[str]: - """流式对话补全:逐段产出增量文本。""" +): + """流式对话补全:逐段产出增量。 + 产出 (kind, text):kind="reasoning" 思考内容 / kind="content" 回答内容。 + """ client = _client_for(resolve_model(model)) stream = await client.chat.completions.create( model=resolve_model(model), @@ -52,8 +54,36 @@ async def chat_completion_stream( stream=True, ) async for chunk in stream: - if chunk.choices and chunk.choices[0].delta and chunk.choices[0].delta.content: - yield chunk.choices[0].delta.content + if not chunk.choices or not chunk.choices[0].delta: + continue + delta = chunk.choices[0].delta + # 思考内容:deepseek-reasoner 用 reasoning_content,部分模型用 reasoning/thinking + reasoning = getattr(delta, "reasoning_content", None) or getattr(delta, "reasoning", None) + if reasoning: + yield ("reasoning", reasoning) + continue + if delta.content: + yield ("content", delta.content) + + +async def chat_completion_full( + messages: list[dict], + model: Optional[str] = None, + temperature: float = 0.7, + max_tokens: int = 4096, +) -> tuple[str, str]: + """非流式对话补全:返回 (回答内容, 思考内容)。""" + client = _client_for(resolve_model(model)) + resp = await client.chat.completions.create( + model=resolve_model(model), + messages=messages, + temperature=temperature, + max_tokens=max_tokens, + stream=False, + ) + msg = resp.choices[0].message + reasoning = getattr(msg, "reasoning_content", None) or getattr(msg, "reasoning", None) or "" + return (msg.content or ""), reasoning async def vision_analysis(prompt: str, image_urls: list[str], model: Optional[str] = None) -> str: diff --git a/backend/app/core/migrate.py b/backend/app/core/migrate.py index 03d031c..bf188a6 100644 --- a/backend/app/core/migrate.py +++ b/backend/app/core/migrate.py @@ -12,6 +12,7 @@ def migrate(db): "suggestions": "TEXT DEFAULT '[]'", "edited": "BOOLEAN DEFAULT 0", "regenerated": "INTEGER DEFAULT 0", + "reasoning_content": "TEXT DEFAULT ''", "updated_at": "DATETIME", } for name, ddl in additions.items(): diff --git a/backend/app/models/chat.py b/backend/app/models/chat.py index 48f060b..d62434a 100644 --- a/backend/app/models/chat.py +++ b/backend/app/models/chat.py @@ -39,6 +39,8 @@ class ChatMessage(Base): feedback: Mapped[str] = mapped_column(String(8), default="") # 推荐短语(JSON 数组,AI 回答后生成 1-3 条) suggestions: Mapped[str] = mapped_column(Text, default="[]") + # 思考内容(思考模型的 reasoning 流式输出) + reasoning_content: Mapped[str] = mapped_column(Text, default="") # 用户消息是否被编辑过 edited: Mapped[bool] = mapped_column(Boolean, default=False) # 重新生成次数 diff --git a/backend/app/services/chat_service.py b/backend/app/services/chat_service.py index 9494cfd..4627ca8 100644 --- a/backend/app/services/chat_service.py +++ b/backend/app/services/chat_service.py @@ -220,9 +220,11 @@ async def chat_once(db: Session, user: User, session: ChatSession, content: str, db.commit() messages = build_messages(db, session, content, file_ids) - reply_text = await llm.chat_completion(messages, model=session.model, temperature=agent.temperature if agent else 0.7) + reply_text, reply_reasoning = await llm.chat_completion_full( + messages, model=session.model, temperature=agent.temperature if agent else 0.7) - reply = ChatMessage(session_id=session.id, role="assistant", content=reply_text, model=session.model) + reply = ChatMessage(session_id=session.id, role="assistant", content=reply_text, + reasoning_content=reply_reasoning, model=session.model) db.add(reply) session.updated_at = datetime.utcnow() db.commit() @@ -245,25 +247,32 @@ async def chat_stream(db: Session, user: User, session: ChatSession, content: st db.commit() messages = build_messages(db, session, content, file_ids) - reply = ChatMessage(session_id=session.id, role="assistant", content="", model=session.model) + reply = ChatMessage(session_id=session.id, role="assistant", content="", reasoning_content="", model=session.model) db.add(reply) db.commit() db.refresh(reply) parts: list[str] = [] + reasoning_parts: list[str] = [] try: - async for delta in llm.chat_completion_stream( + async for kind, delta in llm.chat_completion_stream( messages, model=session.model, temperature=agent.temperature if agent else 0.7 ): - parts.append(delta) - yield delta, reply.id + if kind == "reasoning": + reasoning_parts.append(delta) + yield ("reasoning", delta), reply.id + else: + parts.append(delta) + yield ("content", delta), reply.id except Exception as e: reply.content = "".join(parts) or f"(调用失败:{e})" + reply.reasoning_content = "".join(reasoning_parts) session.updated_at = datetime.utcnow() db.commit() raise else: reply.content = "".join(parts) + reply.reasoning_content = "".join(reasoning_parts) session.updated_at = datetime.utcnow() db.commit() _fire_background_jobs(db, session, content, reply.id, reply.content) @@ -276,7 +285,7 @@ def _fire_background_jobs(db: Session, session: ChatSession, first_content: str, async def regenerate_stream(db: Session, session: ChatSession): - """重新生成最后一条 AI 回答(流式)。返回 (message_id, async_iter)。""" + """重新生成最后一条 AI 回答(流式):先删除旧回答内容,再流式输出。""" last = ( db.query(ChatMessage) .filter(ChatMessage.session_id == session.id, ChatMessage.role == "assistant") @@ -286,27 +295,36 @@ async def regenerate_stream(db: Session, session: ChatSession): if not last: raise LookupError("没有可重新生成的消息") - agent = db.get(Agent, session.agent_id) if session.agent_id else None - messages = build_messages(db, session, "", exclude_last=True) - - # 重新生成后旧建议清空,等新建议 + # 先删除旧回答(内容清空,前端同步清空显示) + last.content = "" + last.reasoning_content = "" last.suggestions = "[]" db.commit() + agent = db.get(Agent, session.agent_id) if session.agent_id else None + messages = build_messages(db, session, "", exclude_last=True) + parts: list[str] = [] + reasoning_parts: list[str] = [] try: - async for delta in llm.chat_completion_stream( + async for kind, delta in llm.chat_completion_stream( messages, model=session.model, temperature=agent.temperature if agent else 0.7 ): - parts.append(delta) - yield delta, last.id + if kind == "reasoning": + reasoning_parts.append(delta) + yield ("reasoning", delta), last.id + else: + parts.append(delta) + yield ("content", delta), last.id except Exception as e: last.content = "".join(parts) or f"(调用失败:{e})" + last.reasoning_content = "".join(reasoning_parts) last.regenerated += 1 db.commit() raise else: last.content = "".join(parts) + last.reasoning_content = "".join(reasoning_parts) last.regenerated += 1 session.updated_at = datetime.utcnow() db.commit() diff --git a/frontend/src/views/ChatListView.vue b/frontend/src/views/ChatListView.vue index a5187f6..308cd0f 100644 --- a/frontend/src/views/ChatListView.vue +++ b/frontend/src/views/ChatListView.vue @@ -2,7 +2,7 @@