Skip to content

Commit ad04429

Browse files
author
dolphin
committed
fix(channel): carry the real answer message_id on the stream end event
Channel article chat yielded the 'end' event BEFORE persisting the answer ChatMessage, so the client never got the real id — the streamed answer kept its temporary placeholder id and a like clicked before switching away wrote to a non-existent row and vanished (same class of bug as the knowledge-space fix). Persist the answer first, then emit the end event with its message_id so the client can swap the placeholder out immediately (frontend useChannelChat already consumes it).
1 parent a8055cc commit ad04429

1 file changed

Lines changed: 99 additions & 105 deletions

File tree

src/backend/bisheng/channel/api/endpoints/channel_chat.py

Lines changed: 99 additions & 105 deletions
Original file line numberDiff line numberDiff line change
@@ -6,21 +6,19 @@
66
- GET /chat/messages/{article_doc_id}: Query chat history
77
- DELETE /chat/messages/{article_doc_id}: Clear chat content
88
"""
9+
910
import json
1011
import logging
1112
from datetime import datetime
12-
from typing import List
1313

1414
from fastapi import APIRouter, Depends
1515
from fastapi.responses import StreamingResponse
1616
from langchain_core.documents import Document
1717
from langchain_core.messages import HumanMessage, SystemMessage
1818
from sse_starlette import EventSourceResponse
1919

20-
from bisheng.api.services.workstation import (
21-
WorkstationConversation, WorkstationMessage
22-
)
23-
from bisheng.api.v1.schemas import resp_200, ChatResponse
20+
from bisheng.api.services.workstation import WorkstationConversation, WorkstationMessage
21+
from bisheng.api.v1.schemas import ChatResponse, resp_200
2422
from bisheng.channel.domain.schemas.channel_chat_schema import ChannelArticleChatRequest
2523
from bisheng.channel.domain.services.article_es_service import ArticleEsService
2624
from bisheng.channel.domain.services.channel_chat_service import ChannelChatService
@@ -29,95 +27,94 @@
2927
from bisheng.common.errcode import BaseErrorCode
3028
from bisheng.common.errcode.channel import ChannelChatConversationNotFoundError
3129
from bisheng.common.errcode.http_error import ServerError, UnAuthorizedError
32-
from bisheng.common.schemas.api import resp_500, SSEResponse
33-
from bisheng.llm.domain.utils import extract_reasoning_content
30+
from bisheng.common.schemas.api import SSEResponse, resp_500
3431
from bisheng.database.constants import MessageCategory
3532
from bisheng.database.models.message import ChatMessage, ChatMessageDao
3633
from bisheng.database.models.session import MessageSession
34+
from bisheng.llm.domain.utils import extract_reasoning_content
3735

3836
logger = logging.getLogger(__name__)
3937

40-
router = APIRouter(prefix='/chat', tags=['Channel Article Chat'])
38+
router = APIRouter(prefix="/chat", tags=["Channel Article Chat"])
4139

4240

4341
def custom_json_serializer(obj):
4442
if isinstance(obj, datetime):
4543
return obj.isoformat()
46-
raise TypeError(f'Type {type(obj)} not serializable')
44+
raise TypeError(f"Type {type(obj)} not serializable")
4745

4846

4947
def user_message(msgId, conversationId, sender, text):
50-
msg = json.dumps({
51-
'message': {
52-
'messageId': msgId,
53-
'conversationId': conversationId,
54-
'sender': sender,
55-
'text': text
56-
},
57-
'created': True
58-
})
59-
return f'event: message\ndata: {msg}\n\n'
48+
msg = json.dumps(
49+
{
50+
"message": {"messageId": msgId, "conversationId": conversationId, "sender": sender, "text": text},
51+
"created": True,
52+
}
53+
)
54+
return f"event: message\ndata: {msg}\n\n"
6055

6156

6257
def step_message(stepId, runId, index, msgId):
63-
msg = json.dumps({
64-
'event': 'on_run_step',
65-
'data': {
66-
'id': stepId,
67-
'runId': runId,
68-
'type': 'message_creation',
69-
'index': index,
70-
'stepDetails': {
71-
'type': 'message_creation',
72-
'message_creation': {
73-
'message_id': msgId
74-
}
75-
}
58+
msg = json.dumps(
59+
{
60+
"event": "on_run_step",
61+
"data": {
62+
"id": stepId,
63+
"runId": runId,
64+
"type": "message_creation",
65+
"index": index,
66+
"stepDetails": {"type": "message_creation", "message_creation": {"message_id": msgId}},
67+
},
7668
}
77-
})
78-
return f'event: message\ndata: {msg}\n\n'
69+
)
70+
return f"event: message\ndata: {msg}\n\n"
7971

8072

8173
def delta(id, delta):
82-
return {'id': id, 'delta': delta}
74+
return {"id": id, "delta": delta}
8375

8476

85-
async def final_message(conversation: MessageSession, title: str, requestMessage: ChatMessage,
86-
text: str, error: bool, modelName: str,
87-
source_document: List[Document] = None):
77+
async def final_message(
78+
conversation: MessageSession,
79+
title: str,
80+
requestMessage: ChatMessage,
81+
text: str,
82+
error: bool,
83+
modelName: str,
84+
source_document: list[Document] = None,
85+
):
8886
responseMessage = await ChatMessageDao.ainsert_one(
8987
ChatMessage(
9088
user_id=conversation.user_id,
9189
chat_id=conversation.chat_id,
9290
flow_id=conversation.flow_id,
93-
type='assistant',
91+
type="assistant",
9492
is_bot=True,
9593
message=text,
96-
category='answer',
94+
category="answer",
9795
sender=modelName,
98-
extra=json.dumps({
99-
'parentMessageId': requestMessage.id,
100-
'error': error
101-
}),
102-
source=0
103-
))
96+
extra=json.dumps({"parentMessageId": requestMessage.id, "error": error}),
97+
source=0,
98+
)
99+
)
104100

105101
msg = json.dumps(
106102
{
107-
'final': True,
108-
'conversation': WorkstationConversation.from_chat_session(conversation).model_dump(),
109-
'title': title,
110-
'requestMessage': (await WorkstationMessage.from_chat_message(requestMessage)).model_dump(),
111-
'responseMessage': (await WorkstationMessage.from_chat_message(responseMessage)).model_dump(),
103+
"final": True,
104+
"conversation": WorkstationConversation.from_chat_session(conversation).model_dump(),
105+
"title": title,
106+
"requestMessage": (await WorkstationMessage.from_chat_message(requestMessage)).model_dump(),
107+
"responseMessage": (await WorkstationMessage.from_chat_message(responseMessage)).model_dump(),
112108
},
113-
default=custom_json_serializer)
114-
return f'event: message\ndata: {msg}\n\n'
109+
default=custom_json_serializer,
110+
)
111+
return f"event: message\ndata: {msg}\n\n"
115112

116113

117-
@router.post('/completions', summary='Channel Article AI Assistant Chat')
114+
@router.post("/completions", summary="Channel Article AI Assistant Chat")
118115
async def chat_completions(
119-
data: ChannelArticleChatRequest,
120-
login_user: UserPayload = Depends(UserPayload.get_login_user),
116+
data: ChannelArticleChatRequest,
117+
login_user: UserPayload = Depends(UserPayload.get_login_user),
121118
):
122119
"""
123120
Channel Article AI Assistant Chat API, returns SSE stream.
@@ -145,7 +142,7 @@ async def chat_completions(
145142
error_response = e if isinstance(e, BaseErrorCode) else ServerError(msg=str(e))
146143
return EventSourceResponse(iter([error_response.to_sse_event_instance()]))
147144
except Exception as e:
148-
logger.exception(f'Error in channel article chat setup: {e}')
145+
logger.exception(f"Error in channel article chat setup: {e}")
149146
return EventSourceResponse(iter([ServerError(exception=e).to_sse_event_instance()]))
150147

151148
async def event_stream():
@@ -162,35 +159,27 @@ async def event_stream():
162159
user_prompt_template = (
163160
subscription_config.user_prompt
164161
if subscription_config and subscription_config.user_prompt
165-
else (
166-
"# 参考资料\n```\n{article_content}\n```\n# 用户问题\n{question}"
167-
)
168-
)
169-
user_prompt = user_prompt_template.format(
170-
article_content=article_content,
171-
question=data.text
162+
else ("# 参考资料\n```\n{article_content}\n```\n# 用户问题\n{question}")
172163
)
164+
user_prompt = user_prompt_template.format(article_content=article_content, question=data.text)
173165
await ChatMessageDao.ainsert_one(
174166
ChatMessage(
175167
user_id=login_user.user_id,
176168
chat_id=conversation.chat_id,
177169
flow_id=data.article_doc_id,
178-
type='human',
170+
type="human",
179171
is_bot=False,
180-
sender='User',
172+
sender="User",
181173
message=json.dumps({"query": data.text}, ensure_ascii=False),
182174
category=MessageCategory.QUESTION,
183175
source=0,
184-
))
176+
)
177+
)
185178
# Get chat history (excluding the latest one)
186179
history_messages = (await ChannelChatService.get_chat_history(conversationId, 8))[:-1]
187180

