Skip to content
Open
Show file tree
Hide file tree
Changes from all 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
102 changes: 72 additions & 30 deletions internal/api/chat/create_conversation_message_stream_v2.go
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,8 @@ package chat

import (
"context"
"time"

"paperdebugger/internal/api/mapper"
"paperdebugger/internal/libs/contextutil"
"paperdebugger/internal/libs/shared"
Expand Down Expand Up @@ -137,7 +139,7 @@ func (s *ChatServerV2) createConversation(
}

// appendConversationMessage appends a message to the conversation and writes it to the database
// Returns the Conversation object
// Returns the Conversation object and the active branch
func (s *ChatServerV2) appendConversationMessage(
ctx context.Context,
userId bson.ObjectID,
Expand All @@ -146,52 +148,79 @@ func (s *ChatServerV2) appendConversationMessage(
userSelectedText string,
surrounding string,
conversationType chatv2.ConversationType,
) (*models.Conversation, error) {
parentMessageId string,
) (*models.Conversation, *models.Branch, error) {
objectID, err := bson.ObjectIDFromHex(conversationId)
if err != nil {
return nil, err
return nil, nil, err
}

conversation, err := s.chatServiceV2.GetConversationV2(ctx, userId, objectID)
if err != nil {
return nil, err
return nil, nil, err
}

// Ensure branches are initialized (migrate legacy data if needed)
conversation.EnsureBranches()

var activeBranch *models.Branch

// Handle branching / edit mode
if parentMessageId != "" {
// Create a new branch for the edit
activeBranch = conversation.CreateNewBranch("", parentMessageId)
if activeBranch == nil {
return nil, nil, shared.ErrBadRequest("Failed to create new branch")
}
} else {
// Normal append - use active (latest) branch
activeBranch = conversation.GetActiveBranch()
if activeBranch == nil {
// This shouldn't happen after EnsureBranches, but handle it
return nil, nil, shared.ErrBadRequest("No active branch found")
}
}

// Now we get the branch, we can append the message to the branch.
userMsg, userOaiMsg, err := s.buildUserMessage(ctx, userMessage, userSelectedText, surrounding, conversationType)
if err != nil {
return nil, err
return nil, nil, err
}

bsonMsg, err := convertToBSONV2(userMsg)
if err != nil {
return nil, err
return nil, nil, err
}
conversation.InappChatHistory = append(conversation.InappChatHistory, bsonMsg)
conversation.OpenaiChatHistoryCompletion = append(conversation.OpenaiChatHistoryCompletion, userOaiMsg)

// Append to the active branch
activeBranch.InappChatHistory = append(activeBranch.InappChatHistory, bsonMsg)
activeBranch.OpenaiChatHistoryCompletion = append(activeBranch.OpenaiChatHistoryCompletion, userOaiMsg)
activeBranch.UpdatedAt = bson.NewDateTimeFromTime(time.Now())

if err := s.chatServiceV2.UpdateConversationV2(conversation); err != nil {
return nil, err
return nil, nil, err
}

return conversation, nil
return conversation, activeBranch, nil
}

// prepare creates a new conversation if conversationId is "", otherwise appends a message to the conversation
// conversationType can be switched multiple times within a single conversation
func (s *ChatServerV2) prepare(ctx context.Context, projectId string, conversationId string, userMessage string, userSelectedText string, surrounding string, modelSlug string, conversationType chatv2.ConversationType) (context.Context, *models.Conversation, *models.Settings, error) {
// Returns: context, conversation, activeBranch, settings, error
func (s *ChatServerV2) prepare(ctx context.Context, projectId string, conversationId string, userMessage string, userSelectedText string, surrounding string, modelSlug string, conversationType chatv2.ConversationType, parentMessageId string) (context.Context, *models.Conversation, *models.Branch, *models.Settings, error) {
actor, err := contextutil.GetActor(ctx)
if err != nil {
return ctx, nil, nil, err
return ctx, nil, nil, nil, err
}

project, err := s.projectService.GetProject(ctx, actor.ID, projectId)
if err != nil && err != mongo.ErrNoDocuments {
return ctx, nil, nil, err
return ctx, nil, nil, nil, err
}

userInstructions, err := s.userService.GetUserInstructions(ctx, actor.ID)
if err != nil {
return ctx, nil, nil, err
return ctx, nil, nil, nil, err
}

var latexFullSource string
Expand All @@ -200,18 +229,20 @@ func (s *ChatServerV2) prepare(ctx context.Context, projectId string, conversati
latexFullSource = "latex_full_source is not available in debug mode"
default:
if project == nil || project.IsOutOfDate() {
return ctx, nil, nil, shared.ErrProjectOutOfDate("project is out of date")
return ctx, nil, nil, nil, shared.ErrProjectOutOfDate("project is out of date")
}

latexFullSource, err = project.GetFullContent()
if err != nil {
return ctx, nil, nil, err
return ctx, nil, nil, nil, err
}
}

var conversation *models.Conversation
var activeBranch *models.Branch

if conversationId == "" {
// Create a new conversation
conversation, err = s.createConversation(
ctx,
actor.ID,
Expand All @@ -225,31 +256,38 @@ func (s *ChatServerV2) prepare(ctx context.Context, projectId string, conversati
modelSlug,
conversationType,
)
if err != nil {
return ctx, nil, nil, nil, err
}
// For new conversations, ensure branches and get the active one
conversation.EnsureBranches()
activeBranch = conversation.GetActiveBranch()
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Missing nil check for activeBranch in new conversations

High Severity

For new conversations, GetActiveBranch() is called without a nil check after EnsureBranches(). This is inconsistent with the existing conversation path in appendConversationMessage, which properly checks for nil at lines 175-179. If GetActiveBranch() returns nil (e.g., if EnsureBranches() doesn't create a branch), accessing activeBranch.OpenaiChatHistoryCompletion at line 319 will cause a nil pointer dereference and crash the server.

Additional Locations (1)

Fix in Cursor Fix in Web

} else {
conversation, err = s.appendConversationMessage(
// Append to an existing conversation
conversation, activeBranch, err = s.appendConversationMessage(
ctx,
actor.ID,
conversationId,
userMessage,
userSelectedText,
surrounding,
conversationType,
parentMessageId,
)
}

if err != nil {
return ctx, nil, nil, err
if err != nil {
return ctx, nil, nil, nil, err
}
}

ctx = contextutil.SetProjectID(ctx, conversation.ProjectID)
ctx = contextutil.SetConversationID(ctx, conversation.ID.Hex())

settings, err := s.userService.GetUserSettings(ctx, actor.ID)
if err != nil {
return ctx, conversation, nil, err
return ctx, conversation, activeBranch, nil, err
}

return ctx, conversation, settings, nil
return ctx, conversation, activeBranch, settings, nil
}

func (s *ChatServerV2) CreateConversationMessageStream(
Expand All @@ -259,15 +297,17 @@ func (s *ChatServerV2) CreateConversationMessageStream(
ctx := stream.Context()

modelSlug := req.GetModelSlug()
ctx, conversation, settings, err := s.prepare(
ctx, conversation, activeBranch, settings, err := s.prepare(
ctx,
req.GetProjectId(),
req.GetConversationId(),
req.GetUserMessage(),
req.GetUserSelectedText(),

req.GetSurrounding(),
modelSlug,
req.GetConversationType(),
req.GetParentMessageId(),
)
if err != nil {
return s.sendStreamError(stream, err)
Expand All @@ -278,12 +318,13 @@ func (s *ChatServerV2) CreateConversationMessageStream(
APIKey: settings.OpenAIAPIKey,
}

openaiChatHistory, inappChatHistory, err := s.aiClientV2.ChatCompletionStreamV2(ctx, stream, conversation.ID.Hex(), modelSlug, conversation.OpenaiChatHistoryCompletion, llmProvider)
// Use active branch's history for the LLM call
openaiChatHistory, inappChatHistory, err := s.aiClientV2.ChatCompletionStreamV2(ctx, stream, conversation.ID.Hex(), modelSlug, activeBranch.OpenaiChatHistoryCompletion, llmProvider)
if err != nil {
return s.sendStreamError(stream, err)
}

// Append messages to the conversation
// Append messages to the active branch
bsonMessages := make([]bson.M, len(inappChatHistory))
for i := range inappChatHistory {
bsonMsg, err := convertToBSONV2(&inappChatHistory[i])
Expand All @@ -292,16 +333,17 @@ func (s *ChatServerV2) CreateConversationMessageStream(
}
bsonMessages[i] = bsonMsg
Comment on lines 333 to 334
Copy link

Copilot AI Jan 9, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The branch's UpdatedAt timestamp is not being updated when AI response messages are appended to it. This means GetActiveBranch() will not correctly identify the most recently modified branch. You should update activeBranch.UpdatedAt to the current time before saving the conversation.

Copilot uses AI. Check for mistakes.
}
conversation.InappChatHistory = append(conversation.InappChatHistory, bsonMessages...)
conversation.OpenaiChatHistoryCompletion = openaiChatHistory
activeBranch.InappChatHistory = append(activeBranch.InappChatHistory, bsonMessages...)
activeBranch.OpenaiChatHistoryCompletion = openaiChatHistory
activeBranch.UpdatedAt = bson.NewDateTimeFromTime(time.Now())
if err := s.chatServiceV2.UpdateConversationV2(conversation); err != nil {
return s.sendStreamError(stream, err)
}

if conversation.Title == services.DefaultConversationTitle {
go func() {
protoMessages := make([]*chatv2.Message, len(conversation.InappChatHistory))
for i, bsonMsg := range conversation.InappChatHistory {
protoMessages := make([]*chatv2.Message, len(activeBranch.InappChatHistory))
for i, bsonMsg := range activeBranch.InappChatHistory {
protoMessages[i] = mapper.BSONToChatMessageV2(bsonMsg)
}
title, err := s.aiClientV2.GetConversationTitleV2(ctx, protoMessages, llmProvider)
Expand Down
5 changes: 4 additions & 1 deletion internal/api/chat/get_conversation_v2.go
Original file line number Diff line number Diff line change
Expand Up @@ -29,7 +29,10 @@ func (s *ChatServerV2) GetConversation(
return nil, err
}

// Use specified branch_id if provided, otherwise use active branch
branchID := req.GetBranchId()

return &chatv2.GetConversationResponse{
Conversation: mapper.MapModelConversationToProtoV2(conversation),
Conversation: mapper.MapModelConversationToProtoV2WithBranch(conversation, branchID),
Comment on lines +32 to +36
Copy link

Copilot AI Jan 9, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Missing input validation for branchId parameter. The code should validate that the provided branchId exists in the conversation's branches before attempting to use it. Currently, if an invalid branchId is provided, the code will silently fall back to the active branch without indicating an error to the client.

Copilot uses AI. Check for mistakes.
}, nil
}
34 changes: 33 additions & 1 deletion internal/api/chat/list_supported_models_v2.go
Original file line number Diff line number Diff line change
Expand Up @@ -27,12 +27,20 @@ func (s *ChatServerV2) ListSupportedModels(
var models []*chatv2.SupportedModel
if strings.TrimSpace(settings.OpenAIAPIKey) == "" {
models = []*chatv2.SupportedModel{
{
Name: "GPT-5.1",
Slug: "openai/gpt-5.1",
TotalContext: 400000,
MaxOutput: 128000,
InputPrice: 125, // $1.25
OutputPrice: 1000, // $10.00
},
{
Name: "GPT-4.1",
Slug: "openai/gpt-4.1",
TotalContext: 1050000,
MaxOutput: 32800,
InputPrice: 200,
InputPrice: 200, // $2.00
OutputPrice: 800,
},
{
Expand Down Expand Up @@ -78,6 +86,30 @@ func (s *ChatServerV2) ListSupportedModels(
}
} else {
models = []*chatv2.SupportedModel{
{
Name: "GPT-5.2 Pro",
Slug: openai.ChatModelGPT5_2Pro,
TotalContext: 400000,
MaxOutput: 128000,
InputPrice: 2100, // $21.00
OutputPrice: 16800, // $168.00
},
{
Name: "GPT-5.2",
Slug: openai.ChatModelGPT5_2,
TotalContext: 400000,
MaxOutput: 128000,
InputPrice: 175, // $1.75
OutputPrice: 1400, // $14.00
},
{
Name: "GPT-5.1",
Slug: openai.ChatModelGPT5_1,
TotalContext: 400000,
MaxOutput: 128000,
InputPrice: 125, // $1.25
OutputPrice: 1000, // $10.00
},
{
Name: "GPT-4.1",
Slug: openai.ChatModelGPT4_1,
Expand Down
59 changes: 54 additions & 5 deletions internal/api/mapper/conversation_v2.go
Original file line number Diff line number Diff line change
Expand Up @@ -22,9 +22,45 @@ func BSONToChatMessageV2(msg bson.M) *chatv2.Message {
return m
}

// MapModelConversationToProtoV2 converts a conversation model to proto.
// Uses the active branch by default, or the specified branchID if provided.
func MapModelConversationToProtoV2(conversation *models.Conversation) *chatv2.Conversation {
return MapModelConversationToProtoV2WithBranch(conversation, "")
}

// MapModelConversationToProtoV2WithBranch converts a conversation model to proto
// with explicit branch selection. If branchID is empty, uses the active branch.
func MapModelConversationToProtoV2WithBranch(conversation *models.Conversation, branchID string) *chatv2.Conversation {
// Ensure branches are initialized (migrate legacy data if needed)
conversation.EnsureBranches()

// Determine which branch to use
var selectedBranch *models.Branch
var currentBranchID string
var currentBranchIndex int32

if branchID != "" {
selectedBranch = conversation.GetBranchByID(branchID)
}
if selectedBranch == nil {
selectedBranch = conversation.GetActiveBranch()
}

// Get messages from the selected branch or use legacy fallback
var inappHistory []bson.M
if selectedBranch != nil {
inappHistory = selectedBranch.InappChatHistory
currentBranchID = selectedBranch.ID
currentBranchIndex = int32(conversation.GetBranchIndex(selectedBranch.ID))
} else {
// Fallback to legacy fields (should not happen after EnsureBranches)
inappHistory = conversation.InappChatHistory
currentBranchID = ""
currentBranchIndex = 1
}

// Convert BSON messages back to protobuf messages
filteredMessages := lo.Map(conversation.InappChatHistory, func(msg bson.M, _ int) *chatv2.Message {
filteredMessages := lo.Map(inappHistory, func(msg bson.M, _ int) *chatv2.Message {
return BSONToChatMessageV2(msg)
})

Expand All @@ -37,10 +73,23 @@ func MapModelConversationToProtoV2(conversation *models.Conversation) *chatv2.Co
modelSlug = models.SlugFromLanguageModel(models.LanguageModel(conversation.LanguageModel))
}

// Build branch info list
branches := lo.Map(conversation.Branches, func(b models.Branch, _ int) *chatv2.BranchInfo {
return &chatv2.BranchInfo{
Id: b.ID,
CreatedAt: int64(b.CreatedAt),
UpdatedAt: int64(b.UpdatedAt),
}
})

return &chatv2.Conversation{
Id: conversation.ID.Hex(),
Title: conversation.Title,
ModelSlug: modelSlug,
Messages: filteredMessages,
Id: conversation.ID.Hex(),
Title: conversation.Title,
ModelSlug: modelSlug,
Messages: filteredMessages,
CurrentBranchId: currentBranchID,
Branches: branches,
CurrentBranchIndex: currentBranchIndex,
TotalBranches: int32(len(conversation.Branches)),
}
}
Loading