Skip to content

Commit 1947a22

Browse files
committed
feat(chat): add streaming display for intent/clarification reasoning content
- Support displaying thinking process for intent recognition and clarification - Add compatibility for 'reasoning' field (Ollama/LMStudio GPT-OSS) - Improve chat scroll behavior with userScrolledAway flag
1 parent 4ea5389 commit 1947a22

8 files changed

Lines changed: 141 additions & 24 deletions

File tree

backend/apps/ai_model/openai/llm.py

Lines changed: 6 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -27,8 +27,12 @@ def _convert_delta_to_message_chunk(
2727
role = cast(str, _dict.get("role"))
2828
content = cast(str, _dict.get("content") or "")
2929
additional_kwargs: dict = {}
30-
if 'reasoning_content' in _dict:
31-
additional_kwargs['reasoning_content'] = _dict.get('reasoning_content')
30+
# 兼容 reasoning_content (DeepSeek等) 和 reasoning (Ollama/LMStudio GPT-OSS) 两种字段
31+
reasoning_content = _dict.get('reasoning_content')
32+
if not reasoning_content:
33+
reasoning_content = _dict.get('reasoning')
34+
if reasoning_content:
35+
additional_kwargs['reasoning_content'] = reasoning_content
3236
if _dict.get("function_call"):
3337
function_call = dict(_dict["function_call"])
3438
if "name" in function_call and function_call["name"] is None:

backend/apps/chat/curd/chat.py

Lines changed: 71 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -276,6 +276,8 @@ def get_chat_with_records(session: SessionDep, chart_id: int, current_user: Curr
276276
chart_alias_log = aliased(ChatLog)
277277
analysis_alias_log = aliased(ChatLog)
278278
predict_alias_log = aliased(ChatLog)
279+
intent_alias_log = aliased(ChatLog)
280+
clarify_alias_log = aliased(ChatLog)
279281

280282
stmt = (select(ChatRecord.id, ChatRecord.chat_id, ChatRecord.create_time, ChatRecord.finish_time,
281283
ChatRecord.question, ChatRecord.sql_answer, ChatRecord.sql,
@@ -287,7 +289,9 @@ def get_chat_with_records(session: SessionDep, chart_id: int, current_user: Curr
287289
sql_alias_log.reasoning_content.label('sql_reasoning_content'),
288290
chart_alias_log.reasoning_content.label('chart_reasoning_content'),
289291
analysis_alias_log.reasoning_content.label('analysis_reasoning_content'),
290-
predict_alias_log.reasoning_content.label('predict_reasoning_content')
292+
predict_alias_log.reasoning_content.label('predict_reasoning_content'),
293+
intent_alias_log.reasoning_content.label('intent_reasoning_content'),
294+
clarify_alias_log.reasoning_content.label('clarify_reasoning_content')
291295
)
292296
.outerjoin(sql_alias_log, and_(sql_alias_log.pid == ChatRecord.id,
293297
sql_alias_log.type == TypeEnum.CHAT,
@@ -301,18 +305,50 @@ def get_chat_with_records(session: SessionDep, chart_id: int, current_user: Curr
301305
.outerjoin(predict_alias_log, and_(predict_alias_log.pid == ChatRecord.id,
302306
predict_alias_log.type == TypeEnum.CHAT,
303307
predict_alias_log.operate == OperationEnum.PREDICT_DATA))
308+
.outerjoin(intent_alias_log, and_(intent_alias_log.pid == ChatRecord.id,
309+
intent_alias_log.type == TypeEnum.CHAT,
310+
intent_alias_log.operate == OperationEnum.RECOGNIZE_INTENT))
311+
.outerjoin(clarify_alias_log, and_(clarify_alias_log.pid == ChatRecord.id,
312+
clarify_alias_log.type == TypeEnum.CHAT,
313+
clarify_alias_log.operate == OperationEnum.GENERATE_CLARIFICATION))
304314
.where(and_(ChatRecord.create_by == current_user.id, ChatRecord.chat_id == chart_id)).order_by(
305315
ChatRecord.create_time))
306316
if with_data:
307-
stmt = select(ChatRecord.id, ChatRecord.chat_id, ChatRecord.create_time, ChatRecord.finish_time,
317+
stmt = (select(ChatRecord.id, ChatRecord.chat_id, ChatRecord.create_time, ChatRecord.finish_time,
308318
ChatRecord.question, ChatRecord.sql_answer, ChatRecord.sql,
309319
ChatRecord.chart_answer, ChatRecord.chart, ChatRecord.analysis, ChatRecord.predict,
310320
ChatRecord.datasource_select_answer, ChatRecord.analysis_record_id, ChatRecord.predict_record_id,
311321
ChatRecord.regenerate_record_id,
312322
ChatRecord.recommended_question, ChatRecord.first_chat,
313-
ChatRecord.finish, ChatRecord.error, ChatRecord.intent_answer, ChatRecord.data, ChatRecord.predict_data).where(
314-
and_(ChatRecord.create_by == current_user.id, ChatRecord.chat_id == chart_id)).order_by(
315-
ChatRecord.create_time)
323+
ChatRecord.finish, ChatRecord.error, ChatRecord.intent_answer,
324+
ChatRecord.data, ChatRecord.predict_data,
325+
sql_alias_log.reasoning_content.label('sql_reasoning_content'),
326+
chart_alias_log.reasoning_content.label('chart_reasoning_content'),
327+
analysis_alias_log.reasoning_content.label('analysis_reasoning_content'),
328+
predict_alias_log.reasoning_content.label('predict_reasoning_content'),
329+
intent_alias_log.reasoning_content.label('intent_reasoning_content'),
330+
clarify_alias_log.reasoning_content.label('clarify_reasoning_content')
331+
)
332+
.outerjoin(sql_alias_log, and_(sql_alias_log.pid == ChatRecord.id,
333+
sql_alias_log.type == TypeEnum.CHAT,
334+
sql_alias_log.operate == OperationEnum.GENERATE_SQL))
335+
.outerjoin(chart_alias_log, and_(chart_alias_log.pid == ChatRecord.id,
336+
chart_alias_log.type == TypeEnum.CHAT,
337+
chart_alias_log.operate == OperationEnum.GENERATE_CHART))
338+
.outerjoin(analysis_alias_log, and_(analysis_alias_log.pid == ChatRecord.id,
339+
analysis_alias_log.type == TypeEnum.CHAT,
340+
analysis_alias_log.operate == OperationEnum.ANALYSIS))
341+
.outerjoin(predict_alias_log, and_(predict_alias_log.pid == ChatRecord.id,
342+
predict_alias_log.type == TypeEnum.CHAT,
343+
predict_alias_log.operate == OperationEnum.PREDICT_DATA))
344+
.outerjoin(intent_alias_log, and_(intent_alias_log.pid == ChatRecord.id,
345+
intent_alias_log.type == TypeEnum.CHAT,
346+
intent_alias_log.operate == OperationEnum.RECOGNIZE_INTENT))
347+
.outerjoin(clarify_alias_log, and_(clarify_alias_log.pid == ChatRecord.id,
348+
clarify_alias_log.type == TypeEnum.CHAT,
349+
clarify_alias_log.operate == OperationEnum.GENERATE_CLARIFICATION))
350+
.where(and_(ChatRecord.create_by == current_user.id, ChatRecord.chat_id == chart_id)).order_by(
351+
ChatRecord.create_time))
316352

317353
result = session.execute(stmt).all()
318354
record_list: list[ChatRecordResult] = []
@@ -333,6 +369,8 @@ def get_chat_with_records(session: SessionDep, chart_id: int, current_user: Curr
333369
chart_reasoning_content=row.chart_reasoning_content,
334370
analysis_reasoning_content=row.analysis_reasoning_content,
335371
predict_reasoning_content=row.predict_reasoning_content,
372+
intent_reasoning_content=row.intent_reasoning_content,
373+
clarify_reasoning_content=row.clarify_reasoning_content,
336374
intent_answer=row.intent_answer,
337375
))
338376
else:
@@ -347,6 +385,12 @@ def get_chat_with_records(session: SessionDep, chart_id: int, current_user: Curr
347385
regenerate_record_id=row.regenerate_record_id,
348386
recommended_question=row.recommended_question, first_chat=row.first_chat,
349387
finish=row.finish, error=row.error, data=row.data, predict_data=row.predict_data,
388+
sql_reasoning_content=row.sql_reasoning_content,
389+
chart_reasoning_content=row.chart_reasoning_content,
390+
analysis_reasoning_content=row.analysis_reasoning_content,
391+
predict_reasoning_content=row.predict_reasoning_content,
392+
intent_reasoning_content=row.intent_reasoning_content,
393+
clarify_reasoning_content=row.clarify_reasoning_content,
350394
intent_answer=row.intent_answer,
351395
))
352396

@@ -368,12 +412,32 @@ def get_chat_with_records(session: SessionDep, chart_id: int, current_user: Curr
368412
def format_record(record: ChatRecordResult):
369413
_dict = record.model_dump()
370414

415+
# 处理 sql_answer(SQL 生成思考内容)
416+
sql_thinking = ''
371417
if record.sql_answer and record.sql_answer.strip() != '' and record.sql_answer.strip()[0] == '{' and \
372418
record.sql_answer.strip()[-1] == '}':
373419
_obj = orjson.loads(record.sql_answer)
374-
_dict['sql_answer'] = _obj.get('reasoning_content')
420+
sql_thinking = _obj.get('reasoning_content') or ''
375421
if record.sql_reasoning_content and record.sql_reasoning_content.strip() != '':
376-
_dict['sql_answer'] = record.sql_reasoning_content
422+
sql_thinking = record.sql_reasoning_content
423+
424+
# 处理 intent_answer(意图识别 + 澄清器思考内容)
425+
intent_thinking = ''
426+
# 先获取意图识别的思考内容
427+
if record.intent_reasoning_content and record.intent_reasoning_content.strip() != '':
428+
intent_thinking = record.intent_reasoning_content
429+
# 追加澄清器的思考内容
430+
if record.clarify_reasoning_content and record.clarify_reasoning_content.strip() != '':
431+
if intent_thinking:
432+
intent_thinking += '\n\n---\n\n' # 分隔符
433+
intent_thinking += record.clarify_reasoning_content
434+
435+
# 独立展示(不再合并)
436+
if sql_thinking:
437+
_dict['sql_answer'] = sql_thinking
438+
if intent_thinking:
439+
_dict['intent_answer'] = intent_thinking
440+
377441
if record.chart_answer and record.chart_answer.strip() != '' and record.chart_answer.strip()[0] == '{' and \
378442
record.chart_answer.strip()[-1] == '}':
379443
_obj = orjson.loads(record.chart_answer)

backend/apps/chat/models/chat_model.py

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -153,6 +153,8 @@ class ChatRecordResult(BaseModel):
153153
chart_reasoning_content: Optional[str] = None
154154
analysis_reasoning_content: Optional[str] = None
155155
predict_reasoning_content: Optional[str] = None
156+
intent_reasoning_content: Optional[str] = None # 意图识别思考过程
157+
clarify_reasoning_content: Optional[str] = None # 澄清器思考过程
156158
# 意图识别结果(JSON 格式存储)
157159
intent_answer: Optional[str] = None
158160
intent_response: Optional[str] = None # 意图识别响应(供前端展示)

backend/apps/chat/task/llm.py

Lines changed: 34 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -305,11 +305,21 @@ def _handle_intent_recognition(self, session: Session, in_chat: bool) -> Iterato
305305

306306
# 3. LLM 调用
307307
full_text = ''
308+
full_thinking_text = ''
308309
token_usage = {}
309310
res = process_stream(self.llm.stream(self.intent_message), token_usage)
310311
for chunk in res:
311312
if chunk.get('content'):
312313
full_text += chunk.get('content')
314+
if chunk.get('reasoning_content'):
315+
full_thinking_text += chunk.get('reasoning_content')
316+
# 流式输出意图识别思考过程
317+
if in_chat and chunk.get('reasoning_content'):
318+
yield 'data:' + orjson.dumps({
319+
'content': '',
320+
'reasoning_content': chunk.get('reasoning_content'),
321+
'type': 'intent-result'
322+
}).decode() + '\n\n'
313323

314324
# 4. 解析结果 & 记录
315325
json_str = extract_nested_json(full_text)
@@ -320,6 +330,7 @@ def _handle_intent_recognition(self, session: Session, in_chat: bool) -> Iterato
320330
session=session,
321331
log=intent_log,
322332
full_message=[{'type': msg.type, 'content': msg.content} for msg in self.intent_message],
333+
reasoning_content=full_thinking_text,
323334
token_usage=token_usage
324335
)
325336
self._intent_result = {'rewritten_query': self.chat_question.question}
@@ -333,6 +344,7 @@ def _handle_intent_recognition(self, session: Session, in_chat: bool) -> Iterato
333344
session=session,
334345
log=intent_log,
335346
full_message=[{'type': msg.type, 'content': msg.content} for msg in self.intent_message],
347+
reasoning_content=full_thinking_text,
336348
token_usage=token_usage
337349
)
338350
self._intent_result = {'rewritten_query': self.chat_question.question}
@@ -343,6 +355,7 @@ def _handle_intent_recognition(self, session: Session, in_chat: bool) -> Iterato
343355
session=session,
344356
log=intent_log,
345357
full_message=[{'type': msg.type, 'content': msg.content} for msg in self.intent_message],
358+
reasoning_content=full_thinking_text,
346359
token_usage=token_usage
347360
)
348361

@@ -413,12 +426,30 @@ def _handle_intent_recognition(self, session: Session, in_chat: bool) -> Iterato
413426
)
414427

415428
# 4. LLM 调用 (澄清器)
429+
# 在澄清器思考内容前添加分隔线
430+
if in_chat:
431+
yield 'data:' + orjson.dumps({
432+
'content': '',
433+
'reasoning_content': '\n\n---\n\n',
434+
'type': 'intent-result'
435+
}).decode() + '\n\n'
436+
416437
clarify_text = ""
438+
clarify_thinking_text = ""
417439
clarify_token_usage = {}
418440
clarify_res = process_stream(self.llm.stream(clarification_msgs), clarify_token_usage)
419441
for chunk in clarify_res:
420442
if chunk.get('content'):
421443
clarify_text += chunk.get('content')
444+
if chunk.get('reasoning_content'):
445+
clarify_thinking_text += chunk.get('reasoning_content')
446+
# 流式输出澄清器思考过程
447+
if in_chat and chunk.get('reasoning_content'):
448+
yield 'data:' + orjson.dumps({
449+
'content': '',
450+
'reasoning_content': chunk.get('reasoning_content'),
451+
'type': 'intent-result'
452+
}).decode() + '\n\n'
422453

423454
# 解析澄清结果
424455
clarify_json = extract_nested_json(clarify_text)
@@ -429,6 +460,7 @@ def _handle_intent_recognition(self, session: Session, in_chat: bool) -> Iterato
429460
session=session,
430461
log=clarify_log,
431462
full_message=[{'type': msg.type, 'content': msg.content} for msg in clarification_msgs],
463+
reasoning_content=clarify_thinking_text,
432464
token_usage=clarify_token_usage
433465
)
434466
self._intent_result = {'rewritten_query': self.chat_question.question}
@@ -442,6 +474,7 @@ def _handle_intent_recognition(self, session: Session, in_chat: bool) -> Iterato
442474
session=session,
443475
log=clarify_log,
444476
full_message=[{'type': msg.type, 'content': msg.content} for msg in clarification_msgs],
477+
reasoning_content=clarify_thinking_text,
445478
token_usage=clarify_token_usage
446479
)
447480
self._intent_result = {'rewritten_query': self.chat_question.question}
@@ -453,6 +486,7 @@ def _handle_intent_recognition(self, session: Session, in_chat: bool) -> Iterato
453486
session=session,
454487
log=clarify_log,
455488
full_message=[{'type': msg.type, 'content': msg.content} for msg in clarification_msgs],
489+
reasoning_content=clarify_thinking_text,
456490
token_usage=clarify_token_usage
457491
)
458492

