Skip to content
Merged
Show file tree
Hide file tree
Changes from 4 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions docs/docs.go
Original file line number Diff line number Diff line change
Expand Up @@ -8781,6 +8781,9 @@ const docTemplate = `{
"helpful": {
"type": "integer"
},
"reasoning_content": {
"type": "string"
},
"role": {
"type": "string"
},
Expand Down
3 changes: 3 additions & 0 deletions docs/swagger.json
Original file line number Diff line number Diff line change
Expand Up @@ -8754,6 +8754,9 @@
"helpful": {
"type": "integer"
},
"reasoning_content": {
"type": "string"
},
"role": {
"type": "string"
},
Expand Down
2 changes: 2 additions & 0 deletions docs/swagger.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -228,6 +228,8 @@ definitions:
type: integer
helpful:
type: integer
reasoning_content:
type: string
role:
type: string
unhelpful:
Expand Down
2 changes: 2 additions & 0 deletions i18n/en_US.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
74 changes: 52 additions & 22 deletions internal/controller/ai_controller.go
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down Expand Up @@ -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,
})
}

Expand All @@ -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
}
Expand All @@ -471,19 +473,20 @@ 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()
}()

var currentToolCalls []openai.ToolCall
var accumulatedContent strings.Builder
var accumulatedReasoning strings.Builder
var accumulatedMessage openai.ChatCompletionMessage
toolCallsMap := make(map[int]*openai.ToolCall)

Expand Down Expand Up @@ -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)

Expand All @@ -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)
}
Expand All @@ -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 == "" {
Expand All @@ -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)

Expand Down
1 change: 1 addition & 0 deletions internal/entity/ai_conversation_record.go
Original file line number Diff line number Diff line change
Expand Up @@ -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"`
}
Expand Down
1 change: 1 addition & 0 deletions internal/migrations/migrations.go
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down
39 changes: 39 additions & 0 deletions internal/migrations/v33.go
Original file line number Diff line number Diff line change
@@ -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
}
1 change: 1 addition & 0 deletions internal/schema/ai_conversation_schema.go
Original file line number Diff line number Diff line change
Expand Up @@ -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"`
Expand Down
9 changes: 9 additions & 0 deletions internal/service/ai_conversation/ai_conversation_service.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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 {
Expand All @@ -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,
}
Expand Down Expand Up @@ -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(),
Expand Down Expand Up @@ -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(),
Expand Down
1 change: 1 addition & 0 deletions ui/src/common/interface.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down
38 changes: 38 additions & 0 deletions ui/src/components/BubbleAi/index.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -33,6 +33,7 @@ interface IProps {
isLast: boolean;
isCompleted: boolean;
content: string;
reasoningContent?: string;
minHeight?: number;
actionData: {
helpful: number;
Expand All @@ -45,6 +46,7 @@ const BubbleAi: FC<IProps> = ({
isLast,
isCompleted,
content,
reasoningContent = '',
chatId = '',
actionData,
minHeight = 0,
Expand All @@ -55,6 +57,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 typewriterRef = useRef<{
timer: NodeJS.Timeout | null;
index: number;
Expand Down Expand Up @@ -199,6 +202,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) {
htmlRender(fmtContainer.current, {
Expand All @@ -219,6 +230,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}
Expand Down
Loading
Loading