188181
# Build LLM input
189-
inputs = [
190-
SystemMessage(content=system_prompt),
191-
*history_messages,
192-
HumanMessage(content=user_prompt)
193-
]
182+
inputs = [SystemMessage(content=system_prompt), *history_messages, HumanMessage(content=user_prompt)]
194183

195184
answer = ""
196185
reasoning_answer = ""
@@ -200,56 +189,61 @@ async def event_stream():
200189
reasoning_content = extract_reasoning_content(chunk)
201190
answer += content
202191
reasoning_answer += reasoning_content
203-
yield SSEResponse(data=ChatResponse(
204-
category=MessageCategory.STREAM,
205-
message={
206-
"content": content,
207-
"reasoning_content": reasoning_content,
208-
},
209-
type="stream"
210-
)).to_string()
211-
212-
yield SSEResponse(data=ChatResponse(
213-
category=MessageCategory.STREAM,
214-
message={
215-
"content": answer,
216-
"reasoning_content": reasoning_answer
217-
},
218-
type="end"
219-
)).to_string()
220-
221-
# Append reasoning process to final result
222-
await ChatMessageDao.ainsert_one(
192+
yield SSEResponse(
193+
data=ChatResponse(
194+
category=MessageCategory.STREAM,
195+
message={
196+
"content": content,
197+
"reasoning_content": reasoning_content,
198+
},
199+
type="stream",
200+
)
201+
).to_string()
202+
203+
# Persist the answer BEFORE the end event so we can hand the client the
204+
# real ChatMessage id. The client renders the streamed answer under a
205+
# temporary placeholder id; without the real id, like/dislike clicked
206+
# before a reload writes to a non-existent row and silently vanishes.
207+
answer_message = await ChatMessageDao.ainsert_one(
223208
ChatMessage(
224209
category=MessageCategory.ANSWER,
225-
message=json.dumps({
226-
"content": answer,
227-
"reasoning_content": reasoning_answer
228-
}, ensure_ascii=False),
210+
message=json.dumps({"content": answer, "reasoning_content": reasoning_answer}, ensure_ascii=False),
229211
user_id=login_user.user_id,
230212
chat_id=conversation.chat_id,
231213
flow_id=data.article_doc_id,
232214
type="end",
233215
is_bot=True,
234216
)
235217
)
218+
219+
yield SSEResponse(
220+
data=ChatResponse(
221+
category=MessageCategory.STREAM,
222+
message={
223+
"content": answer,
224+
"reasoning_content": reasoning_answer,
225+
"message_id": answer_message.id,
226+
},
227+
type="end",
228+
)
229+
).to_string()
236230
except BaseErrorCode as e:
237231
yield e.to_sse_event_instance_str()
238232
except Exception as e:
239-
logger.exception(f'Error in channel article chat processing')
233+
logger.exception("Error in channel article chat processing")
240234
yield ServerError(exception=e).to_sse_event_instance_str()
241235

242236
try:
243-
return StreamingResponse(event_stream(), media_type='text/event-stream')
237+
return StreamingResponse(event_stream(), media_type="text/event-stream")
244238
except Exception as e:
245-
logger.exception(f'Error creating channel article chat stream: {e}')
239+
logger.exception(f"Error creating channel article chat stream: {e}")
246240
return EventSourceResponse(iter([ServerError(exception=e).to_sse_event_instance()]))
247241

248242

249-
@router.get('/messages/{article_doc_id}', summary='Query Channel Article AI Assistant Chat History')
243+
@router.get("/messages/{article_doc_id}", summary="Query Channel Article AI Assistant Chat History")
250244
async def get_chat_history(
251-
article_doc_id: str,
252-
login_user: UserPayload = Depends(UserPayload.get_login_user),
245+
article_doc_id: str,
246+
login_user: UserPayload = Depends(UserPayload.get_login_user),
253247
):
254248
"""Query Channel Article AI Assistant Chat History Content"""
255249
messages = await ChannelChatService.get_chat_messages(article_doc_id, login_user)
@@ -258,10 +252,10 @@ async def get_chat_history(
258252
return resp_200(data=messages)
259253

260254

261-
@router.delete('/messages/{article_doc_id}', summary='Clear Channel Article AI Assistant Chat Content')
255+
@router.delete("/messages/{article_doc_id}", summary="Clear Channel Article AI Assistant Chat Content")
262256
async def clear_chat(
263-
article_doc_id: str,
264-
login_user: UserPayload = Depends(UserPayload.get_login_user),
257+
article_doc_id: str,
258+
login_user: UserPayload = Depends(UserPayload.get_login_user),
265259
):
266260
"""Clear Channel Article AI Assistant Chat Content"""
267261
try:

0 commit comments

Comments
 (0)