frontend/src/api/chat.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -53,6 +53,7 @@ export class ChatRecord {
5353
predict_record_id?: number
5454
regenerate_record_id?: number
5555
intent_response?: string // 意图识别响应(OTHER/澄清)
56+
intent_answer?: string // 意图识别思考内容
5657

5758
constructor()
5859
constructor(
@@ -271,6 +272,7 @@ const toChatRecord = (data?: any): ChatRecord | undefined => {
271272
data.regenerate_record_id
272273
)
273274
record.intent_response = data.intent_response
275+
record.intent_answer = data.intent_answer
274276
return record
275277
}
276278
const toChatRecordList = (list: any = []): ChatRecord[] => {

frontend/src/views/chat/answer/BaseAnswer.vue

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -16,7 +16,8 @@ const props = withDefaults(
1616
| 'chart_answer'
1717
| 'analysis_thinking'
1818
| 'predict'
19-
| Array<'sql_answer' | 'chart_answer' | 'analysis_thinking' | 'predict'>
19+
| 'intent_answer'
20+
| Array<'sql_answer' | 'chart_answer' | 'analysis_thinking' | 'predict' | 'intent_answer'>
2021
}>(),
2122
{
2223
loading: false,
@@ -30,7 +31,7 @@ const chatConfig = useChatConfigStore()
3031
const show = ref<boolean>(false)
3132
3233
const reasoningContent = computed<Array<string>>(() => {
33-
const names: Array<'sql_answer' | 'chart_answer' | 'analysis_thinking' | 'predict'> = []
34+
const names: Array<'sql_answer' | 'chart_answer' | 'analysis_thinking' | 'predict' | 'intent_answer'> = []
3435
if (typeof props.reasoningName === 'string') {
3536
names.push(props.reasoningName)
3637
} else {

frontend/src/views/chat/answer/ChartAnswer.vue

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -13,7 +13,7 @@ const props = withDefaults(
1313
currentChat?: ChatInfo
1414
message?: ChatMessage
1515
loading?: boolean
16-
reasoningName: 'sql_answer' | 'chart_answer' | Array<'sql_answer' | 'chart_answer'>
16+
reasoningName: 'sql_answer' | 'chart_answer' | 'intent_answer' | Array<'sql_answer' | 'chart_answer' | 'intent_answer'>
1717
}>(),
1818
{
1919
recordId: undefined,
@@ -113,6 +113,7 @@ const sendMessage = async () => {
113113
114114
let sql_answer = ''
115115
let chart_answer = ''
116+
let intent_answer = ''
116117
117118
let tempResult = ''
118119
@@ -187,6 +188,10 @@ const sendMessage = async () => {
187188
currentRecord.error = data.content
188189
emits('error')
189190
break
191+
case 'intent-result':
192+
intent_answer += data.reasoning_content
193+
_currentChat.value.records[index.value].intent_answer = intent_answer
194+
break
190195
case 'sql-result':
191196
sql_answer += data.reasoning_content
192197
_currentChat.value.records[index.value].sql_answer = sql_answer

frontend/src/views/chat/index.vue

Lines changed: 17 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -231,7 +231,7 @@
231231
:record-id="message.record?.id"
232232
:loading="isTyping"
233233
:message="message"
234-
:reasoning-name="['sql_answer', 'chart_answer']"
234+
:reasoning-name="['intent_answer', 'sql_answer', 'chart_answer']"
235235
@scroll-bottom="scrollToBottom"
236236
@finish="onChartAnswerFinish"
237237
@error="onChartAnswerError"
@@ -565,6 +565,8 @@ let scrollTime: any
565565
let scrollingTime: any
566566
let scrollTopVal = 0
567567
let scrolling = false
568+
let userScrolledAway = false // 用户是否主动滚动离开底部
569+
568570
const scrollBottom = () => {
569571
if (scrolling) return
570572
if (!isTyping.value && !getRecommendQuestionsLoading.value) {
@@ -584,22 +586,25 @@ const handleScroll = (val: any) => {
584586
scrollingTime = setTimeout(() => {
585587
scrolling = false
586588
}, 400)
587-
if (
588-
scrollTopVal + 200 <
589-
innerRef.value!.clientHeight - (document.querySelector('.chat-record-list')!.clientHeight - 20)
590-
) {
589+
590+
const threshold =
591+
innerRef.value!.clientHeight -
592+
(document.querySelector('.chat-record-list')!.clientHeight - 20)
593+
const isNearBottom = scrollTopVal + 50 >= threshold
594+
595+
// 用户滚动离开底部时,标记并停止自动滚动
596+
if (!isNearBottom) {
597+
userScrolledAway = true
591598
clearInterval(scrollTime)
592599
scrollTime = null
593600
return
594601
}
595602
596-
if (
597-
!scrollTime &&
598-
isTyping.value &&
599-
scrollTopVal + 30 <
600-
innerRef.value!.clientHeight -
601-
(document.querySelector('.chat-record-list')!.clientHeight - 20)
602-
) {
603+
// 用户滚回底部时,重置标记
604+
userScrolledAway = false
605+
606+
// 只有用户在底部、没有主动滚走、且正在输入时才启动自动滚动
607+
if (!scrollTime && isTyping.value && !userScrolledAway) {
603608
scrollTime = setInterval(() => {
604609
scrollBottom()
605610
}, 300)

0 commit comments

Comments
 (0)