You are viewing a plain text version of this content. The canonical link for it is here.
Posted to commits@answer.apache.org by li...@apache.org on 2026/05/30 07:34:00 UTC
(answer) branch dev updated: feat: add reasoning content to AI conversation records and update related components (#1530)
This is an automated email from the ASF dual-hosted git repository.
LinkinStars pushed a commit to branch dev
in repository https://gitbox.apache.org/repos/asf/answer.git
The following commit(s) were added to refs/heads/dev by this push:
new 7c210a48 feat: add reasoning content to AI conversation records and update related components (#1530)
7c210a48 is described below
commit 7c210a4855270ef0b91d8e5471479b04f24c65ae
Author: Luke Gao <dh...@hotmail.com>
AuthorDate: Sat May 30 15:33:54 2026 +0800
feat: add reasoning content to AI conversation records and update related components (#1530)
Fix #1524
Root cause
DeepSeek's reasoning models stream reasoning_content alongside content.
Answer ignored it, so follow-up requests failed with 400: The
reasoning_content in the thinking mode must be passed back to the API,
and the thinking text was never shown or saved.
Fix
- Capture reasoning_content from the stream and pass it back to theAPI
on subsequent rounds.
- Persist it with the conversation (new DB column via migrationv2.0.2).
- Render it in the chat UI as a collapsible "Thinking…/Thoughts"panel
above the answer.
Compatibility
Nullable column, omitempty field, UI hides the panel when empty — old
conversations and non-reasoning models behave exactly as before.
Demo
https://github.com/user-attachments/assets/49b1a2a1-9133-4ac2-bbeb-860215a50285
---
docs/docs.go | 3 +
docs/swagger.json | 3 +
docs/swagger.yaml | 2 +
i18n/en_US.yaml | 2 +
internal/controller/ai_controller.go | 74 +++++++++++++++-------
internal/entity/ai_conversation_record.go | 1 +
internal/migrations/migrations.go | 1 +
internal/migrations/v33.go | 39 ++++++++++++
internal/schema/ai_conversation_schema.go | 1 +
.../ai_conversation/ai_conversation_service.go | 9 +++
ui/src/common/interface.ts | 1 +
ui/src/components/BubbleAi/index.tsx | 38 +++++++++++
.../AiAssistant/components/DetailModal/index.tsx | 1 +
ui/src/pages/AiAssistant/index.tsx | 15 +++--
ui/src/pages/Search/components/AiCard/index.tsx | 15 +++--
15 files changed, 175 insertions(+), 30 deletions(-)
diff --git a/docs/docs.go b/docs/docs.go
index 57a23d43..03b06701 100644
--- a/docs/docs.go
+++ b/docs/docs.go
@@ -8781,6 +8781,9 @@ const docTemplate = `{
"helpful": {
"type": "integer"
},
+ "reasoning_content": {
+ "type": "string"
+ },
"role": {
"type": "string"
},
diff --git a/docs/swagger.json b/docs/swagger.json
index dac2b38f..3bfb2e3c 100644
--- a/docs/swagger.json
+++ b/docs/swagger.json
@@ -8754,6 +8754,9 @@
"helpful": {
"type": "integer"
},
+ "reasoning_content": {
+ "type": "string"
+ },
"role": {
"type": "string"
},
diff --git a/docs/swagger.yaml b/docs/swagger.yaml
index 7a7adb68..c08f1e8d 100644
--- a/docs/swagger.yaml
+++ b/docs/swagger.yaml
@@ -228,6 +228,8 @@ definitions:
type: integer
helpful:
type: integer
+ reasoning_content:
+ type: string
role:
type: string
unhelpful:
diff --git a/i18n/en_US.yaml b/i18n/en_US.yaml
index 9a0d198b..61f49650 100644
--- a/i18n/en_US.yaml
+++ b/i18n/en_US.yaml
@@ -867,6 +867,8 @@ ui:
copy: Copy
ask_a_follow_up: Ask a follow-up
ask_placeholder: Ask a question
+ thinking: Thinking…
+ thoughts: Thoughts
notifications:
title: Notifications
inbox: Inbox
diff --git a/internal/controller/ai_controller.go b/internal/controller/ai_controller.go
index 125cdab2..e7495253 100644
--- a/internal/controller/ai_controller.go
+++ b/internal/controller/ai_controller.go
@@ -143,8 +143,9 @@ type StreamChoice struct {
}
type Delta struct {
- Role string `json:"role,omitempty"`
- Content string `json:"content,omitempty"`
+ Role string `json:"role,omitempty"`
+ Content string `json:"content,omitempty"`
+ ReasoningContent string `json:"reasoning_content,omitempty"`
}
type Usage struct {
@@ -443,14 +444,15 @@ func (c *AIController) handleAIConversation(ctx *gin.Context, w http.ResponseWri
Stream: true,
}
- toolCalls, newMessages, finished, aiResponse := c.processAIStream(ctx, w, id, conversationCtx.Model, client, aiReq, messages)
+ toolCalls, newMessages, finished, aiResponse, reasoningContent := c.processAIStream(ctx, w, id, conversationCtx.Model, client, aiReq, messages)
messages = newMessages
log.Debugf("Round %d: toolCalls=%v", round+1, toolCalls)
- if aiResponse != "" {
+ if aiResponse != "" || reasoningContent != "" {
conversationCtx.Messages = append(conversationCtx.Messages, &ai_conversation.ConversationMessage{
- Role: "assistant",
- Content: aiResponse,
+ Role: "assistant",
+ Content: aiResponse,
+ ReasoningContent: reasoningContent,
})
}
@@ -459,7 +461,7 @@ func (c *AIController) handleAIConversation(ctx *gin.Context, w http.ResponseWri
}
if len(toolCalls) > 0 {
- messages = c.executeToolCalls(ctx, w, id, conversationCtx.Model, toolCalls, messages)
+ messages = c.executeToolCalls(ctx, w, id, conversationCtx.Model, toolCalls, messages, aiResponse, reasoningContent)
} else {
return
}
@@ -471,12 +473,12 @@ func (c *AIController) handleAIConversation(ctx *gin.Context, w http.ResponseWri
// processAIStream
func (c *AIController) processAIStream(
_ *gin.Context, w http.ResponseWriter, id, model string, client *openai.Client, aiReq openai.ChatCompletionRequest, messages []openai.ChatCompletionMessage) (
- []openai.ToolCall, []openai.ChatCompletionMessage, bool, string) {
+ []openai.ToolCall, []openai.ChatCompletionMessage, bool, string, string) {
stream, err := client.CreateChatCompletionStream(context.Background(), aiReq)
if err != nil {
log.Errorf("Failed to create stream: %v", err)
c.sendErrorResponse(w, id, model, "Failed to create AI stream")
- return nil, messages, true, ""
+ return nil, messages, true, "", ""
}
defer func() {
_ = stream.Close()
@@ -484,6 +486,7 @@ func (c *AIController) processAIStream(
var currentToolCalls []openai.ToolCall
var accumulatedContent strings.Builder
+ var accumulatedReasoning strings.Builder
var accumulatedMessage openai.ChatCompletionMessage
toolCallsMap := make(map[int]*openai.ToolCall)
@@ -528,6 +531,27 @@ func (c *AIController) processAIStream(
}
}
+ if choice.Delta.ReasoningContent != "" {
+ accumulatedReasoning.WriteString(choice.Delta.ReasoningContent)
+
+ reasoningResponse := StreamResponse{
+ ChatCompletionID: id,
+ Object: "chat.completion.chunk",
+ Created: time.Now().Unix(),
+ Model: model,
+ Choices: []StreamChoice{
+ {
+ Index: 0,
+ Delta: Delta{
+ ReasoningContent: choice.Delta.ReasoningContent,
+ },
+ FinishReason: nil,
+ },
+ },
+ }
+ sendStreamData(w, reasoningResponse)
+ }
+
if choice.Delta.Content != "" {
accumulatedContent.WriteString(choice.Delta.Content)
@@ -554,26 +578,30 @@ func (c *AIController) processAIStream(
for _, toolCall := range toolCallsMap {
currentToolCalls = append(currentToolCalls, *toolCall)
}
- return currentToolCalls, messages, false, accumulatedContent.String()
+ return currentToolCalls, messages, false, accumulatedContent.String(), accumulatedReasoning.String()
} else {
aiResponseContent := accumulatedContent.String()
- if aiResponseContent != "" {
+ aiReasoningContent := accumulatedReasoning.String()
+ if aiResponseContent != "" || aiReasoningContent != "" {
accumulatedMessage = openai.ChatCompletionMessage{
- Role: openai.ChatMessageRoleAssistant,
- Content: aiResponseContent,
+ Role: openai.ChatMessageRoleAssistant,
+ Content: aiResponseContent,
+ ReasoningContent: aiReasoningContent,
}
messages = append(messages, accumulatedMessage)
}
- return nil, messages, true, aiResponseContent
+ return nil, messages, true, aiResponseContent, aiReasoningContent
}
}
}
aiResponseContent := accumulatedContent.String()
- if aiResponseContent != "" {
+ aiReasoningContent := accumulatedReasoning.String()
+ if aiResponseContent != "" || aiReasoningContent != "" {
accumulatedMessage = openai.ChatCompletionMessage{
- Role: openai.ChatMessageRoleAssistant,
- Content: aiResponseContent,
+ Role: openai.ChatMessageRoleAssistant,
+ Content: aiResponseContent,
+ ReasoningContent: aiReasoningContent,
}
messages = append(messages, accumulatedMessage)
}
@@ -582,14 +610,14 @@ func (c *AIController) processAIStream(
for _, toolCall := range toolCallsMap {
currentToolCalls = append(currentToolCalls, *toolCall)
}
- return currentToolCalls, messages, false, aiResponseContent
+ return currentToolCalls, messages, false, aiResponseContent, aiReasoningContent
}
- return currentToolCalls, messages, len(currentToolCalls) == 0, aiResponseContent
+ return currentToolCalls, messages, len(currentToolCalls) == 0, aiResponseContent, aiReasoningContent
}
// executeToolCalls
-func (c *AIController) executeToolCalls(ctx *gin.Context, _ http.ResponseWriter, _, _ string, toolCalls []openai.ToolCall, messages []openai.ChatCompletionMessage) []openai.ChatCompletionMessage {
+func (c *AIController) executeToolCalls(ctx *gin.Context, _ http.ResponseWriter, _, _ string, toolCalls []openai.ToolCall, messages []openai.ChatCompletionMessage, assistantContent, reasoningContent string) []openai.ChatCompletionMessage {
validToolCalls := make([]openai.ToolCall, 0)
for _, toolCall := range toolCalls {
if toolCall.ID == "" || toolCall.Function.Name == "" {
@@ -611,8 +639,10 @@ func (c *AIController) executeToolCalls(ctx *gin.Context, _ http.ResponseWriter,
}
assistantMsg := openai.ChatCompletionMessage{
- Role: openai.ChatMessageRoleAssistant,
- ToolCalls: validToolCalls,
+ Role: openai.ChatMessageRoleAssistant,
+ Content: assistantContent,
+ ReasoningContent: reasoningContent,
+ ToolCalls: validToolCalls,
}
messages = append(messages, assistantMsg)
diff --git a/internal/entity/ai_conversation_record.go b/internal/entity/ai_conversation_record.go
index 14dea347..da8f11b1 100644
--- a/internal/entity/ai_conversation_record.go
+++ b/internal/entity/ai_conversation_record.go
@@ -30,6 +30,7 @@ type AIConversationRecord struct {
ChatCompletionID string `xorm:"not null VARCHAR(255) chat_completion_id"`
Role string `xorm:"not null default '' VARCHAR(128) role"`
Content string `xorm:"not null MEDIUMTEXT content"`
+ ReasoningContent string `xorm:"MEDIUMTEXT reasoning_content"`
Helpful int `xorm:"not null default 0 INT(11) helpful"`
Unhelpful int `xorm:"not null default 0 INT(11) unhelpful"`
}
diff --git a/internal/migrations/migrations.go b/internal/migrations/migrations.go
index 682a7b20..7fca2d50 100644
--- a/internal/migrations/migrations.go
+++ b/internal/migrations/migrations.go
@@ -108,6 +108,7 @@ var migrations = []Migration{
NewMigration("v1.8.0", "change admin menu", updateAdminMenuSettings, true),
NewMigration("v1.8.1", "ai feat", aiFeat, true),
NewMigration("v2.0.1", "change avatar type to text", updateAvatarType, false),
+ NewMigration("v2.0.2", "add reasoning content to ai conversation record", addAIConversationReasoningContent, false),
}
func GetMigrations() []Migration {
diff --git a/internal/migrations/v33.go b/internal/migrations/v33.go
new file mode 100644
index 00000000..810c2101
--- /dev/null
+++ b/internal/migrations/v33.go
@@ -0,0 +1,39 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one
+ * or more contributor license agreements. See the NOTICE file
+ * distributed with this work for additional information
+ * regarding copyright ownership. The ASF licenses this file
+ * to you under the Apache License, Version 2.0 (the
+ * "License"); you may not use this file except in compliance
+ * with the License. You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing,
+ * software distributed under the License is distributed on an
+ * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+ * KIND, either express or implied. See the License for the
+ * specific language governing permissions and limitations
+ * under the License.
+ */
+
+package migrations
+
+import (
+ "context"
+ "fmt"
+
+ "github.com/apache/answer/internal/entity"
+ "xorm.io/xorm"
+)
+
+// addAIConversationReasoningContent adds a reasoning_content column to the
+// ai_conversation_record table so that the chain-of-thought returned by
+// reasoning/thinking-capable models (e.g. DeepSeek) is persisted along with
+// the regular content and can be re-displayed when reloading a conversation.
+func addAIConversationReasoningContent(ctx context.Context, x *xorm.Engine) error {
+ if err := x.Context(ctx).Sync(new(entity.AIConversationRecord)); err != nil {
+ return fmt.Errorf("sync ai_conversation_record table failed: %w", err)
+ }
+ return nil
+}
diff --git a/internal/schema/ai_conversation_schema.go b/internal/schema/ai_conversation_schema.go
index fd34278a..60ec5d74 100644
--- a/internal/schema/ai_conversation_schema.go
+++ b/internal/schema/ai_conversation_schema.go
@@ -48,6 +48,7 @@ type AIConversationRecord struct {
ChatCompletionID string `json:"chat_completion_id"`
Role string `json:"role"`
Content string `json:"content"`
+ ReasoningContent string `json:"reasoning_content,omitempty"`
Helpful int `json:"helpful"`
Unhelpful int `json:"unhelpful"`
CreatedAt int64 `json:"created_at"`
diff --git a/internal/service/ai_conversation/ai_conversation_service.go b/internal/service/ai_conversation/ai_conversation_service.go
index d095ac0e..b7ddc6b1 100644
--- a/internal/service/ai_conversation/ai_conversation_service.go
+++ b/internal/service/ai_conversation/ai_conversation_service.go
@@ -51,6 +51,7 @@ type ConversationMessage struct {
ChatCompletionID string `json:"chat_completion_id"`
Role string `json:"role"`
Content string `json:"content"`
+ ReasoningContent string `json:"reasoning_content,omitempty"`
}
// aiConversationService
@@ -97,6 +98,7 @@ func (s *aiConversationService) SaveConversationRecords(ctx context.Context, con
}
content := strings.Builder{}
+ reasoning := strings.Builder{}
for _, record := range records {
if len(record.ChatCompletionID) > 0 {
@@ -120,12 +122,17 @@ func (s *aiConversationService) SaveConversationRecords(ctx context.Context, con
content.WriteString(record.Content)
content.WriteString("\n")
+ if record.ReasoningContent != "" {
+ reasoning.WriteString(record.ReasoningContent)
+ reasoning.WriteString("\n")
+ }
}
aiRecord := &entity.AIConversationRecord{
ConversationID: conversationID,
ChatCompletionID: chatcmplID,
Role: "assistant",
Content: content.String(),
+ ReasoningContent: reasoning.String(),
Helpful: 0,
Unhelpful: 0,
}
@@ -190,6 +197,7 @@ func (s *aiConversationService) GetConversationDetail(ctx context.Context, req *
ChatCompletionID: record.ChatCompletionID,
Role: record.Role,
Content: record.Content,
+ ReasoningContent: record.ReasoningContent,
Helpful: record.Helpful,
Unhelpful: record.Unhelpful,
CreatedAt: record.CreatedAt.Unix(),
@@ -319,6 +327,7 @@ func (s *aiConversationService) GetConversationDetailForAdmin(ctx context.Contex
ChatCompletionID: record.ChatCompletionID,
Role: record.Role,
Content: record.Content,
+ ReasoningContent: record.ReasoningContent,
Helpful: record.Helpful,
Unhelpful: record.Unhelpful,
CreatedAt: record.CreatedAt.Unix(),
diff --git a/ui/src/common/interface.ts b/ui/src/common/interface.ts
index 308726e8..dbcec6c0 100644
--- a/ui/src/common/interface.ts
+++ b/ui/src/common/interface.ts
@@ -860,6 +860,7 @@ export interface AdminConversationListItem {
export interface ConversationDetailItem {
chat_completion_id: string;
content: string;
+ reasoning_content?: string;
role: string;
helpful: number;
unhelpful: number;
diff --git a/ui/src/components/BubbleAi/index.tsx b/ui/src/components/BubbleAi/index.tsx
index 1e79ca7c..4cbd2417 100644
--- a/ui/src/components/BubbleAi/index.tsx
+++ b/ui/src/components/BubbleAi/index.tsx
@@ -32,6 +32,7 @@ interface IProps {
isLast: boolean;
isCompleted: boolean;
content: string;
+ reasoningContent?: string;
minHeight?: number;
actionData: {
helpful: number;
@@ -55,6 +56,7 @@ const BubbleAi: FC<IProps> = ({
isLast,
isCompleted,
content,
+ reasoningContent = '',
chatId = '',
actionData,
minHeight = 0,
@@ -65,6 +67,7 @@ const BubbleAi: FC<IProps> = ({
const [isHelpful, setIsHelpful] = useState(false);
const [isUnhelpful, setIsUnhelpful] = useState(false);
const [canShowAction, setCanShowAction] = useState(false);
+ const [isThinkingOpen, setIsThinkingOpen] = useState(true);
const [safeHtml, setSafeHtml] = useState('');
const typewriterRef = useRef<{
timer: NodeJS.Timeout | null;
@@ -255,6 +258,14 @@ const BubbleAi: FC<IProps> = ({
setIsUnhelpful(actionData.unhelpful > 0);
}, [actionData]);
+ // Auto-collapse the "Thinking" panel once the actual answer starts streaming
+ // (only while the message is being generated; users can still toggle manually).
+ useEffect(() => {
+ if (content && !isCompleted) {
+ setIsThinkingOpen(false);
+ }
+ }, [content, isCompleted]);
+
useEffect(() => {
if (fmtContainer.current && isCompleted && safeHtml) {
htmlRender(fmtContainer.current, {
@@ -275,6 +286,33 @@ const BubbleAi: FC<IProps> = ({
ref={containerRef}
style={{ minHeight: `${minHeight}px`, overflowAnchor: 'none' }}>
<div id={chatId}>
+ {reasoningContent ? (
+ <div
+ className="bubble-ai-thinking mb-2 border-start border-2 ps-2 small text-secondary"
+ style={{ borderColor: 'var(--bs-border-color)' }}>
+ <Button
+ variant="link"
+ className="p-0 link-secondary small text-decoration-none d-inline-flex align-items-center"
+ onClick={() => setIsThinkingOpen((v) => !v)}>
+ <Icon name={isThinkingOpen ? 'chevron-down' : 'chevron-right'} />
+ <span className="ms-1">
+ {isCompleted ? t('thoughts') : t('thinking')}
+ </span>
+ </Button>
+ {isThinkingOpen && (
+ <div
+ className="mt-1 text-secondary"
+ style={{
+ whiteSpace: 'pre-wrap',
+ fontStyle: 'italic',
+ opacity: 0.85,
+ }}>
+ {reasoningContent}
+ </div>
+ )}
+ </div>
+ ) : null}
+
<div
className="fmt text-break text-wrap"
ref={fmtContainer}
diff --git a/ui/src/pages/Admin/AiAssistant/components/DetailModal/index.tsx b/ui/src/pages/Admin/AiAssistant/components/DetailModal/index.tsx
index ec4bd7d7..02bbc682 100644
--- a/ui/src/pages/Admin/AiAssistant/components/DetailModal/index.tsx
+++ b/ui/src/pages/Admin/AiAssistant/components/DetailModal/index.tsx
@@ -64,6 +64,7 @@ const Index: FC<IProps> = ({ visible, id, onClose }) => {
isLast={false}
isCompleted
content={item.content}
+ reasoningContent={item.reasoning_content || ''}
actionData={{
helpful: item.helpful,
unhelpful: item.unhelpful,
diff --git a/ui/src/pages/AiAssistant/index.tsx b/ui/src/pages/AiAssistant/index.tsx
index 83ffe8f1..e355e804 100644
--- a/ui/src/pages/AiAssistant/index.tsx
+++ b/ui/src/pages/AiAssistant/index.tsx
@@ -154,7 +154,10 @@ const Index = () => {
await requestAi('/answer/api/v1/chat/completions', {
body: JSON.stringify(params),
onMessage: (res) => {
- if (!res.choices[0].delta?.content) {
+ const delta = res.choices[0]?.delta;
+ const deltaContent = delta?.content || '';
+ const deltaReasoning = delta?.reasoning_content || '';
+ if (!deltaContent && !deltaReasoning) {
return;
}
setIsLoading(false);
@@ -165,13 +168,16 @@ const Index = () => {
if (lastConversion?.chat_completion_id === res?.chat_completion_id) {
updatedRecords[updatedRecords.length - 1] = {
...lastConversion,
- content: lastConversion.content + res.choices[0].delta.content,
+ content: (lastConversion.content || '') + deltaContent,
+ reasoning_content:
+ (lastConversion.reasoning_content || '') + deltaReasoning,
};
} else {
updatedRecords.push({
chat_completion_id: res.chat_completion_id,
- role: res.choices[0].delta.role || 'assistant',
- content: res.choices[0].delta.content,
+ role: delta?.role || 'assistant',
+ content: deltaContent,
+ reasoning_content: deltaReasoning,
helpful: 0,
unhelpful: 0,
created_at: Date.now(),
@@ -330,6 +336,7 @@ const Index = () => {
isLast={isLastMessage}
isCompleted={!isGenerate || !isLastMessage}
content={item.content}
+ reasoningContent={item.reasoning_content || ''}
actionData={{
helpful: item.helpful,
unhelpful: item.unhelpful,
diff --git a/ui/src/pages/Search/components/AiCard/index.tsx b/ui/src/pages/Search/components/AiCard/index.tsx
index 99e21adc..c6cc699c 100644
--- a/ui/src/pages/Search/components/AiCard/index.tsx
+++ b/ui/src/pages/Search/components/AiCard/index.tsx
@@ -78,7 +78,10 @@ const Index = () => {
await requestAi('/answer/api/v1/chat/completions', {
body: JSON.stringify(params),
onMessage: (res) => {
- if (!res.choices[0].delta?.content) {
+ const delta = res.choices[0]?.delta;
+ const deltaContent = delta?.content || '';
+ const deltaReasoning = delta?.reasoning_content || '';
+ if (!deltaContent && !deltaReasoning) {
return;
}
setIsLoading(false);
@@ -90,13 +93,16 @@ const Index = () => {
if (lastConversion?.chat_completion_id === res?.chat_completion_id) {
updatedRecords[updatedRecords.length - 1] = {
...lastConversion,
- content: lastConversion.content + res.choices[0].delta.content,
+ content: (lastConversion.content || '') + deltaContent,
+ reasoning_content:
+ (lastConversion.reasoning_content || '') + deltaReasoning,
};
} else {
updatedRecords.push({
chat_completion_id: res.chat_completion_id,
- role: res.choices[0].delta.role || 'assistant',
- content: res.choices[0].delta.content,
+ role: delta?.role || 'assistant',
+ content: deltaContent,
+ reasoning_content: deltaReasoning,
helpful: 0,
unhelpful: 0,
created_at: Date.now(),
@@ -154,6 +160,7 @@ const Index = () => {
isLast={isLastMessage}
isCompleted={!isGenerate || !isLastMessage}
content={item.content}
+ reasoningContent={item.reasoning_content || ''}
actionData={{
helpful: item.helpful,
unhelpful: item.unhelpful,