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
2 changes: 2 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
@@ -1,3 +1,5 @@
.claude

# docker image build
.dockerignore
node_modules
Expand Down
7 changes: 6 additions & 1 deletion internal/api/chat/create_conversation_message_stream.go
Original file line number Diff line number Diff line change
Expand Up @@ -207,9 +207,13 @@ func (s *ChatServerV1) prepare(ctx context.Context, projectId string, conversati
}

var latexFullSource string
var projectInstructions string
switch conversationType {
case chatv1.ConversationType_CONVERSATION_TYPE_DEBUG:
latexFullSource = "latex_full_source is not available in debug mode"
if project != nil {
projectInstructions = project.Instructions
}
default:
if project == nil || project.IsOutOfDate() {
return ctx, nil, nil, shared.ErrProjectOutOfDate("project is out of date")
Expand All @@ -219,6 +223,7 @@ func (s *ChatServerV1) prepare(ctx context.Context, projectId string, conversati
if err != nil {
return ctx, nil, nil, err
}
projectInstructions = project.Instructions
}

var conversation *models.Conversation
Expand All @@ -229,7 +234,7 @@ func (s *ChatServerV1) prepare(ctx context.Context, projectId string, conversati
actor.ID,
projectId,
latexFullSource,
project.Instructions,
projectInstructions,
userInstructions,
userMessage,
userSelectedText,
Expand Down
110 changes: 79 additions & 31 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,110 +148,152 @@ 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
var err error
activeBranch, err = conversation.CreateNewBranch("", parentMessageId)
if err != nil {
return nil, nil, shared.ErrBadRequest(err)
}
} 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
var projectInstructions string
switch conversationType {
case chatv2.ConversationType_CONVERSATION_TYPE_DEBUG:
latexFullSource = "latex_full_source is not available in debug mode"
if project != nil {
projectInstructions = project.Instructions
}
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
}
projectInstructions = project.Instructions
}

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

if conversationId == "" {
// Create a new conversation
conversation, err = s.createConversation(
ctx,
actor.ID,
projectId,
latexFullSource,
project.Instructions,
projectInstructions,
userInstructions,
userMessage,
userSelectedText,
surrounding,
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()
} 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 +303,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 +324,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 +339,17 @@ func (s *ChatServerV2) CreateConversationMessageStream(
}
bsonMessages[i] = bsonMsg
}
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
21 changes: 20 additions & 1 deletion internal/api/chat/get_conversation_v2.go
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ import (

"paperdebugger/internal/api/mapper"
"paperdebugger/internal/libs/contextutil"
"paperdebugger/internal/libs/shared"
chatv2 "paperdebugger/pkg/gen/api/chat/v2"

"go.mongodb.org/mongo-driver/v2/bson"
Expand All @@ -29,7 +30,25 @@ func (s *ChatServerV2) GetConversation(
return nil, err
}

// Migrate legacy data to branch structure if needed
// Persist immediately so branch IDs remain stable across API calls
if conversation.EnsureBranches() {
if err := s.chatServiceV2.UpdateConversationV2(conversation); err != nil {
return nil, err
}
}

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

// Validate that the provided branchId exists in the conversation
if branchID != "" {
if conversation.GetBranchByID(branchID) == nil {
return nil, shared.ErrBadRequest("branch_id not found in conversation")
}
}

return &chatv2.GetConversationResponse{
Conversation: mapper.MapModelConversationToProtoV2(conversation),
Conversation: mapper.MapModelConversationToProtoV2WithBranch(conversation, branchID),
}, nil
}
12 changes: 12 additions & 0 deletions internal/api/chat/list_conversations_v2.go
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,18 @@ func (s *ChatServerV2) ListConversations(
return nil, err
}

// Migrate legacy data to branch structure if needed
// Persist immediately so branch IDs remain stable across API calls
for _, conversation := range conversations {
if conversation.EnsureBranches() {
// Persist migration asynchronously to avoid blocking the response
// Errors are logged but don't fail the request
go func(c *models.Conversation) {
_ = s.chatServiceV2.UpdateConversationV2(c)
}(conversation)
}
}

return &chatv2.ListConversationsResponse{
Conversations: lo.Map(conversations, func(conversation *models.Conversation, _ int) *chatv2.Conversation {
return mapper.MapModelConversationToProtoV2(conversation)
Expand Down
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
Loading
Loading