From b55bc9203ef53cfbeba865f6b9506f26f7299a8e Mon Sep 17 00:00:00 2001 From: MartinGuo <39837778+gzquse@users.noreply.github.com> Date: Sun, 12 Apr 2026 11:20:45 -0500 Subject: [PATCH] add overleaf review insertion and model updates --- internal/api/chat/list_supported_models.go | 44 +- internal/api/chat/list_supported_models_v2.go | 46 +- internal/api/project/review.go | 230 +++++ internal/api/project/server.go | 15 +- internal/models/language_model.go | 18 +- internal/services/comment.go | 144 ++- internal/services/system_prompt_debug.tmpl | 15 + internal/services/system_prompt_default.tmpl | 28 + internal/services/toolkit/client/client_v2.go | 4 +- .../services/toolkit/client/completion_v2.go | 101 ++- .../toolkit/client/get_citation_keys.go | 2 +- internal/services/toolkit/client/utils.go | 47 +- internal/services/toolkit/client/utils_v2.go | 91 +- .../services/toolkit/handler/toolcall_v2.go | 27 +- .../toolkit/tools/latex/locate_section.go | 279 +++++- .../services/toolkit/tools/paper_score.go | 19 +- .../toolkit/tools/paper_score_comment.go | 17 +- internal/services/user_prompt_debug.tmpl | 8 + internal/services/user_prompt_default.tmpl | 8 + internal/wire_gen.go | 2 +- webapp/_webapp/src/background.ts | 3 +- .../add-comments-button.tsx | 189 +++- .../tools/paper-score-comment/index.tsx | 26 + .../tools/paper-score-comment/utils.ts | 18 + webapp/_webapp/src/hooks/useLanguageModels.ts | 79 +- .../_webapp/src/hooks/useReviewAndInsert.ts | 857 ++++++++++++++++++ webapp/_webapp/src/intermediate.ts | 14 +- webapp/_webapp/src/libs/helpers.ts | 38 + webapp/_webapp/src/libs/overleaf-socket.ts | 6 +- webapp/_webapp/src/query/api.ts | 27 + webapp/_webapp/src/query/utils.ts | 4 +- .../conversation/conversation-ui-store.ts | 2 +- webapp/_webapp/src/stores/socket-store.ts | 232 ++++- .../_webapp/src/views/chat/actions/actions.ts | 18 +- .../_webapp/src/views/chat/footer/index.tsx | 122 ++- .../chat/footer/toolbar/chat-actions.tsx | 12 +- webapp/_webapp/src/views/devtools/index.tsx | 2 +- .../src/views/login/advanced-settings.tsx | 20 +- 38 files changed, 2575 insertions(+), 239 deletions(-) create mode 100644 internal/api/project/review.go create mode 100644 webapp/_webapp/src/hooks/useReviewAndInsert.ts diff --git a/internal/api/chat/list_supported_models.go b/internal/api/chat/list_supported_models.go index 17e01e72..d93d492f 100644 --- a/internal/api/chat/list_supported_models.go +++ b/internal/api/chat/list_supported_models.go @@ -27,6 +27,22 @@ func (s *ChatServerV1) ListSupportedModels( var models []*chatv1.SupportedModel if strings.TrimSpace(settings.OpenAIAPIKey) == "" { models = []*chatv1.SupportedModel{ + { + Name: "GPT-5.4", + Slug: "openai/gpt-5.4", + }, + { + Name: "GPT-5.4 Mini", + Slug: "openai/gpt-5.4-mini", + }, + { + Name: "GPT-5.4 Nano", + Slug: "openai/gpt-5.4-nano", + }, + { + Name: "Claude Opus 4.6", + Slug: "anthropic/claude-opus-4.6", + }, { Name: "GPT-4o", @@ -44,28 +60,32 @@ func (s *ChatServerV1) ListSupportedModels( } else { models = []*chatv1.SupportedModel{ { - Name: "GPT 4o", - Slug: openai.ChatModelGPT4o, + Name: "GPT-5.4", + Slug: "gpt-5.4", }, { - Name: "GPT 4.1", - Slug: openai.ChatModelGPT4_1, + Name: "GPT-5.4 Mini", + Slug: "gpt-5.4-mini", }, { - Name: "GPT 4.1 mini", - Slug: openai.ChatModelGPT4_1Mini, + Name: "GPT-5.4 Nano", + Slug: "gpt-5.4-nano", }, { - Name: "GPT 5", - Slug: openai.ChatModelGPT5, + Name: "Claude Opus 4.6", + Slug: "anthropic/claude-opus-4.6", }, { - Name: "GPT 5 mini", - Slug: openai.ChatModelGPT5Mini, + Name: "GPT 4o", + Slug: openai.ChatModelGPT4o, }, { - Name: "GPT 5 nano", - Slug: openai.ChatModelGPT5Nano, + Name: "GPT 4.1", + Slug: openai.ChatModelGPT4_1, + }, + { + Name: "GPT 4.1 mini", + Slug: openai.ChatModelGPT4_1Mini, }, { Name: "GPT 5 Chat Latest", diff --git a/internal/api/chat/list_supported_models_v2.go b/internal/api/chat/list_supported_models_v2.go index 1fb54575..e3427c74 100644 --- a/internal/api/chat/list_supported_models_v2.go +++ b/internal/api/chat/list_supported_models_v2.go @@ -25,43 +25,43 @@ type modelConfig struct { // allModels defines all available models in the system var allModels = []modelConfig{ { - name: "GPT-5.1", - slugOpenRouter: "openai/gpt-5.1", - slugOpenAI: openai.ChatModelGPT5_1, - totalContext: 400000, + name: "GPT-5.4", + slugOpenRouter: "openai/gpt-5.4", + slugOpenAI: "gpt-5.4", + totalContext: 1050000, maxOutput: 128000, - inputPrice: 125, // $1.25 - outputPrice: 1000, // $10.00 + inputPrice: 250, // $2.50 + outputPrice: 1500, // $15.00 requireOwnKey: false, }, { - name: "GPT-5.2", - slugOpenRouter: "openai/gpt-5.2", - slugOpenAI: openai.ChatModelGPT5_2, + name: "GPT-5.4 Mini", + slugOpenRouter: "openai/gpt-5.4-mini", + slugOpenAI: "gpt-5.4-mini", totalContext: 400000, maxOutput: 128000, - inputPrice: 175, // $1.75 - outputPrice: 1400, // $14.00 - requireOwnKey: true, + inputPrice: 75, // $0.75 + outputPrice: 450, // $4.50 + requireOwnKey: false, }, { - name: "GPT-5 Mini", - slugOpenRouter: "openai/gpt-5-mini", - slugOpenAI: openai.ChatModelGPT5Mini, + name: "GPT-5.4 Nano", + slugOpenRouter: "openai/gpt-5.4-nano", + slugOpenAI: "gpt-5.4-nano", totalContext: 400000, maxOutput: 128000, - inputPrice: 25, - outputPrice: 200, + inputPrice: 20, + outputPrice: 125, requireOwnKey: false, }, { - name: "GPT-5 Nano", - slugOpenRouter: "openai/gpt-5-nano", - slugOpenAI: openai.ChatModelGPT5Nano, - totalContext: 400000, + name: "Claude Opus 4.6", + slugOpenRouter: "anthropic/claude-opus-4.6", + slugOpenAI: "", + totalContext: 1000000, maxOutput: 128000, - inputPrice: 5, // $0.20 - outputPrice: 40, // $0.80 + inputPrice: 500, // $5.00 + outputPrice: 2500, // $25.00 requireOwnKey: false, }, { diff --git a/internal/api/project/review.go b/internal/api/project/review.go new file mode 100644 index 00000000..738fbedf --- /dev/null +++ b/internal/api/project/review.go @@ -0,0 +1,230 @@ +package project + +import ( + "bytes" + "context" + "encoding/json" + "fmt" + "io" + "net/http" + "strings" + + "paperdebugger/internal/libs/contextutil" + "paperdebugger/internal/libs/shared" + "paperdebugger/internal/models" + projectv1 "paperdebugger/pkg/gen/api/project/v1" +) + +type paperScoreRequest struct { + LatexSource string `json:"latexSource"` + Category string `json:"category"` +} + +type paperScoreCommentRequest struct { + LatexSource string `json:"latexSource"` + PaperScoreResult *projectv1.PaperScoreResult `json:"paperScoreResult"` +} + +func (s *ProjectServer) RunProjectPaperScore( + ctx context.Context, + req *projectv1.RunProjectPaperScoreRequest, +) (*projectv1.RunProjectPaperScoreResponse, error) { + if req.GetProjectId() == "" { + return nil, shared.ErrBadRequest("project_id is required") + } + + ctx, fullContent, category, err := s.loadReviewInput(ctx, req.GetProjectId(), req.GetConversationId()) + if err != nil { + return nil, err + } + + result, err := s.scorePaper(ctx, fullContent, category) + if err != nil { + return nil, shared.ErrInternal(err) + } + + return &projectv1.RunProjectPaperScoreResponse{ + ProjectId: req.GetProjectId(), + PaperScore: result, + }, nil +} + +func (s *ProjectServer) RunProjectPaperScoreComment( + ctx context.Context, + req *projectv1.RunProjectPaperScoreCommentRequest, +) (*projectv1.RunProjectPaperScoreCommentResponse, error) { + if req.GetProjectId() == "" { + return nil, shared.ErrBadRequest("project_id is required") + } + + ctx, fullContent, category, err := s.loadReviewInput(ctx, req.GetProjectId(), req.GetConversationId()) + if err != nil { + return nil, err + } + + scoreResult, err := s.scorePaper(ctx, fullContent, category) + if err != nil { + return nil, shared.ErrInternal(err) + } + + commentResult, err := s.generatePaperScoreComments(ctx, fullContent, scoreResult) + if err != nil { + return nil, shared.ErrInternal(err) + } + + return &projectv1.RunProjectPaperScoreCommentResponse{ + ProjectId: req.GetProjectId(), + Comments: []*projectv1.PaperScoreCommentResult{commentResult}, + }, nil +} + +func (s *ProjectServer) RunProjectOverleafComment( + ctx context.Context, + req *projectv1.RunProjectOverleafCommentRequest, +) (*projectv1.RunProjectOverleafCommentResponse, error) { + if req.GetProjectId() == "" { + return nil, shared.ErrBadRequest("project_id is required") + } + if strings.TrimSpace(req.GetComment()) == "" { + return nil, shared.ErrBadRequest("comment is required") + } + + ctx, _, err := s.loadProject(ctx, req.GetProjectId()) + if err != nil { + return nil, err + } + + commentResult := &projectv1.PaperScoreCommentResult{ + Results: []*projectv1.PaperScoreCommentEntry{ + { + Section: req.GetSection(), + AnchorText: req.GetAnchorText(), + Weakness: req.GetComment(), + Importance: req.GetImportance(), + }, + }, + } + + comments, err := s.reverseCommentService.ReverseComments(ctx, commentResult) + if err != nil { + return nil, shared.ErrInternal(err) + } + if len(comments) == 0 { + section := strings.TrimSpace(req.GetSection()) + if section == "" { + section = "the requested location" + } + return nil, shared.ErrBadRequest(fmt.Sprintf("unable to locate %s in the project for comment insertion", section)) + } + + return &projectv1.RunProjectOverleafCommentResponse{ + ProjectId: req.GetProjectId(), + Comments: comments, + }, nil +} + +func (s *ProjectServer) loadProject(ctx context.Context, projectID string) (context.Context, *models.Project, error) { + actor, err := contextutil.GetActor(ctx) + if err != nil { + return ctx, nil, err + } + + ctx = contextutil.SetProjectID(ctx, projectID) + + project, err := s.projectService.GetProject(ctx, actor.ID, projectID) + if err != nil { + return ctx, nil, err + } + + return ctx, project, nil +} + +func (s *ProjectServer) loadReviewInput(ctx context.Context, projectID string, conversationID string) (context.Context, string, string, error) { + ctx, project, err := s.loadProject(ctx, projectID) + if err != nil { + return ctx, "", "", err + } + + if conversationID != "" { + ctx = contextutil.SetConversationID(ctx, conversationID) + } + + fullContent, err := project.GetFullContent() + if err != nil { + return ctx, "", "", shared.ErrInternal("failed to get paper full content") + } + + actor, err := contextutil.GetActor(ctx) + if err != nil { + return ctx, "", "", err + } + + projectCategory, err := s.projectService.GetProjectCategory(ctx, actor.ID, projectID) + if err != nil { + return ctx, "", "", shared.ErrInternal(err) + } + + return ctx, fullContent, projectCategory.Category, nil +} + +func (s *ProjectServer) scorePaper(ctx context.Context, fullContent string, category string) (*projectv1.PaperScoreResult, error) { + result := &projectv1.PaperScoreResult{} + err := s.postReviewJSON(ctx, "paper-score", &paperScoreRequest{ + LatexSource: fullContent, + Category: category, + }, result) + if err != nil { + return nil, err + } + return result, nil +} + +func (s *ProjectServer) generatePaperScoreComments( + ctx context.Context, + fullContent string, + scoreResult *projectv1.PaperScoreResult, +) (*projectv1.PaperScoreCommentResult, error) { + result := &projectv1.PaperScoreCommentResult{} + err := s.postReviewJSON(ctx, "paper-score-comments", &paperScoreCommentRequest{ + LatexSource: fullContent, + PaperScoreResult: scoreResult, + }, result) + if err != nil { + return nil, err + } + return result, nil +} + +func (s *ProjectServer) postReviewJSON(ctx context.Context, path string, payload any, out any) error { + body, err := json.Marshal(payload) + if err != nil { + return fmt.Errorf("failed to marshal review request: %w", err) + } + + url := strings.TrimRight(s.cfg.MCPServerURL, "/") + "/" + strings.TrimLeft(path, "/") + req, err := http.NewRequestWithContext(ctx, http.MethodPost, url, bytes.NewBuffer(body)) + if err != nil { + return fmt.Errorf("failed to create review request: %w", err) + } + req.Header.Set("Content-Type", "application/json") + + resp, err := (&http.Client{}).Do(req) + if err != nil { + return fmt.Errorf("failed to send review request: %w", err) + } + defer resp.Body.Close() + + respBody, err := io.ReadAll(resp.Body) + if err != nil { + return fmt.Errorf("failed to read review response: %w", err) + } + if resp.StatusCode < http.StatusOK || resp.StatusCode >= http.StatusMultipleChoices { + return fmt.Errorf("review service returned status %d: %s", resp.StatusCode, strings.TrimSpace(string(respBody))) + } + + if err := json.Unmarshal(respBody, out); err != nil { + return fmt.Errorf("failed to decode review response: %w", err) + } + + return nil +} diff --git a/internal/api/project/server.go b/internal/api/project/server.go index 712ad3a8..2dbee61a 100644 --- a/internal/api/project/server.go +++ b/internal/api/project/server.go @@ -9,19 +9,22 @@ import ( type ProjectServer struct { projectv1.UnimplementedProjectServiceServer - projectService *services.ProjectService - logger *logger.Logger - cfg *cfg.Cfg + projectService *services.ProjectService + reverseCommentService *services.ReverseCommentService + logger *logger.Logger + cfg *cfg.Cfg } func NewProjectServer( projectService *services.ProjectService, + reverseCommentService *services.ReverseCommentService, logger *logger.Logger, cfg *cfg.Cfg, ) projectv1.ProjectServiceServer { return &ProjectServer{ - projectService: projectService, - logger: logger, - cfg: cfg, + projectService: projectService, + reverseCommentService: reverseCommentService, + logger: logger, + cfg: cfg, } } diff --git a/internal/models/language_model.go b/internal/models/language_model.go index 73c94d25..3fdcbfc5 100644 --- a/internal/models/language_model.go +++ b/internal/models/language_model.go @@ -33,11 +33,11 @@ func (x LanguageModel) Name() string { case chatv1.LanguageModel_LANGUAGE_MODEL_OPENAI_GPT41_MINI: return openai.ChatModelGPT4_1Mini case chatv1.LanguageModel_LANGUAGE_MODEL_OPENAI_GPT5: - return openai.ChatModelGPT5 + return openai.ChatModel("gpt-5.4") case chatv1.LanguageModel_LANGUAGE_MODEL_OPENAI_GPT5_MINI: - return openai.ChatModelGPT5Mini + return openai.ChatModel("gpt-5.4-mini") case chatv1.LanguageModel_LANGUAGE_MODEL_OPENAI_GPT5_NANO: - return openai.ChatModelGPT5Nano + return openai.ChatModel("gpt-5.4-nano") case chatv1.LanguageModel_LANGUAGE_MODEL_OPENAI_GPT5_CHAT_LATEST: return openai.ChatModelGPT5ChatLatest case chatv1.LanguageModel_LANGUAGE_MODEL_OPENAI_O1: @@ -66,10 +66,16 @@ func LanguageModelFromSlug(slug string) LanguageModel { case "gpt-4.1-mini": return LanguageModel(chatv1.LanguageModel_LANGUAGE_MODEL_OPENAI_GPT41_MINI) case "gpt-5": + fallthrough + case "gpt-5.4": return LanguageModel(chatv1.LanguageModel_LANGUAGE_MODEL_OPENAI_GPT5) case "gpt-5-mini": + fallthrough + case "gpt-5.4-mini": return LanguageModel(chatv1.LanguageModel_LANGUAGE_MODEL_OPENAI_GPT5_MINI) case "gpt-5-nano": + fallthrough + case "gpt-5.4-nano": return LanguageModel(chatv1.LanguageModel_LANGUAGE_MODEL_OPENAI_GPT5_NANO) case "gpt-5-chat-latest": return LanguageModel(chatv1.LanguageModel_LANGUAGE_MODEL_OPENAI_GPT5_CHAT_LATEST) @@ -99,11 +105,11 @@ func SlugFromLanguageModel(languageModel LanguageModel) string { case LanguageModel(chatv1.LanguageModel_LANGUAGE_MODEL_OPENAI_GPT41_MINI): return "gpt-4.1-mini" case LanguageModel(chatv1.LanguageModel_LANGUAGE_MODEL_OPENAI_GPT5): - return "gpt-5" + return "gpt-5.4" case LanguageModel(chatv1.LanguageModel_LANGUAGE_MODEL_OPENAI_GPT5_MINI): - return "gpt-5-mini" + return "gpt-5.4-mini" case LanguageModel(chatv1.LanguageModel_LANGUAGE_MODEL_OPENAI_GPT5_NANO): - return "gpt-5-nano" + return "gpt-5.4-nano" case LanguageModel(chatv1.LanguageModel_LANGUAGE_MODEL_OPENAI_GPT5_CHAT_LATEST): return "gpt-5-chat-latest" case LanguageModel(chatv1.LanguageModel_LANGUAGE_MODEL_OPENAI_O1): diff --git a/internal/services/comment.go b/internal/services/comment.go index 381a1bbc..cc184a61 100644 --- a/internal/services/comment.go +++ b/internal/services/comment.go @@ -12,6 +12,7 @@ import ( "paperdebugger/internal/libs/stringutil" "paperdebugger/internal/models" projectv1 "paperdebugger/pkg/gen/api/project/v1" + "regexp" "strings" "sync" "time" @@ -82,6 +83,77 @@ func isSectionHeader(line string) bool { return false } +var nonAlphaNumSectionPattern = regexp.MustCompile(`[^a-z0-9]+`) + +func normalizeSectionName(name string) string { + name = strings.ToLower(strings.TrimSpace(name)) + name = regexp.MustCompile(`\\[a-zA-Z]+\{([^}]*)\}`).ReplaceAllString(name, "$1") + name = regexp.MustCompile(`\\[a-zA-Z]+`).ReplaceAllString(name, "") + name = nonAlphaNumSectionPattern.ReplaceAllString(name, " ") + return strings.Join(strings.Fields(name), " ") +} + +func extractSectionHeaderTitle(line string) string { + patterns := []*regexp.Regexp{ + regexp.MustCompile(`^[^%]*\\section\*?\{([^}]*)\}`), + regexp.MustCompile(`^[^%]*\\subsection\*?\{([^}]*)\}`), + regexp.MustCompile(`^[^%]*\\subsubsection\*?\{([^}]*)\}`), + } + for _, pattern := range patterns { + matches := pattern.FindStringSubmatch(line) + if len(matches) > 1 { + return strings.TrimSpace(matches[1]) + } + } + return "" +} + +func orderedProjectDocs(project *models.Project) []*models.ProjectDoc { + ordered := make([]*models.ProjectDoc, 0, len(project.Docs)) + for idx := range project.Docs { + if project.Docs[idx].ID == project.RootDocID { + ordered = append(ordered, &project.Docs[idx]) + break + } + } + for idx := range project.Docs { + if project.Docs[idx].ID == project.RootDocID { + continue + } + ordered = append(ordered, &project.Docs[idx]) + } + return ordered +} + +func findSpecialSectionMarkerPosition(docContent string, sectionName string) (int, string) { + normalized := normalizeSectionName(sectionName) + lines := strings.Split(docContent, "\n") + runeOffset := 0 + for _, line := range lines { + trimmed := strings.TrimSpace(line) + switch normalized { + case "title": + if strings.Contains(trimmed, `\title{`) || strings.HasPrefix(trimmed, `\title[`) || strings.Contains(trimmed, `\maketitle`) { + return runeOffset, strings.TrimSpace(line) + } + case "abstract": + if strings.Contains(trimmed, `\begin{abstract}`) { + return runeOffset, strings.TrimSpace(line) + } + default: + title := extractSectionHeaderTitle(line) + if title != "" { + normalizedTitle := normalizeSectionName(title) + if normalizedTitle == normalized || strings.Contains(normalizedTitle, normalized) || strings.Contains(normalized, normalizedTitle) { + return runeOffset, strings.TrimSpace(line) + } + } + } + runeOffset += utf8.RuneCountInString(line) + 1 + } + return NoMatchPosition, "" +} + // generateDocSHA1 generates a SHA1 hash for the document content func generateDocSHA1(content string) string { h := sha1.New() @@ -205,13 +277,36 @@ func (s *ReverseCommentService) fuzzyMatchPosition(docContent, anchorText string // findTargetDocBySection searches for a document containing the target section name // It handles both direct section definitions and sections with input statements func (s *ReverseCommentService) findTargetDocBySection(project *models.Project, targetSectionName string) *models.ProjectDoc { + normalizedTarget := normalizeSectionName(targetSectionName) + if normalizedTarget == "title" || normalizedTarget == "abstract" { + for _, doc := range orderedProjectDocs(project) { + for _, line := range doc.Lines { + trimmed := strings.TrimSpace(line) + if normalizedTarget == "title" && (strings.Contains(trimmed, `\title{`) || strings.HasPrefix(trimmed, `\title[`) || strings.Contains(trimmed, `\maketitle`)) { + return doc + } + if normalizedTarget == "abstract" && strings.Contains(trimmed, `\begin{abstract}`) { + return doc + } + } + } + for _, doc := range orderedProjectDocs(project) { + return doc + } + } + // First pass: look for direct section matches for _, doc := range project.Docs { for i, line := range doc.Lines { - if isSectionHeader(line) && strings.Contains( - strings.ToLower(line), - strings.ToLower(targetSectionName), - ) { + headerTitle := extractSectionHeaderTitle(line) + if isSectionHeader(line) && headerTitle != "" { + normalizedHeader := normalizeSectionName(headerTitle) + if normalizedHeader != normalizedTarget && + !strings.Contains(normalizedHeader, normalizedTarget) && + !strings.Contains(normalizedTarget, normalizedHeader) { + continue + } + // Check if this section is followed by an input statement if i+1 < len(doc.Lines) { nextLine := strings.TrimSpace(doc.Lines[i+1]) @@ -246,11 +341,14 @@ func (s *ReverseCommentService) findTargetDocBySection(project *models.Project, if strings.HasSuffix(inputDoc.Filepath, inputFile+".tex") { // Check if this input file contains the target section for _, inputLine := range inputDoc.Lines { - if isSectionHeader(inputLine) && strings.Contains( - strings.ToLower(inputLine), - strings.ToLower(targetSectionName), - ) { - return &inputDoc + headerTitle := extractSectionHeaderTitle(inputLine) + if isSectionHeader(inputLine) && headerTitle != "" { + normalizedHeader := normalizeSectionName(headerTitle) + if normalizedHeader == normalizedTarget || + strings.Contains(normalizedHeader, normalizedTarget) || + strings.Contains(normalizedTarget, normalizedHeader) { + return &inputDoc + } } } } @@ -295,15 +393,18 @@ func (s *ReverseCommentService) ReverseComments(ctx context.Context, comments *p } comment.AnchorText = strings.TrimSpace(comment.AnchorText) - comment.Weakness = fmt.Sprintf(`👨🏻‍💻 %s: %s`, comment.Importance, comment.Weakness) + comment.Weakness = formatOverleafReviewComment(comment) // Generate SHA1 hash for the document content docContent := strings.Join(targetDoc.Lines, "\n") docSHA1 := generateDocSHA1(docContent) quotePosition, matchedText := s.findBestMatchPosition(docContent, comment.AnchorText) if quotePosition == -1 { - s.logger.Info("No sufficiently similar match found for comment", "comment", comment) - continue + quotePosition, matchedText = findSpecialSectionMarkerPosition(docContent, comment.Section) + if quotePosition == -1 { + s.logger.Info("No sufficiently similar match found for comment", "comment", comment) + continue + } } commentRecord := s.createCommentRecord(actor.ID, projectId, targetDoc, docSHA1, quotePosition, matchedText, comment) @@ -319,6 +420,25 @@ func (s *ReverseCommentService) ReverseComments(ctx context.Context, comments *p return requests, nil } +func formatOverleafReviewComment(comment *projectv1.PaperScoreCommentEntry) string { + importance := strings.TrimSpace(comment.GetImportance()) + issue := strings.TrimSpace(comment.GetWeakness()) + if issue == "" { + issue = "This passage needs a clearer and better-supported revision." + } + + header := "Review comment" + if importance != "" { + header = fmt.Sprintf("[%s] Review comment", importance) + } + + return fmt.Sprintf( + "%s\nIssue: %s\nNext steps:\n1. Revise the highlighted passage so the local claim is precise and defensible.\n2. Add the missing justification, evidence, citation, derivation detail, baseline, or experimental context at this location.\n3. Check nearby sentences, notation, and claims so the fix stays consistent across the section.", + header, + issue, + ) +} + // createCommentRecord creates a models.Comment from the provided data func (s *ReverseCommentService) createCommentRecord(userID bson.ObjectID, projectId string, targetDoc *models.ProjectDoc, docSHA1 string, quotePosition int, matchedText string, comment *projectv1.PaperScoreCommentEntry) *models.Comment { return &models.Comment{ diff --git a/internal/services/system_prompt_debug.tmpl b/internal/services/system_prompt_debug.tmpl index 90ece53e..4283415d 100644 --- a/internal/services/system_prompt_debug.tmpl +++ b/internal/services/system_prompt_debug.tmpl @@ -3,11 +3,26 @@ You are PaperDebugger, a large language model tweaked by PaperDebugger Inc. ## tool_call_limit You have a maximum of 20 tool calls per conversation turn. Please plan your tool usage carefully and avoid unnecessary tool calls. +## role +You are an expert academic reviewer and author-side revision coach. Give precise, high-quality feedback that helps the author strengthen a submission for conferences and journals. + +## review_style +- Be concrete and step-by-step. +- Explain critiques in the order: issue, why it matters, how to fix it. +- Distinguish major concerns from smaller polish issues when the user asks for review. + +## quantum_track_review +If the work is in a quantum track or adjacent field, adapt your review to quantum-specific expectations: +- inspect technical correctness, assumptions, notation, and claim scope carefully; +- check resource estimates, noise/hardware realism, scalability, and baseline fairness when relevant; +- look for missing evidence, derivation details, reproducibility details, and gaps versus prior quantum literature. + ## selected_text The user may select sentences or paragraphs of LaTeX content for revision or ask questions along with the selected text. If the user asks questions, just answer the question. If the user requests to revise the selected text, you MUST include a separate block where the revised text is wrapped inside a `` tag, like `...revised text...`. The content inside `` MUST be only the revised text (no explanations, no markdown formatting, no surrounding backticks); any explanations should be placed outside of the `` block. +- If the user asks to add comments directly into Overleaf, directly into the paper, or directly into the TeX source, use the paper scoring/comment tools when available instead of replying with freeform review snippets. {{ if .ProjectInstructions }}## project_instructions, please follow the project's instructions strictly {{ .ProjectInstructions }}{{ end }} diff --git a/internal/services/system_prompt_default.tmpl b/internal/services/system_prompt_default.tmpl index bfef4ae9..1e871a30 100644 --- a/internal/services/system_prompt_default.tmpl +++ b/internal/services/system_prompt_default.tmpl @@ -3,6 +3,34 @@ You are PaperDebugger, a large language model tweaked by PaperDebugger Inc. ## tool_call_limit You have a maximum of 20 tool calls per conversation turn. Please plan your tool usage carefully and avoid unnecessary tool calls. +## role +You are an expert academic reviewer and author-side revision coach. Your job is to help the author improve the manuscript so it is stronger for conference and journal submission, not to flatter it. + +## review_style +- Give high-signal, step-by-step feedback. +- Prioritize correctness, novelty, clarity, evidence, and revision impact. +- Separate major issues from minor issues when the request is review-oriented. +- Prefer concrete recommendations over vague criticism. +- When possible, explain each point in the order: issue, why it matters, how to fix it. +- For manuscript-wide review requests, focus on the few highest-impact weaknesses first. + +## quantum_track_review +If the paper is in a quantum track or adjacent area such as quantum computing, quantum information, quantum algorithms, quantum hardware, quantum communication, quantum error correction, quantum sensing, or quantum software: +- judge technical soundness carefully, especially derivations, assumptions, complexity claims, and physical plausibility; +- check whether claims match the actual noise model, hardware constraints, simulation regime, and resource estimates; +- examine whether comparisons against classical, heuristic, and quantum baselines are fair and complete; +- look for missing discussion of scalability, error sources, sample complexity, circuit depth, qubit counts, or experimental limitations when relevant; +- verify that notation is consistent and that the claimed contribution is clearly distinguished from prior quantum literature; +- comment on reproducibility, including algorithms, hyperparameters, datasets, simulators, hardware settings, and evaluation protocol where relevant. + +## output_preferences +- For review requests, write like a careful reviewer who wants the author to succeed. +- For local selected-text critique, keep feedback tightly anchored to the quoted passage. +- For Overleaf-friendly comments, keep each comment focused on one issue and make the next action explicit. +- If a full-paper review is requested and the scoring/comment tools are available, use them when appropriate to produce structured review comments. +- If the user asks to add comments directly into Overleaf, directly into the paper, or directly into the TeX source, prefer the paper_score and paper_score_comment tools over freeform annotated LaTeX output. +- When the paper_score_comment tool is available for a direct-comment request, do not answer with manually written `\textcolor{red}` review snippets unless the user explicitly asked for raw LaTeX examples. + ## selected_text The user may select sentences or paragraphs of LaTeX content for revision. Your task is to revise the selected text according to the user's instructions. diff --git a/internal/services/toolkit/client/client_v2.go b/internal/services/toolkit/client/client_v2.go index 87a1e26a..88d566d6 100644 --- a/internal/services/toolkit/client/client_v2.go +++ b/internal/services/toolkit/client/client_v2.go @@ -77,13 +77,13 @@ func NewAIClientV2( if llmProvider != nil && llmProvider.IsCustom() { baseUrl = cfg.OpenAIBaseURL apiKey = cfg.OpenAIAPIKey - modelSlug = "gpt-5-nano" + modelSlug = "gpt-5.4-nano" // Use the default inference endpoint } else { // suffix needed for cloudflare gateway baseUrl = cfg.InferenceBaseURL + "/openrouter" apiKey = cfg.InferenceAPIKey - modelSlug = "openai/gpt-5-nano" + modelSlug = "openai/gpt-5.4-nano" } CheckOpenAIWorksV2( diff --git a/internal/services/toolkit/client/completion_v2.go b/internal/services/toolkit/client/completion_v2.go index f10082bf..d5c25796 100644 --- a/internal/services/toolkit/client/completion_v2.go +++ b/internal/services/toolkit/client/completion_v2.go @@ -11,6 +11,96 @@ import ( "github.com/openai/openai-go/v3" ) +var directCommentIntentPhrasesV2 = []string{ + "add direct comments", + "direct comments in the paper", + "directly add comments", + "insert comments in the tex", + "insert comments into the tex", + "add comments into the tex", + "add comments into the tex source", + "add comments into the overleaf tex source", + "comment directly in overleaf", + "add comments in overleaf", + "add comments into overleaf", + "add comments to the paper", + "add the real comments", + "tex file in overleaf", + "tex file of overleaf", + "insert into the tex file", + "insert comments into the paper", + "review and insert", + "review & insert", + "paper_score_comment", +} + +func containsDirectCommentIntentV2(text string) bool { + text = strings.ToLower(text) + for _, phrase := range directCommentIntentPhrasesV2 { + if strings.Contains(text, phrase) { + return true + } + } + return false +} + +func forceToolChoiceV2(name string) openai.ChatCompletionToolChoiceOptionUnionParam { + return openai.ChatCompletionToolChoiceOptionUnionParam{ + OfFunctionToolChoice: &openai.ChatCompletionNamedToolChoiceParam{ + Function: openai.ChatCompletionNamedToolChoiceFunctionParam{ + Name: name, + }, + }, + } +} + +func getForcedReviewToolChoiceV2(messages OpenAIChatHistory) (openai.ChatCompletionToolChoiceOptionUnionParam, bool) { + lastUserIndex := -1 + lastUserText := "" + for i := len(messages) - 1; i >= 0; i-- { + if user := messages[i].OfUser; user != nil { + lastUserIndex = i + if user.Content.OfString.Valid() { + lastUserText = user.Content.OfString.Value + } + break + } + } + + if lastUserIndex == -1 || !containsDirectCommentIntentV2(lastUserText) { + return openai.ChatCompletionToolChoiceOptionUnionParam{}, false + } + + hasPaperScore := false + hasPaperScoreComment := false + for i := lastUserIndex + 1; i < len(messages); i++ { + assistant := messages[i].OfAssistant + if assistant == nil { + continue + } + for _, toolCall := range assistant.ToolCalls { + if toolCall.OfFunction == nil { + continue + } + switch toolCall.OfFunction.Function.Name { + case "paper_score": + hasPaperScore = true + case "paper_score_comment": + hasPaperScoreComment = true + } + } + } + + if !hasPaperScore { + return forceToolChoiceV2("paper_score"), true + } + if !hasPaperScoreComment { + return forceToolChoiceV2("paper_score_comment"), true + } + + return openai.ChatCompletionToolChoiceOptionUnionParam{}, false +} + // define []openai.ChatCompletionMessageParamUnion as OpenAIChatHistory // ChatCompletion orchestrates a chat completion process with a language model (e.g., GPT), handling tool calls and message history management. @@ -70,6 +160,11 @@ func (a *AIClientV2) ChatCompletionStreamV2(ctx context.Context, callbackStream for { params.Messages = openaiChatHistory + if forcedToolChoice, ok := getForcedReviewToolChoiceV2(openaiChatHistory); ok { + params.ToolChoice = forcedToolChoice + } else { + params.ToolChoice = openai.ChatCompletionToolChoiceOptionUnionParam{} + } // var openaiOutput OpenAIChatHistory stream := oaiClient.Chat.Completions.NewStreaming(context.Background(), params) @@ -193,12 +288,12 @@ func (a *AIClientV2) ChatCompletionStreamV2(ctx context.Context, callbackStream return nil, nil, err } - if answer_content != "" { - appendAssistantTextResponseV2(&openaiChatHistory, &inappChatHistory, answer_content, answer_content_id, modelSlug) + if answer_content != "" && len(toolCalls) == 0 { + appendAssistantTextResponseV2(&openaiChatHistory, &inappChatHistory, answer_content, answer_content_id, modelSlug, reasoning_content) } // Execute the calls (if any), return incremental data - openaiToolHistory, inappToolHistory, err := a.toolCallHandler.HandleToolCallsV2(ctx, toolCalls, streamHandler) + openaiToolHistory, inappToolHistory, err := a.toolCallHandler.HandleToolCallsV2(ctx, toolCalls, answer_content, reasoning_content, streamHandler) if err != nil { return nil, nil, err } diff --git a/internal/services/toolkit/client/get_citation_keys.go b/internal/services/toolkit/client/get_citation_keys.go index 1995d590..4bad11eb 100644 --- a/internal/services/toolkit/client/get_citation_keys.go +++ b/internal/services/toolkit/client/get_citation_keys.go @@ -241,7 +241,7 @@ func (a *AIClientV2) GetCitationKeys(ctx context.Context, sentence string, userI // Bibliography is placed at the start of the prompt to leverage prompt caching message := fmt.Sprintf("Bibliography: %s\nSentence: %s\nBased on the sentence and bibliography, suggest only the most relevant citation keys separated by commas with no spaces (e.g. key1,key2). Be selective and only include citations that are directly relevant. Avoid suggesting more than 3 citations. If no relevant citations are found, return '%s'.", bibliography, sentence, emptyCitation) - _, resp, err := a.ChatCompletionV2(ctx, "gpt-5.2", OpenAIChatHistory{ + _, resp, err := a.ChatCompletionV2(ctx, "gpt-5.4-mini", OpenAIChatHistory{ openai.SystemMessage("You are a helpful assistant that suggests relevant citation keys."), openai.UserMessage(message), }, llmProvider) diff --git a/internal/services/toolkit/client/utils.go b/internal/services/toolkit/client/utils.go index 529195d2..7cd6a570 100644 --- a/internal/services/toolkit/client/utils.go +++ b/internal/services/toolkit/client/utils.go @@ -12,12 +12,14 @@ import ( "paperdebugger/internal/libs/logger" "paperdebugger/internal/services" "paperdebugger/internal/services/toolkit/registry" + maintools "paperdebugger/internal/services/toolkit/tools" chatv1 "paperdebugger/pkg/gen/api/chat/v1" + "strings" "github.com/openai/openai-go/v2" openaiv2 "github.com/openai/openai-go/v2" "github.com/openai/openai-go/v2/responses" - "github.com/samber/lo" + sharedv2 "github.com/openai/openai-go/v2/shared" ) // appendAssistantTextResponse appends the assistant's response to both OpenAI and in-app chat histories. @@ -52,6 +54,9 @@ func appendAssistantTextResponse(openaiChatHistory *responses.ResponseNewParamsI func getDefaultParams(modelSlug string, toolRegistry *registry.ToolRegistry) responses.ResponseNewParams { var reasoningModels = []string{ "gpt-5", + "gpt-5.4", + "gpt-5.4-mini", + "gpt-5.4-nano", "gpt-5-mini", "gpt-5-nano", "gpt-5-chat-latest", @@ -61,12 +66,36 @@ func getDefaultParams(modelSlug string, toolRegistry *registry.ToolRegistry) res "o1-mini", "o1", "codex-mini-latest", + "claude-opus-4.6", + "claude-4.6-opus", } - if lo.Contains(reasoningModels, modelSlug) { - return responses.ResponseNewParams{ - Model: modelSlug, - Tools: toolRegistry.GetTools(), - Store: openaiv2.Bool(false), + for _, model := range reasoningModels { + if strings.Contains(modelSlug, model) { + params := responses.ResponseNewParams{ + Model: modelSlug, + Tools: toolRegistry.GetTools(), + Store: openaiv2.Bool(false), + } + if strings.Contains(modelSlug, "claude-opus-4.6") || strings.Contains(modelSlug, "claude-4.6-opus") { + params.SetExtraFields(map[string]any{ + "reasoning": map[string]any{ + "enabled": true, + "max_tokens": 2000, + }, + }) + } else if strings.Contains(modelSlug, "/") { + params.SetExtraFields(map[string]any{ + "reasoning": map[string]any{ + "enabled": true, + "effort": "medium", + }, + }) + } else { + params.Reasoning = sharedv2.ReasoningParam{ + Effort: sharedv2.ReasoningEffortMedium, + } + } + return params } } @@ -124,5 +153,11 @@ func initializeToolkit( // } // } + paperScoreTool := maintools.NewPaperScoreTool(db, projectService) + toolRegistry.Register("paper_score", maintools.PaperScoreToolDescription, paperScoreTool.Call) + + paperScoreCommentTool := maintools.NewPaperScoreCommentTool(db, projectService, services.NewReverseCommentService(db, cfg, logger, projectService)) + toolRegistry.Register("paper_score_comment", paperScoreCommentTool.Description, paperScoreCommentTool.Call) + return toolRegistry } diff --git a/internal/services/toolkit/client/utils_v2.go b/internal/services/toolkit/client/utils_v2.go index 69e73071..9f4cc69e 100644 --- a/internal/services/toolkit/client/utils_v2.go +++ b/internal/services/toolkit/client/utils_v2.go @@ -12,6 +12,7 @@ import ( "paperdebugger/internal/libs/logger" "paperdebugger/internal/services" "paperdebugger/internal/services/toolkit/registry" + maintools "paperdebugger/internal/services/toolkit/tools" filetools "paperdebugger/internal/services/toolkit/tools/files" latextools "paperdebugger/internal/services/toolkit/tools/latex" "paperdebugger/internal/services/toolkit/tools/xtramcp" @@ -20,42 +21,86 @@ import ( "time" openaiv3 "github.com/openai/openai-go/v3" + sharedv3 "github.com/openai/openai-go/v3/shared" ) -func appendAssistantTextResponseV2(openaiChatHistory *OpenAIChatHistory, inappChatHistory *AppChatHistory, content string, contentId string, modelSlug string) { - *openaiChatHistory = append(*openaiChatHistory, openaiv3.ChatCompletionMessageParamUnion{ - OfAssistant: &openaiv3.ChatCompletionAssistantMessageParam{ - Role: "assistant", - Content: openaiv3.ChatCompletionAssistantMessageParamContentUnion{ - OfArrayOfContentParts: []openaiv3.ChatCompletionAssistantMessageParamContentArrayOfContentPartUnion{ - { - OfText: &openaiv3.ChatCompletionContentPartTextParam{ - Type: "text", - Text: content, - }, +func appendAssistantTextResponseV2(openaiChatHistory *OpenAIChatHistory, inappChatHistory *AppChatHistory, content string, contentId string, modelSlug string, reasoning string) { + assistantMessage := openaiv3.ChatCompletionAssistantMessageParam{ + Role: "assistant", + Content: openaiv3.ChatCompletionAssistantMessageParamContentUnion{ + OfArrayOfContentParts: []openaiv3.ChatCompletionAssistantMessageParamContentArrayOfContentPartUnion{ + { + OfText: &openaiv3.ChatCompletionContentPartTextParam{ + Type: "text", + Text: content, }, }, }, }, + } + if strings.TrimSpace(reasoning) != "" { + assistantMessage.SetExtraFields(map[string]any{ + "reasoning": reasoning, + }) + } + + *openaiChatHistory = append(*openaiChatHistory, openaiv3.ChatCompletionMessageParamUnion{ + OfAssistant: &assistantMessage, }) + assistantPayload := &chatv2.MessageTypeAssistant{ + Content: content, + ModelSlug: modelSlug, + } + if strings.TrimSpace(reasoning) != "" { + assistantPayload.Reasoning = &reasoning + } + *inappChatHistory = append(*inappChatHistory, chatv2.Message{ MessageId: contentId, Payload: &chatv2.MessagePayload{ MessageType: &chatv2.MessagePayload_Assistant{ - Assistant: &chatv2.MessageTypeAssistant{ - Content: content, - ModelSlug: modelSlug, - }, + Assistant: assistantPayload, }, }, Timestamp: time.Now().Unix(), }) } +func isClaudeOpus46Model(modelSlug string) bool { + return strings.Contains(modelSlug, "claude-opus-4.6") || strings.Contains(modelSlug, "claude-4.6-opus") +} + +func configureReasoningDefaultsV2(params *openaiv3.ChatCompletionNewParams, modelSlug string) { + if isClaudeOpus46Model(modelSlug) { + params.SetExtraFields(map[string]any{ + "reasoning": map[string]any{ + "enabled": true, + "max_tokens": 2000, + }, + }) + return + } + + if strings.Contains(modelSlug, "/") { + params.SetExtraFields(map[string]any{ + "reasoning": map[string]any{ + "enabled": true, + "effort": "medium", + }, + }) + return + } + + params.ReasoningEffort = sharedv3.ReasoningEffortMedium +} + func getDefaultParamsV2(modelSlug string, toolRegistry *registry.ToolRegistryV2) openaiv3.ChatCompletionNewParams { var reasoningModels = []string{ "gpt-5", + "gpt-5.4", + "gpt-5.4-mini", + "gpt-5.4-nano", "gpt-5-mini", "gpt-5-nano", "gpt-5-chat-latest", @@ -65,16 +110,20 @@ func getDefaultParamsV2(modelSlug string, toolRegistry *registry.ToolRegistryV2) "o1-mini", "o1", "codex-mini-latest", + "claude-opus-4.6", + "claude-4.6-opus", } for _, model := range reasoningModels { if strings.Contains(modelSlug, model) { - return openaiv3.ChatCompletionNewParams{ + params := openaiv3.ChatCompletionNewParams{ Model: modelSlug, MaxCompletionTokens: openaiv3.Int(4000), Tools: toolRegistry.GetTools(), ParallelToolCalls: openaiv3.Bool(true), Store: openaiv3.Bool(false), } + configureReasoningDefaultsV2(¶ms, modelSlug) + return params } } @@ -134,7 +183,8 @@ func initializeToolkitV2( documentStructureTool := latextools.NewDocumentStructureTool(projectService) toolRegistry.Register("get_document_structure", latextools.GetDocumentStructureToolDescriptionV2, documentStructureTool.Call) - toolRegistry.Register("locate_section", latextools.LocateSectionToolDescriptionV2, latextools.LocateSectionTool) + locateSectionTool := latextools.NewLocateSectionTool(projectService) + toolRegistry.Register("locate_section", latextools.LocateSectionToolDescriptionV2, locateSectionTool.Call) readSectionSourceTool := latextools.NewReadSectionSourceTool(projectService) toolRegistry.Register("read_section_source", latextools.ReadSectionSourceToolDescriptionV2, readSectionSourceTool.Call) @@ -142,6 +192,13 @@ func initializeToolkitV2( readSourceLineRangeTool := latextools.NewReadSourceLineRangeTool(projectService) toolRegistry.Register("read_source_line_range", latextools.ReadSourceLineRangeToolDescriptionV2, readSourceLineRangeTool.Call) + // Register review tools so the agent can produce structured Overleaf/TeX comments. + paperScoreTool := maintools.NewPaperScoreTool(db, projectService) + toolRegistry.Register("paper_score", maintools.PaperScoreToolDescriptionV2, paperScoreTool.Call) + + paperScoreCommentTool := maintools.NewPaperScoreCommentTool(db, projectService, services.NewReverseCommentService(db, cfg, logger, projectService)) + toolRegistry.Register("paper_score_comment", maintools.PaperScoreCommentToolDescriptionV2, paperScoreCommentTool.Call) + // Load tools dynamically from backend xtraMCPLoader := xtramcp.NewXtraMCPLoaderV2(db, projectService, cfg.XtraMCPURI) diff --git a/internal/services/toolkit/handler/toolcall_v2.go b/internal/services/toolkit/handler/toolcall_v2.go index 1a887c17..a1973cf3 100644 --- a/internal/services/toolkit/handler/toolcall_v2.go +++ b/internal/services/toolkit/handler/toolcall_v2.go @@ -39,7 +39,7 @@ type AppChatHistory []chatv2.Message // - openaiChatHistory: The OpenAI-compatible chat history including tool call and output items. // - inappChatHistory: The in-app chat history as a slice of chatv2.Message, reflecting tool call events. // - error: Any error encountered during processing (always nil in current implementation). -func (h *ToolCallHandlerV2) HandleToolCallsV2(ctx context.Context, toolCalls []openai.FinishedChatCompletionToolCall, streamHandler *StreamHandlerV2) (OpenAIChatHistory, AppChatHistory, error) { +func (h *ToolCallHandlerV2) HandleToolCallsV2(ctx context.Context, toolCalls []openai.FinishedChatCompletionToolCall, assistantContent string, assistantReasoning string, streamHandler *StreamHandlerV2) (OpenAIChatHistory, AppChatHistory, error) { if len(toolCalls) == 0 { return nil, nil, nil } @@ -61,10 +61,29 @@ func (h *ToolCallHandlerV2) HandleToolCallsV2(ctx context.Context, toolCalls []o } } + assistantMessage := openai.ChatCompletionAssistantMessageParam{ + ToolCalls: toolCallsParam, + } + if strings.TrimSpace(assistantContent) != "" { + assistantMessage.Content = openai.ChatCompletionAssistantMessageParamContentUnion{ + OfArrayOfContentParts: []openai.ChatCompletionAssistantMessageParamContentArrayOfContentPartUnion{ + { + OfText: &openai.ChatCompletionContentPartTextParam{ + Type: "text", + Text: assistantContent, + }, + }, + }, + } + } + if strings.TrimSpace(assistantReasoning) != "" { + assistantMessage.SetExtraFields(map[string]any{ + "reasoning": assistantReasoning, + }) + } + openaiChatHistory = append(openaiChatHistory, openai.ChatCompletionMessageParamUnion{ - OfAssistant: &openai.ChatCompletionAssistantMessageParam{ - ToolCalls: toolCallsParam, - }, + OfAssistant: &assistantMessage, }) // Iterate over each output item to process tool calls diff --git a/internal/services/toolkit/tools/latex/locate_section.go b/internal/services/toolkit/tools/latex/locate_section.go index df7d7d65..5fbba420 100644 --- a/internal/services/toolkit/tools/latex/locate_section.go +++ b/internal/services/toolkit/tools/latex/locate_section.go @@ -4,6 +4,12 @@ import ( "context" "encoding/json" "fmt" + "regexp" + "strings" + + "paperdebugger/internal/models" + "paperdebugger/internal/services" + "paperdebugger/internal/services/toolkit" "github.com/openai/openai-go/v3" "github.com/openai/openai-go/v3/packages/param" @@ -13,13 +19,13 @@ var LocateSectionToolDescriptionV2 = openai.ChatCompletionToolUnionParam{ OfFunction: &openai.ChatCompletionFunctionToolParam{ Function: openai.FunctionDefinitionParam{ Name: "locate_section", - Description: param.NewOpt("Locates a specific section by its title and returns the exact position (file path + line number range). Locates a specific section by its title and returns the file path and line number range."), + Description: param.NewOpt("Locates a specific section or manuscript region by title and returns the file path and line number range. Also supports special targets like Title and Abstract."), Parameters: openai.FunctionParameters{ "type": "object", "properties": map[string]interface{}{ "title": map[string]any{ "type": "string", - "description": "The title of the section to locate (e.g., 'Introduction', 'Related Work').", + "description": "The title of the section to locate (e.g., 'Introduction', 'Related Work', 'Abstract', or 'Title').", }, }, "required": []string{"title"}, @@ -32,13 +38,276 @@ type LocateSectionArgs struct { Title string `json:"title"` } -func LocateSectionTool(ctx context.Context, toolCallId string, args json.RawMessage) (string, string, error) { +type LocateSectionTool struct { + projectService *services.ProjectService +} + +type locatedSection struct { + Found bool `json:"found"` + Title string `json:"title"` + FilePath string `json:"file_path,omitempty"` + StartLine int `json:"start_line,omitempty"` + EndLine int `json:"end_line,omitempty"` + MatchedTitle string `json:"matched_title,omitempty"` + Kind string `json:"kind,omitempty"` + Message string `json:"message,omitempty"` +} + +type sectionMatch struct { + level int + title string + filePath string + line int +} + +var ( + titleCommandPattern = regexp.MustCompile(`\\title(?:\[[^\]]*\])?\{`) + abstractStartPattern = regexp.MustCompile(`\\begin\{abstract\}`) + abstractEndPattern = regexp.MustCompile(`\\end\{abstract\}`) + sectionHeaderPatterns = []struct { + level int + pattern *regexp.Regexp + }{ + {0, regexp.MustCompile(`^[^%]*\\part\*?\{([^}]*)\}`)}, + {1, regexp.MustCompile(`^[^%]*\\chapter\*?\{([^}]*)\}`)}, + {2, regexp.MustCompile(`^[^%]*\\section\*?\{([^}]*)\}`)}, + {3, regexp.MustCompile(`^[^%]*\\subsection\*?\{([^}]*)\}`)}, + {4, regexp.MustCompile(`^[^%]*\\subsubsection\*?\{([^}]*)\}`)}, + } + nonAlphanumericPattern = regexp.MustCompile(`[^a-z0-9]+`) +) + +func NewLocateSectionTool(projectService *services.ProjectService) *LocateSectionTool { + return &LocateSectionTool{ + projectService: projectService, + } +} + +func (t *LocateSectionTool) Call(ctx context.Context, toolCallId string, args json.RawMessage) (string, string, error) { var getArgs LocateSectionArgs if err := json.Unmarshal(args, &getArgs); err != nil { return "", "", err } - // TODO: Implement actual section location logic - return "", "", fmt.Errorf("locate_section tool is not yet implemented: cannot locate section '%s'", getArgs.Title) + if strings.TrimSpace(getArgs.Title) == "" { + result, _ := json.Marshal(locatedSection{ + Found: false, + Title: "", + Message: "title is required", + }) + return string(result), "", nil + } + + actor, projectID, _ := toolkit.GetActorProjectConversationID(ctx) + if actor == nil || projectID == "" { + return "", "", fmt.Errorf("failed to get actor or project id from context") + } + + project, err := t.projectService.GetProject(ctx, actor.ID, projectID) + if err != nil { + return "", "", fmt.Errorf("failed to get project: %w", err) + } + + result := locateSectionInProject(project, getArgs.Title) + resultJSON, err := json.Marshal(result) + if err != nil { + return "", "", err + } + return string(resultJSON), "", nil +} + +func locateSectionInProject(project *models.Project, query string) locatedSection { + normalizedQuery := normalizeSectionLookup(query) + if normalizedQuery == "" { + return locatedSection{ + Found: false, + Title: query, + Message: "empty section query", + } + } + + if match, ok := findSpecialSectionLocation(project, normalizedQuery); ok { + return locatedSection{ + Found: true, + Title: query, + FilePath: match.filePath, + StartLine: match.startLine, + EndLine: match.endLine, + MatchedTitle: match.matchedTitle, + Kind: match.kind, + } + } + + entriesByFile := collectProjectSectionMatches(project) + for filePath, entries := range entriesByFile { + for idx, entry := range entries { + normalizedTitle := normalizeSectionLookup(entry.title) + if normalizedTitle != normalizedQuery { + continue + } + + endLine := len(findDocLines(project, filePath)) + for nextIdx := idx + 1; nextIdx < len(entries); nextIdx++ { + if entries[nextIdx].level <= entry.level { + endLine = entries[nextIdx].line - 1 + break + } + } + + if endLine < entry.line { + endLine = entry.line + } + + return locatedSection{ + Found: true, + Title: query, + FilePath: filePath, + StartLine: entry.line, + EndLine: endLine, + MatchedTitle: entry.title, + Kind: "section", + } + } + } + + return locatedSection{ + Found: false, + Title: query, + Message: fmt.Sprintf("could not locate section '%s'", query), + } +} + +func collectProjectSectionMatches(project *models.Project) map[string][]sectionMatch { + entriesByFile := make(map[string][]sectionMatch) + for _, doc := range project.Docs { + for lineIdx, line := range doc.Lines { + for _, sectionPattern := range sectionHeaderPatterns { + matches := sectionPattern.pattern.FindStringSubmatch(line) + if len(matches) < 2 { + continue + } + title := cleanLaTeXTitle(strings.TrimSpace(matches[1])) + if title == "" { + continue + } + entriesByFile[doc.Filepath] = append(entriesByFile[doc.Filepath], sectionMatch{ + level: sectionPattern.level, + title: title, + filePath: doc.Filepath, + line: lineIdx + 1, + }) + break + } + } + } + return entriesByFile +} + +type specialSectionLocation struct { + filePath string + startLine int + endLine int + matchedTitle string + kind string +} + +func findSpecialSectionLocation(project *models.Project, normalizedQuery string) (specialSectionLocation, bool) { + docs := orderedProjectDocs(project) + + switch normalizedQuery { + case "title": + for _, doc := range docs { + for idx, line := range doc.Lines { + if titleCommandPattern.MatchString(strings.TrimSpace(line)) { + return specialSectionLocation{ + filePath: doc.Filepath, + startLine: idx + 1, + endLine: idx + 1, + matchedTitle: "Title", + kind: "title", + }, true + } + } + } + case "abstract": + for _, doc := range docs { + startLine := -1 + for idx, line := range doc.Lines { + trimmed := strings.TrimSpace(line) + if startLine == -1 && abstractStartPattern.MatchString(trimmed) { + startLine = idx + 1 + continue + } + if startLine != -1 && abstractEndPattern.MatchString(trimmed) { + return specialSectionLocation{ + filePath: doc.Filepath, + startLine: startLine, + endLine: idx + 1, + matchedTitle: "Abstract", + kind: "abstract", + }, true + } + } + if startLine != -1 { + return specialSectionLocation{ + filePath: doc.Filepath, + startLine: startLine, + endLine: len(doc.Lines), + matchedTitle: "Abstract", + kind: "abstract", + }, true + } + } + } + + return specialSectionLocation{}, false +} + +func orderedProjectDocs(project *models.Project) []*models.ProjectDoc { + ordered := make([]*models.ProjectDoc, 0, len(project.Docs)) + for idx := range project.Docs { + if project.Docs[idx].ID == project.RootDocID { + ordered = append(ordered, &project.Docs[idx]) + break + } + } + for idx := range project.Docs { + if project.Docs[idx].ID == project.RootDocID { + continue + } + ordered = append(ordered, &project.Docs[idx]) + } + return ordered +} + +func findDocLines(project *models.Project, filePath string) []string { + for _, doc := range project.Docs { + if doc.Filepath == filePath { + return doc.Lines + } + } + return nil +} + +func normalizeSectionLookup(text string) string { + text = strings.ToLower(strings.TrimSpace(text)) + text = cleanLaTeXTitle(text) + text = nonAlphanumericPattern.ReplaceAllString(text, " ") + return strings.Join(strings.Fields(text), " ") +} + +// LocateSectionToolLegacy for backward compatibility (standalone function) +func LocateSectionToolLegacy(ctx context.Context, toolCallId string, args json.RawMessage) (string, string, error) { + var getArgs LocateSectionArgs + if err := json.Unmarshal(args, &getArgs); err != nil { + return "", "", err + } + + result, _ := json.Marshal(locatedSection{ + Found: false, + Title: getArgs.Title, + Message: "locate_section tool is not properly initialized. Please ensure ProjectService is available.", + }) + return string(result), "", nil } diff --git a/internal/services/toolkit/tools/paper_score.go b/internal/services/toolkit/tools/paper_score.go index 42a22e23..aeb9b99c 100644 --- a/internal/services/toolkit/tools/paper_score.go +++ b/internal/services/toolkit/tools/paper_score.go @@ -17,6 +17,8 @@ import ( "github.com/openai/openai-go/v2/packages/param" "github.com/openai/openai-go/v2/responses" + openaiv3 "github.com/openai/openai-go/v3" + paramv3 "github.com/openai/openai-go/v3/packages/param" ) type PaperScoreTool struct { @@ -31,11 +33,24 @@ type PaperScoreTool struct { var PaperScoreToolDescription = responses.ToolUnionParam{ OfFunction: &responses.FunctionToolParam{ Name: "paper_score", - Description: param.NewOpt("Scoring the paper and get the score, percentile, details, and suggestions. After the score is generated, you can call the paper_score_comment function to get the actionable comment for the paper score."), + Description: param.NewOpt("Score the paper and return its score, percentile, details, and suggestions. After scoring, call paper_score_comment to generate author-facing, actionable, Overleaf-ready review comments."), // No parameters, because we can get the paper content from the database. }, } +var PaperScoreToolDescriptionV2 = openaiv3.ChatCompletionToolUnionParam{ + OfFunction: &openaiv3.ChatCompletionFunctionToolParam{ + Function: openaiv3.FunctionDefinitionParam{ + Name: "paper_score", + Description: paramv3.NewOpt("Score the paper and return its score, percentile, details, and suggestions. After scoring, call paper_score_comment to generate author-facing, actionable, Overleaf-ready review comments."), + Parameters: openaiv3.FunctionParameters{ + "type": "object", + "properties": map[string]any{}, + }, + }, + }, +} + func NewPaperScoreTool(db *db.DB, projectService *services.ProjectService) *PaperScoreTool { toolCallRecordDB := toolCallRecordDB.NewToolCallRecordDB(db) return &PaperScoreTool{ @@ -89,7 +104,7 @@ func (t *PaperScoreTool) Call(ctx context.Context, toolCallId string, args json. return "", "", err } - furtherInstruction := "Then, call the paper_score_comment function to get the actionable comment for the paper score." + furtherInstruction := "Then, call the paper_score_comment function to generate step-by-step, author-facing review comments that can be added to Overleaf." return string(responseJSON), furtherInstruction, nil } diff --git a/internal/services/toolkit/tools/paper_score_comment.go b/internal/services/toolkit/tools/paper_score_comment.go index 1938af76..8b5b5b4f 100644 --- a/internal/services/toolkit/tools/paper_score_comment.go +++ b/internal/services/toolkit/tools/paper_score_comment.go @@ -18,6 +18,8 @@ import ( "github.com/openai/openai-go/v2/packages/param" "github.com/openai/openai-go/v2/responses" + openaiv3 "github.com/openai/openai-go/v3" + paramv3 "github.com/openai/openai-go/v3/packages/param" ) type PaperScoreCommentRequest struct { @@ -35,12 +37,25 @@ type PaperScoreCommentTool struct { client *http.Client } +var PaperScoreCommentToolDescriptionV2 = openaiv3.ChatCompletionToolUnionParam{ + OfFunction: &openaiv3.ChatCompletionFunctionToolParam{ + Function: openaiv3.FunctionDefinitionParam{ + Name: "paper_score_comment", + Description: paramv3.NewOpt("Generate actionable, author-facing review comments for the paper score. Use this when the user wants comments added directly into Overleaf or the TeX source. Return structured comments instead of manually writing raw annotated LaTeX review snippets."), + Parameters: openaiv3.FunctionParameters{ + "type": "object", + "properties": map[string]any{}, + }, + }, + }, +} + func NewPaperScoreCommentTool(db *db.DB, projectService *services.ProjectService, reverseCommentService *services.ReverseCommentService) *PaperScoreCommentTool { toolCallRecordDB := toolCallRecordDB.NewToolCallRecordDB(db) paperScoreCommentToolDescription := responses.ToolUnionParam{ OfFunction: &responses.FunctionToolParam{ Name: "paper_score_comment", - Description: param.NewOpt("Get the actionable comment for the paper score. usually the comment is about the weakness of the paper."), + Description: param.NewOpt("Generate actionable, author-facing review comments for the paper score. Use this when the user wants comments added directly into Overleaf or the TeX source. Return structured comments instead of manually writing raw annotated LaTeX review snippets."), }, } diff --git a/internal/services/user_prompt_debug.tmpl b/internal/services/user_prompt_debug.tmpl index e9e0d14c..a7dd0d46 100644 --- a/internal/services/user_prompt_debug.tmpl +++ b/internal/services/user_prompt_debug.tmpl @@ -14,4 +14,12 @@ {{ .Surrounding }} ``` {{- end }} + +If the user asks for critique or review of the selected text, structure the response step by step as: +1. Issue +2. Why it matters +3. How to fix it +4. Optional improved wording or local revision strategy + +If the user asks to add comments directly into Overleaf, directly into the paper, or directly into the TeX source, prefer structured Overleaf-ready comments via the available paper review tools rather than raw annotated LaTeX snippets. {{- end }} diff --git a/internal/services/user_prompt_default.tmpl b/internal/services/user_prompt_default.tmpl index af71d6b3..cee47a76 100644 --- a/internal/services/user_prompt_default.tmpl +++ b/internal/services/user_prompt_default.tmpl @@ -11,6 +11,14 @@ Context around the selection: ``` {{- end }} +If the user asks for critique, review, referee-style feedback, or author guidance on the selected text, structure the response step by step: +1. Issue +2. Why it matters +3. How to fix it +4. Optional improved wording or local revision strategy + +If the user asks to add comments directly into Overleaf, directly into the paper, or directly into the TeX source, do not return manual review markup by default. Prefer structured Overleaf-ready comments via the available paper review tools. + If the user requests to revise the selected text, include a separate block where the revised text is wrapped inside `` tags, like `...revised text...`. The content inside `` MUST be only the revised text (no explanations, no markdown formatting, no surrounding backticks); any explanations should be placed outside of the `` block. Otherwise, just answer the question normally. diff --git a/internal/wire_gen.go b/internal/wire_gen.go index 75c4e91a..52cbd0e8 100644 --- a/internal/wire_gen.go +++ b/internal/wire_gen.go @@ -43,7 +43,7 @@ func InitializeApp() (*api.Server, error) { chatv2ChatServiceServer := chat.NewChatServerV2(aiClientV2, chatServiceV2, projectService, userService, loggerLogger, cfgCfg) promptService := services.NewPromptService(dbDB, cfgCfg, loggerLogger) userServiceServer := user.NewUserServer(userService, promptService, cfgCfg, loggerLogger) - projectServiceServer := project.NewProjectServer(projectService, loggerLogger, cfgCfg) + projectServiceServer := project.NewProjectServer(projectService, reverseCommentService, loggerLogger, cfgCfg) commentServiceServer := comment.NewCommentServer(projectService, chatService, reverseCommentService, loggerLogger, cfgCfg) grpcServer := api.NewGrpcServer(userService, cfgCfg, authServiceServer, chatServiceServer, chatv2ChatServiceServer, userServiceServer, projectServiceServer, commentServiceServer) oAuthService := services.NewOAuthService(dbDB, cfgCfg, loggerLogger) diff --git a/webapp/_webapp/src/background.ts b/webapp/_webapp/src/background.ts index d6bc2a8a..dcf9a391 100644 --- a/webapp/_webapp/src/background.ts +++ b/webapp/_webapp/src/background.ts @@ -127,7 +127,8 @@ browserAPI.runtime?.onMessage?.addListener( const handler = handlers.find((h) => h.name === request.action) as HandlerAny; if (!handler) { - return true; + sendResponse({ error: `Unknown background action: ${request.action}` }); + return false; } (async () => { diff --git a/webapp/_webapp/src/components/message-entry-container/tools/paper-score-comment/add-comments-button.tsx b/webapp/_webapp/src/components/message-entry-container/tools/paper-score-comment/add-comments-button.tsx index 472fa31f..36ad0e4f 100644 --- a/webapp/_webapp/src/components/message-entry-container/tools/paper-score-comment/add-comments-button.tsx +++ b/webapp/_webapp/src/components/message-entry-container/tools/paper-score-comment/add-comments-button.tsx @@ -1,12 +1,19 @@ -import { useState } from "react"; +import { useEffect, useRef, useState } from "react"; import { Icon } from "@iconify/react/dist/iconify.js"; import { OverleafComment } from "../../../../pkg/gen/apiclient/project/v1/project_pb"; import { useSocketStore } from "../../../../stores/socket-store"; -import { addClickedOverleafComment, hasClickedOverleafComment } from "../../../../libs/helpers"; +import { + addClickedOverleafComment, + addClickedOverleafTexComment, + hasClickedOverleafComment, + hasClickedOverleafTexComment, +} from "../../../../libs/helpers"; import { acceptComments } from "../../../../query/api"; import { fromJson } from "../../../../libs/protobuf-utils"; import { CommentsAcceptedRequestSchema } from "../../../../pkg/gen/apiclient/comment/v1/comment_pb"; import { useConversationStore } from "../../../../stores/conversation/conversation-store"; +import { errorToast, successToast } from "../../../../libs/toasts"; +import { formatTexSourceComment } from "./utils"; type AddCommentsButtonProps = { projectId: string; @@ -15,6 +22,7 @@ type AddCommentsButtonProps = { overleafSession: string; gclb: string; setIsSuggestionsExpanded: (value: boolean) => void; + shouldAutoInsertTexComments?: boolean; }; export const AddCommentsButton = ({ @@ -24,16 +32,30 @@ export const AddCommentsButton = ({ overleafSession, gclb, setIsSuggestionsExpanded, + shouldAutoInsertTexComments = false, }: AddCommentsButtonProps) => { - const { connectSocket, disconnectSocket, addComment } = useSocketStore(); + const { connectSocket, disconnectSocket, addComment, addTexComments } = useSocketStore(); const [isLoading, setIsLoading] = useState(false); + const [isTexLoading, setIsTexLoading] = useState(false); const [currentProgress, setCurrentProgress] = useState(0); + const [texProgress, setTexProgress] = useState(0); const [errorMessage, setErrorMessage] = useState(""); + const [texErrorMessage, setTexErrorMessage] = useState(""); const { currentConversation } = useConversationStore(); + const hasAttemptedAutoInsert = useRef(false); + const uniqueDocPaths = Array.from(new Set(comments.map((comment) => comment.docPath).filter(Boolean))); + const uniqueSections = Array.from(new Set(comments.map((comment) => comment.section).filter(Boolean))); + const targetSummary = + uniqueDocPaths.length === 0 + ? "current selection" + : uniqueDocPaths.length <= 2 + ? uniqueDocPaths.join(", ") + : `${uniqueDocPaths.slice(0, 2).join(", ")} +${uniqueDocPaths.length - 2} more`; const handleAddComments = async () => { setIsLoading(true); setCurrentProgress(0); + setErrorMessage(""); try { const csrfToken = document.querySelector('meta[name="ol-csrfToken"]')?.getAttribute("content") || ""; if (csrfToken.length === 0) { @@ -67,6 +89,7 @@ export const AddCommentsButton = ({ setErrorMessage(""); addClickedOverleafComment(projectId, messageId); setIsSuggestionsExpanded(false); + successToast(`Added ${comments.length} Overleaf thread(s) for ${targetSummary}.`, "Review Threads Added"); acceptComments( fromJson(CommentsAcceptedRequestSchema, { projectId: projectId, @@ -76,52 +99,152 @@ export const AddCommentsButton = ({ }), ); } catch (error) { - setErrorMessage(error instanceof Error ? error.message : "Unknown error"); + const message = error instanceof Error ? error.message : "Unknown error"; + setErrorMessage(message); + errorToast(message, "Overleaf Thread Add Failed"); } finally { setIsLoading(false); setCurrentProgress(0); } }; + const handleAddTexComments = async () => { + setIsTexLoading(true); + setTexProgress(0); + setTexErrorMessage(""); + + try { + const csrfToken = document.querySelector('meta[name="ol-csrfToken"]')?.getAttribute("content") || ""; + if (csrfToken.length === 0) { + throw new Error("CSRF token not found"); + } + + await connectSocket( + projectId, + { + cookieOverleafSession2: overleafSession, + cookieGCLB: gclb, + }, + csrfToken, + ); + + await addTexComments( + comments.map((comment) => ({ + ...comment, + comment: formatTexSourceComment(comment.importance, comment.section, comment.comment), + })), + ); + setTexProgress(comments.length); + + disconnectSocket(); + addClickedOverleafTexComment(projectId, messageId); + setIsSuggestionsExpanded(false); + successToast(`Inserted ${comments.length} TeX comment block(s) into ${targetSummary}.`, "TeX Comments Inserted"); + } catch (error) { + const message = error instanceof Error ? error.message : "Unknown error"; + setTexErrorMessage(message); + errorToast(message, "TeX Comment Insert Failed"); + } finally { + setIsTexLoading(false); + setTexProgress(0); + } + }; + const alreadyClicked = hasClickedOverleafComment(projectId, messageId); - const hasValidCookies = overleafSession.length > 0 && gclb.length > 0; + const alreadyInsertedTex = hasClickedOverleafTexComment(projectId, messageId); + const hasValidCookies = overleafSession.length > 0; + + useEffect(() => { + if (!shouldAutoInsertTexComments) return; + if (hasAttemptedAutoInsert.current) return; + if (!hasValidCookies || comments.length === 0 || alreadyInsertedTex || isTexLoading) return; + + hasAttemptedAutoInsert.current = true; + void handleAddTexComments(); + }, [shouldAutoInsertTexComments, hasValidCookies, comments.length, alreadyInsertedTex, isTexLoading]); return ( <> - + ) : alreadyClicked ? ( + + + Added Threads + + ) : !hasValidCookies ? ( + + Overleaf session required + + ) : errorMessage.length > 0 ? ( + Thread Add Failed + ) : ( + Add {comments.length} Overleaf Threads + )} + + + + {errorMessage.length > 0 && (
Error: {errorMessage}
)} -
- Note: this operation does not modify your paper. + {texErrorMessage.length > 0 && ( +
+ TeX insert error: {texErrorMessage} +
+ )} +
+ Thread mode adds native Overleaf review threads. TeX mode inserts real `% PaperDebugger ...` comments into the `.tex` source. +
+
+ + Files: {targetSummary} + + {uniqueSections.length > 0 && ( + + Sections: {uniqueSections.length} + + )} + + Mentions: {comments.length} target point(s) +
{/* TODO: report user selected comments to server */} diff --git a/webapp/_webapp/src/components/message-entry-container/tools/paper-score-comment/index.tsx b/webapp/_webapp/src/components/message-entry-container/tools/paper-score-comment/index.tsx index 1df2f868..13944b55 100644 --- a/webapp/_webapp/src/components/message-entry-container/tools/paper-score-comment/index.tsx +++ b/webapp/_webapp/src/components/message-entry-container/tools/paper-score-comment/index.tsx @@ -13,6 +13,7 @@ import { StatsSummary } from "./stats-summary"; import { FilterControls } from "./filter-controls"; import { CommentsList } from "./comments-list"; import { AddCommentsButton } from "./add-comments-button"; +import { useMessageStore } from "../../../../stores/message-store"; const CardBody = ({ children }: { children: React.ReactNode }) => { return
{children}
; @@ -20,6 +21,7 @@ const CardBody = ({ children }: { children: React.ReactNode }) => { export const PaperScoreCommentCard = ({ messageId, message, preparing, animated }: PaperScoreCommentCardProps) => { const projectId = getProjectId(); + const visibleMessages = useMessageStore((state) => state.visibleDisplayMessages); const [overleafSession, setOverleafSession] = useState(""); const [gclb, setGclb] = useState(""); const [isSuggestionsExpanded, setIsSuggestionsExpanded] = useState(false); @@ -56,6 +58,29 @@ export const PaperScoreCommentCard = ({ messageId, message, preparing, animated } }, [message]); + const latestUserMessage = [...visibleMessages].reverse().find((entry) => entry.type === "user"); + const latestUserIntent = latestUserMessage?.content?.toLowerCase() ?? ""; + const texInsertIntentPhrases = [ + "add direct comments", + "direct comments in the paper", + "directly add comments", + "insert comments in the tex", + "insert comments into the tex", + "add comments into the tex", + "add comments into the tex source", + "comment directly in overleaf", + "add comments in overleaf", + "add comments into overleaf", + "add comments to the paper", + "add the real comments", + "tex file in overleaf", + "tex file of overleaf", + "insert into the tex file", + "review and insert", + "review & insert", + ]; + const shouldAutoInsertTexComments = texInsertIntentPhrases.some((phrase) => latestUserIntent.includes(phrase)); + if (preparing) { return (
@@ -160,6 +185,7 @@ export const PaperScoreCommentCard = ({ messageId, message, preparing, animated overleafSession={overleafSession} gclb={gclb} setIsSuggestionsExpanded={setIsSuggestionsExpanded} + shouldAutoInsertTexComments={shouldAutoInsertTexComments} /> ); diff --git a/webapp/_webapp/src/components/message-entry-container/tools/paper-score-comment/utils.ts b/webapp/_webapp/src/components/message-entry-container/tools/paper-score-comment/utils.ts index ebb0cb4d..1c4d9bd1 100644 --- a/webapp/_webapp/src/components/message-entry-container/tools/paper-score-comment/utils.ts +++ b/webapp/_webapp/src/components/message-entry-container/tools/paper-score-comment/utils.ts @@ -27,3 +27,21 @@ export const getImportanceIcon = (importance: string) => { export const cleanCommentText = (comment: string) => { return comment.replace("👨🏻‍💻 Medium:", "").replace("👨🏻‍💻 High:", "").replace("👨🏻‍💻 Critical:", "").replace("👨🏻‍💻 Low:", ""); }; + +const wrapCommentLine = (line: string) => { + const trimmed = line.trim(); + return trimmed.length > 0 ? `% ${trimmed}` : "%"; +}; + +export const formatTexSourceComment = (importance: string, section: string, comment: string) => { + const cleanedComment = cleanCommentText(comment).trim(); + const header = importance ? `PaperDebugger ${importance} review comment` : "PaperDebugger review comment"; + const sectionLine = section ? `Section: ${section}` : ""; + + const lines = [header, sectionLine, cleanedComment] + .filter((line) => line.trim().length > 0) + .flatMap((line) => line.split("\n")) + .map(wrapCommentLine); + + return `\n${lines.join("\n")}\n`; +}; diff --git a/webapp/_webapp/src/hooks/useLanguageModels.ts b/webapp/_webapp/src/hooks/useLanguageModels.ts index a45b3761..cada06bb 100644 --- a/webapp/_webapp/src/hooks/useLanguageModels.ts +++ b/webapp/_webapp/src/hooks/useLanguageModels.ts @@ -1,4 +1,4 @@ -import { useCallback, useMemo } from "react"; +import { useCallback, useEffect, useMemo } from "react"; import { SupportedModel } from "../pkg/gen/apiclient/chat/v2/chat_pb"; import { useConversationStore } from "../stores/conversation/conversation-store"; import { useListSupportedModelsQuery } from "../query"; @@ -22,16 +22,54 @@ const extractProvider = (slug: string): string => { return parts.length > 1 ? parts[0] : "openai"; }; +const normalizeModelId = (slug: string): string => slug.toLowerCase().trim().split("/").filter(Boolean).pop() ?? ""; + +const normalizeModelAlias = (slug: string): string => { + const modelId = normalizeModelId(slug); + if (modelId === "claude-4.6-opus") return "claude-opus-4.6"; + return modelId; +}; + // Fallback models in case the API fails const fallbackModels: Model[] = [ { - name: "GPT-4.1", - slug: "openai/gpt-4.1", + name: "GPT-5.4", + slug: "openai/gpt-5.4", provider: "openai", totalContext: 1050000, - maxOutput: 32800, - inputPrice: 200, - outputPrice: 800, + maxOutput: 128000, + inputPrice: 250, + outputPrice: 1500, + disabled: false, + }, + { + name: "GPT-5.4 Mini", + slug: "openai/gpt-5.4-mini", + provider: "openai", + totalContext: 400000, + maxOutput: 128000, + inputPrice: 75, + outputPrice: 450, + disabled: false, + }, + { + name: "GPT-5.4 Nano", + slug: "openai/gpt-5.4-nano", + provider: "openai", + totalContext: 400000, + maxOutput: 128000, + inputPrice: 20, + outputPrice: 125, + disabled: false, + }, + { + name: "Claude Opus 4.6", + slug: "anthropic/claude-opus-4.6", + provider: "anthropic", + totalContext: 1000000, + maxOutput: 128000, + inputPrice: 500, + outputPrice: 2500, disabled: false, }, ]; @@ -54,10 +92,18 @@ export const useLanguageModels = () => { const { data: supportedModelsResponse } = useListSupportedModelsQuery(); const models: Model[] = useMemo(() => { - if (supportedModelsResponse?.models && supportedModelsResponse.models.length > 0) { - return supportedModelsResponse.models.map(mapSupportedModelToModel); + const supportedModels = supportedModelsResponse?.models?.map(mapSupportedModelToModel) ?? []; + const mergedModels = [...supportedModels]; + const seen = new Set(supportedModels.map((model) => normalizeModelAlias(model.slug))); + + for (const fallbackModel of fallbackModels) { + const normalizedSlug = normalizeModelAlias(fallbackModel.slug); + if (seen.has(normalizedSlug)) continue; + mergedModels.push(fallbackModel); + seen.add(normalizedSlug); } - return fallbackModels; + + return mergedModels.length > 0 ? mergedModels : fallbackModels; }, [supportedModelsResponse]); const currentModel = useMemo(() => { @@ -65,6 +111,21 @@ export const useLanguageModels = () => { return model || models[0]; }, [models, currentConversation.modelSlug]); + useEffect(() => { + if (!supportedModelsResponse?.models?.length) return; + if (models.some((model) => model.slug === currentConversation.modelSlug)) return; + + const currentId = normalizeModelAlias(currentConversation.modelSlug); + const matchingModel = models.find((model) => normalizeModelAlias(model.slug) === currentId) ?? models[0]; + if (!matchingModel || matchingModel.slug === currentConversation.modelSlug) return; + + setCurrentConversation({ + ...currentConversation, + modelSlug: matchingModel.slug, + }); + setLastUsedModelSlug(matchingModel.slug); + }, [currentConversation, models, setCurrentConversation, setLastUsedModelSlug, supportedModelsResponse]); + const setModel = useCallback( (model: Model) => { setLastUsedModelSlug(model.slug); diff --git a/webapp/_webapp/src/hooks/useReviewAndInsert.ts b/webapp/_webapp/src/hooks/useReviewAndInsert.ts new file mode 100644 index 00000000..07760a64 --- /dev/null +++ b/webapp/_webapp/src/hooks/useReviewAndInsert.ts @@ -0,0 +1,857 @@ +import { JsonValue } from "@bufbuild/protobuf"; +import { useCallback } from "react"; +import { useAdapter } from "../adapters"; +import { formatTexSourceComment } from "../components/message-entry-container/tools/paper-score-comment/utils"; +import { getCookies } from "../intermediate"; +import { generateOverleafDocSHA1, getProjectId } from "../libs/helpers"; +import { logWarn } from "../libs/logger"; +import { fromJson } from "../libs/protobuf-utils"; +import { errorToast, successToast } from "../libs/toasts"; +import { + OverleafComment, + OverleafCommentSchema, +} from "../pkg/gen/apiclient/project/v1/project_pb"; +import { runProjectOverleafComment, runProjectPaperScoreComment } from "../query/api"; +import { useConversationStore } from "../stores/conversation/conversation-store"; +import { useSocketStore } from "../stores/socket-store"; +import { useSync } from "./useSync"; + +const DIRECT_INSERT_PATTERNS = [ + /\breview\s*&\s*insert\b/i, + /\b(add|insert|write|put)\b[\s\S]{0,80}\b(comment|comments|annotation|annotations|review)\b[\s\S]{0,80}\b(overleaf|tex|\.tex|paper)\b/i, + /\b(overleaf|tex|\.tex|paper)\b[\s\S]{0,80}\b(add|insert|write|put)\b[\s\S]{0,80}\b(comment|comments|annotation|annotations|review)\b/i, + /\bdirect comments?\b[\s\S]{0,80}\b(overleaf|tex|\.tex|paper)\b/i, + /\buse the paper review comment tool\b/i, +]; + +type ReviewAndInsertResult = { + comments: OverleafComment[]; + generatedCount: number; + insertedCount: number; + summaryPrompt: string; +}; + +type ParsedReviewComment = { + section: string; + comment: string; + importance: string; + anchorHint: string; +}; + +type ProjectDocSnapshot = { + id: string; + path: string; + version: number; + lines: string[]; +}; + +type LocatedComment = { + doc: ProjectDocSnapshot; + quotePosition: number; + quoteText: string; + section: string; + importance: string; + comment: string; +}; + +export class ReviewInsertError extends Error { + fallbackRecommended: boolean; + + constructor(message: string, fallbackRecommended = false) { + super(message); + this.name = "ReviewInsertError"; + this.fallbackRecommended = fallbackRecommended; + } +} + +export function shouldAutoReviewAndInsert(message: string): boolean { + const trimmed = message.trim(); + return DIRECT_INSERT_PATTERNS.some((pattern) => pattern.test(trimmed)); +} + +export function shouldUseAssistantTextFallback(error: unknown): boolean { + if (error instanceof ReviewInsertError) { + return error.fallbackRecommended; + } + const message = getErrorMessage(error).toLowerCase(); + return message.includes("not implemented"); +} + +function getErrorMessage(error: unknown): string { + if (error instanceof Error) { + return error.message; + } + if (typeof error === "object" && error && "message" in error && typeof error.message === "string") { + return error.message; + } + return String(error); +} + +function normalizeWhitespace(value: string): string { + return value.replace(/\s+/g, " ").trim(); +} + +function normalizeSectionName(value: string): string { + return normalizeWhitespace(value.toLowerCase().replace(/[^a-z0-9]+/g, " ")); +} + +function normalizeForMatch(value: string): string { + return normalizeWhitespace( + value + .toLowerCase() + .replace(/\\[a-z]+\*?/g, " ") + .replace(/[{}[\]()%$&#_^~]/g, " ") + .replace(/\\./g, " ") + .replace(/\.{3,}/g, " "), + ); +} + +function cleanSectionLabel(label: string): string { + return label + .replace(/^\s*[-*#\d.)\s]+/, "") + .replace(/\*\*/g, "") + .replace(/\s*\([^)]*\)\s*$/, "") + .trim(); +} + +function inferImportance(text: string): string { + const haystack = text.toLowerCase(); + if (haystack.includes("critical")) return "Critical"; + if (haystack.includes("major") || haystack.includes("severe")) return "High"; + if (haystack.includes("minor") || haystack.includes("small")) return "Low"; + return "Medium"; +} + +function lastMeaningfulLine(text: string): string { + const lines = text + .split("\n") + .map((line) => line.trim()) + .filter( + (line) => + line.length > 0 && + line.toLowerCase() !== "latex" && + line.toLowerCase() !== "tex" && + !line.startsWith("```") && + !line.startsWith("%"), + ); + return lines.at(-1) ?? ""; +} + +function extractAnchorHintFromHeading(rawHeading: string): string { + const quoted = rawHeading.match(/"([^"]+)"/); + if (quoted?.[1]) { + return quoted[1].trim(); + } + + const after = rawHeading.match(/\bafter\s+(.+)$/i); + if (after?.[1]) { + return after[1].replace(/[()]/g, "").trim(); + } + + return ""; +} + +function extractReviewEntriesFromCandidate(candidate: string, defaultAnchorHint: string): ParsedReviewComment[] { + const results: ParsedReviewComment[] = []; + const bracketMatches = [...candidate.matchAll(/\[REVIEW:\s*([\s\S]*?)\]/gi)]; + + for (const match of bracketMatches) { + const comment = normalizeWhitespace(match[1] ?? ""); + if (!comment) continue; + + const beforeMatch = candidate.slice(0, match.index ?? 0); + results.push({ + section: "", + comment, + importance: inferImportance(comment), + anchorHint: lastMeaningfulLine(beforeMatch) || defaultAnchorHint, + }); + } + + const lineMatches = [...candidate.matchAll(/^\s*%+\s*REVIEW:\s*(.+)$/gim)]; + for (const match of lineMatches) { + const comment = normalizeWhitespace(match[1] ?? ""); + if (!comment) continue; + const beforeMatch = candidate.slice(0, match.index ?? 0); + results.push({ + section: "", + comment, + importance: inferImportance(comment), + anchorHint: lastMeaningfulLine(beforeMatch) || defaultAnchorHint, + }); + } + + const issueMatches = [...candidate.matchAll(/^\s*Issue:\s*(.+)$/gim)]; + for (const match of issueMatches) { + const comment = normalizeWhitespace(match[1] ?? ""); + if (!comment) continue; + results.push({ + section: "", + comment, + importance: inferImportance(comment), + anchorHint: defaultAnchorHint, + }); + } + + return results; +} + +function isCodeFenceLine(line: string): boolean { + return line.trim().startsWith("```"); +} + +function isLikelyHeadingLine(line: string): boolean { + const trimmed = line.trim(); + if (!trimmed) return false; + if (isCodeFenceLine(trimmed)) return false; + if (/^\s*%+\s*REVIEW:/i.test(trimmed)) return false; + if (/^\s*Issue:\s*/i.test(trimmed)) return false; + if (/\[REVIEW:/i.test(trimmed)) return false; + if (/^[-*]\s+/.test(trimmed) && trimmed.split(":").length <= 1) return false; + return /:$/.test(trimmed); +} + +function inferSectionAndAnchorFromHeading(rawHeading: string): { section: string; anchorHint: string } { + const section = cleanSectionLabel(rawHeading); + const anchorHint = extractAnchorHintFromHeading(rawHeading); + return { section, anchorHint }; +} + +function parseInlineReviewComments(text: string): ParsedReviewComment[] { + const lines = text.replace(/\r\n/g, "\n").split("\n"); + const results: ParsedReviewComment[] = []; + let currentSection = ""; + let currentAnchorHint = ""; + + for (let i = 0; i < lines.length; i++) { + const line = lines[i].trim(); + if (!line) continue; + + if (isLikelyHeadingLine(line)) { + const inferred = inferSectionAndAnchorFromHeading(line.replace(/^[-*]\s*/, "")); + currentSection = inferred.section || currentSection; + currentAnchorHint = inferred.anchorHint || currentAnchorHint; + continue; + } + + const reviewMatch = line.match(/(?:^|.*)\[REVIEW:\s*([\s\S]*?)\]/i); + if (reviewMatch?.[1]) { + results.push({ + section: currentSection, + comment: normalizeWhitespace(reviewMatch[1]), + importance: inferImportance(reviewMatch[1]), + anchorHint: currentAnchorHint, + }); + continue; + } + + const percentReviewMatch = line.match(/^%+\s*REVIEW:\s*(.+)$/i); + if (percentReviewMatch?.[1]) { + const previousLine = i > 0 ? lines[i - 1].trim() : ""; + results.push({ + section: currentSection, + comment: normalizeWhitespace(percentReviewMatch[1]), + importance: inferImportance(percentReviewMatch[1]), + anchorHint: previousLine && !isCodeFenceLine(previousLine) ? previousLine : currentAnchorHint, + }); + continue; + } + + const plainSectionMatch = line.match( + /^(?:[-*]\s*)?(Title|Abstract|Introduction|Conclusion|Discussion|Methods?|Results?|Related Work|Experiments?|Evaluation|Background|Significance|Limitations?)\b([^:]{0,120})?:\s*(.+)$/i, + ); + if (plainSectionMatch?.[1] && plainSectionMatch?.[3]) { + const headingText = `${plainSectionMatch[1]}${plainSectionMatch[2] ?? ""}:`; + const inferred = inferSectionAndAnchorFromHeading(headingText); + results.push({ + section: inferred.section, + comment: normalizeWhitespace(plainSectionMatch[3]), + importance: inferImportance(plainSectionMatch[3]), + anchorHint: inferred.anchorHint, + }); + currentSection = inferred.section || currentSection; + currentAnchorHint = inferred.anchorHint || currentAnchorHint; + continue; + } + + const issueMatch = line.match(/^Issue:\s*(.+)$/i); + if (issueMatch?.[1]) { + results.push({ + section: currentSection, + comment: normalizeWhitespace(issueMatch[1]), + importance: inferImportance(issueMatch[1]), + anchorHint: currentAnchorHint, + }); + } + } + + return results; +} + +function inferSectionFromNarrativeHeading(heading: string): string { + const cleanedHeading = cleanSectionLabel(heading); + const match = cleanedHeading.match(/\b(?:in|for)\s+(.+)$/i); + return (match?.[1] ?? cleanedHeading).trim(); +} + +function parseNarrativeReviewBlocks(text: string): ParsedReviewComment[] { + const blocks = text + .split(/(?=^\s*\d+\.\s+)/gm) + .map((block) => block.trim()) + .filter(Boolean); + + const results: ParsedReviewComment[] = []; + + for (const block of blocks) { + const headingMatch = block.match(/^\s*\d+\.\s+(.+)$/m); + const heading = headingMatch?.[1]?.trim(); + if (!heading) continue; + + const problem = block.match(/^\s*Problem:\s*([\s\S]*?)(?=^\s*(?:Suggestion|Actionable fix|Proposed rewrite|Excerpt|Rationale|Source):|\Z)/im)?.[1]; + const suggestion = block.match( + /^\s*(?:Suggestion|Actionable fix|Proposed rewrite):\s*([\s\S]*?)(?=^\s*(?:Excerpt|Rationale|Source):|\Z)/im, + )?.[1]; + const excerpt = block.match(/^\s*Excerpt:\s*([\s\S]*?)(?=^\s*(?:Rationale|Source):|\Z)/im)?.[1]; + + const comment = normalizeWhitespace(suggestion ?? problem ?? ""); + if (!comment) continue; + + results.push({ + section: inferSectionFromNarrativeHeading(heading), + comment, + importance: inferImportance(`${heading} ${problem ?? ""}`), + anchorHint: normalizeWhitespace((excerpt ?? "").replace(/^["'`]+|["'`]+$/g, "")), + }); + } + + return results; +} + +function parseAssistantReviewComments(text: string): ParsedReviewComment[] { + const normalized = text.replace(/\r\n/g, "\n"); + const headingRegex = /^(?:[-*]\s*)?(?:\*\*)?([A-Z][^:\n]{0,160})(?:\*\*)?:\s*$/gm; + const matches = [...normalized.matchAll(headingRegex)]; + const parsed: ParsedReviewComment[] = []; + + for (let i = 0; i < matches.length; i++) { + const match = matches[i]; + const rawHeading = (match[1] ?? "").trim(); + const section = cleanSectionLabel(rawHeading); + if (!section) continue; + + const start = (match.index ?? 0) + match[0].length; + const end = matches[i + 1]?.index ?? normalized.length; + const block = normalized.slice(start, end).trim(); + const defaultAnchorHint = extractAnchorHintFromHeading(rawHeading); + + const codeBlocks = [...block.matchAll(/```(?:latex|tex)?\s*([\s\S]*?)```/gi)].map((blockMatch) => blockMatch[1] ?? ""); + const candidates = codeBlocks.length > 0 ? codeBlocks : [block]; + + for (const candidate of candidates) { + const extracted = extractReviewEntriesFromCandidate(candidate, defaultAnchorHint); + for (const entry of extracted) { + parsed.push({ + ...entry, + section, + importance: entry.importance || inferImportance(rawHeading + " " + entry.comment), + anchorHint: entry.anchorHint || defaultAnchorHint, + }); + } + } + } + + for (const entry of parseInlineReviewComments(normalized)) { + parsed.push(entry); + } + + for (const entry of parseNarrativeReviewBlocks(normalized)) { + parsed.push(entry); + } + + const deduped = new Map(); + for (const entry of parsed) { + const normalizedComment = normalizeWhitespace(entry.comment); + if (!normalizedComment) continue; + + const normalizedSection = normalizeSectionName(entry.section); + const normalizedAnchor = normalizeForMatch(entry.anchorHint); + const fallbackSection = normalizedAnchor.includes("title") + ? "title" + : normalizedAnchor.includes("abstract") + ? "abstract" + : normalizedSection; + + const finalizedSection = fallbackSection || "main"; + const finalizedAnchor = normalizeWhitespace(entry.anchorHint); + const key = `${finalizedSection}::${normalizedComment}`; + if (!deduped.has(key)) { + deduped.set(key, { + ...entry, + section: entry.section || finalizedSection, + anchorHint: finalizedAnchor, + comment: normalizedComment, + }); + } + } + + return Array.from(deduped.values()); +} + +function getLineStartOffset(lines: string[], lineIndex: number): number { + let offset = 0; + for (let i = 0; i < lineIndex; i++) { + offset += lines[i].length + 1; + } + return offset; +} + +function findDocLineByMatcher( + doc: ProjectDocSnapshot, + matcher: (line: string) => boolean, +): { quotePosition: number; quoteText: string } | null { + for (let i = 0; i < doc.lines.length; i++) { + if (!matcher(doc.lines[i])) continue; + return { + quotePosition: getLineStartOffset(doc.lines, i), + quoteText: doc.lines[i], + }; + } + return null; +} + +function getRootDoc(docs: ProjectDocSnapshot[], rootDocId: string): ProjectDocSnapshot | null { + if (rootDocId) { + const byId = docs.find((doc) => doc.id === rootDocId); + if (byId) return byId; + } + const mainTex = docs.find((doc) => doc.path.endsWith("main.tex")); + if (mainTex) return mainTex; + const texDoc = docs.find((doc) => doc.path.endsWith(".tex")); + return texDoc ?? docs[0] ?? null; +} + +function locateByAnchorHint( + docs: ProjectDocSnapshot[], + anchorHint: string, +): { doc: ProjectDocSnapshot; quotePosition: number; quoteText: string } | null { + const normalizedHint = normalizeForMatch(anchorHint); + if (!normalizedHint) return null; + + for (const doc of docs) { + for (let i = 0; i < doc.lines.length; i++) { + const normalizedLine = normalizeForMatch(doc.lines[i]); + if (!normalizedLine) continue; + if (normalizedLine.includes(normalizedHint) || normalizedHint.includes(normalizedLine)) { + return { + doc, + quotePosition: getLineStartOffset(doc.lines, i), + quoteText: doc.lines[i], + }; + } + } + } + + return null; +} + +function locateBySection( + docs: ProjectDocSnapshot[], + rootDocId: string, + section: string, +): { doc: ProjectDocSnapshot; quotePosition: number; quoteText: string } | null { + const normalizedSection = normalizeSectionName(section); + const rootDoc = getRootDoc(docs, rootDocId); + + if (!normalizedSection) return null; + + if (normalizedSection === "title" && rootDoc) { + const located = findDocLineByMatcher(rootDoc, (line) => line.includes("\\title{")); + if (located) return { doc: rootDoc, ...located }; + } + + if (normalizedSection === "abstract" && rootDoc) { + const located = findDocLineByMatcher( + rootDoc, + (line) => line.includes("\\begin{abstract}") || line.includes("\\abstract{"), + ); + if (located) return { doc: rootDoc, ...located }; + } + + for (const doc of docs) { + const located = findDocLineByMatcher(doc, (line) => { + const match = line.match(/\\(?:part|chapter|section|subsection|subsubsection|paragraph|subparagraph)\*?\{([^}]*)\}/); + if (!match?.[1]) return false; + const normalizedHeader = normalizeSectionName(match[1]); + return ( + normalizedHeader === normalizedSection || + normalizedHeader.includes(normalizedSection) || + normalizedSection.includes(normalizedHeader) + ); + }); + if (located) return { doc, ...located }; + } + + if (rootDoc) { + const documentStart = findDocLineByMatcher(rootDoc, (line) => line.includes("\\begin{document}")); + if (documentStart) return { doc: rootDoc, ...documentStart }; + } + + return null; +} + +function buildLocalOverleafComment( + projectId: string, + located: LocatedComment, +): OverleafComment { + const docContent = located.doc.lines.join("\n"); + return fromJson( + OverleafCommentSchema, + { + commentId: "", + projectId, + docId: located.doc.id, + docVersion: located.doc.version, + docSha1: generateOverleafDocSHA1(docContent), + quotePosition: located.quotePosition, + quoteText: located.quoteText, + comment: located.comment, + importance: located.importance, + docPath: located.doc.path, + section: located.section, + } as JsonValue, + ); +} + +function summarizeInsertedComment(comment: OverleafComment): string { + const issueLine = + comment.comment + .split("\n") + .map((line) => line.trim()) + .find((line) => line.startsWith("Issue:")) ?? normalizeWhitespace(comment.comment); + + const issue = issueLine.replace(/^Issue:\s*/i, "").trim(); + const location = comment.section || comment.docPath || "paper"; + const importance = comment.importance || "Review"; + + return `[${importance}] ${location}: ${issue}`; +} + +function buildInsertedCommentsPrompt(originalPrompt: string, comments: OverleafComment[]): string { + const summaryLines = comments.slice(0, 8).map((comment) => `- ${summarizeInsertedComment(comment)}`); + if (comments.length > summaryLines.length) { + summaryLines.push(`- Plus ${comments.length - summaryLines.length} more inserted comment(s).`); + } + + return `${originalPrompt} + +PaperDebugger note: The review comments have already been inserted directly into the Overleaf TeX source. Do not say the insert tool is unavailable and do not ask the user to paste comments manually. Summarize the inserted comments below, prioritize the highest-impact fixes, and mention that the comments are already in the paper. + +Inserted comments: +${summaryLines.join("\n")}`; +} + +function getLatestAssistantContent(): string { + const latestConversation = useConversationStore.getState().currentConversation; + const latestAssistantMessage = [...latestConversation.messages] + .reverse() + .find((message) => message.payload?.messageType.case === "assistant" && message.payload.messageType.value.content.trim()); + + if (latestAssistantMessage?.payload?.messageType.case !== "assistant") { + return ""; + } + + return latestAssistantMessage.payload.messageType.value.content; +} + +async function waitForLatestAssistantContent(timeoutMs = 3000, pollMs = 100): Promise { + const deadline = Date.now() + timeoutMs; + let latestContent = getLatestAssistantContent(); + + while (!latestContent && Date.now() < deadline) { + await new Promise((resolve) => setTimeout(resolve, pollMs)); + latestContent = getLatestAssistantContent(); + } + + return latestContent; +} + +async function insertAnchoredCommentsIntoCurrentProject( + projectId: string, + comments: OverleafComment[], + connectSocket: ReturnType["connectSocket"], + disconnectSocket: ReturnType["disconnectSocket"], + addTexComments: ReturnType["addTexComments"], +) { + const csrfToken = document.querySelector('meta[name="ol-csrfToken"]')?.getAttribute("content") || ""; + if (!csrfToken) { + throw new ReviewInsertError("Overleaf CSRF token not found."); + } + + const { session, gclb } = await getCookies(window.location.hostname); + if (!session) { + throw new ReviewInsertError("Overleaf session cookie not found."); + } + + await connectSocket( + projectId, + { + cookieOverleafSession2: session, + cookieGCLB: gclb, + }, + csrfToken, + ); + + try { + await addTexComments( + comments.map((comment) => ({ + ...comment, + comment: formatTexSourceComment(comment.importance, comment.section, comment.comment), + })), + ); + } finally { + disconnectSocket(); + } +} + +export function useReviewAndInsert() { + const adapter = useAdapter(); + const { sync } = useSync(); + const currentConversation = useConversationStore((s) => s.currentConversation); + const { connectSocket, disconnectSocket, createSnapshot, addTexComments } = useSocketStore(); + + const insertLocally = useCallback( + async (originalPrompt: string, parsedComments: ParsedReviewComment[]): Promise => { + if (adapter.platform !== "overleaf") { + throw new ReviewInsertError("Direct TeX comment insertion is only available in Overleaf."); + } + + const projectId = adapter.getDocumentId?.() || getProjectId(); + if (!projectId) { + throw new ReviewInsertError("Overleaf project id not found."); + } + + if (parsedComments.length === 0) { + throw new ReviewInsertError("I could not parse any structured review comments from the assistant response."); + } + + try { + const csrfToken = document.querySelector('meta[name="ol-csrfToken"]')?.getAttribute("content") || ""; + if (!csrfToken) { + throw new ReviewInsertError("Overleaf CSRF token not found."); + } + + const { session, gclb } = await getCookies(window.location.hostname); + if (!session) { + throw new ReviewInsertError("Overleaf session cookie not found."); + } + + await connectSocket( + projectId, + { + cookieOverleafSession2: session, + cookieGCLB: gclb, + }, + csrfToken, + ); + + const snapshot = await createSnapshot(); + const rootDocId = useSocketStore.getState().rootDocId; + const docs: ProjectDocSnapshot[] = Array.from(snapshot.entries()).map(([id, doc]) => ({ + id, + path: doc.path, + version: doc.version, + lines: doc.lines, + })); + + const locatedComments: LocatedComment[] = []; + const skippedComments: ParsedReviewComment[] = []; + + for (const parsedComment of parsedComments) { + const locatedByAnchor = parsedComment.anchorHint ? locateByAnchorHint(docs, parsedComment.anchorHint) : null; + const locatedBySection = locateBySection(docs, rootDocId, parsedComment.section); + const located = locatedByAnchor ?? locatedBySection; + + if (!located) { + skippedComments.push(parsedComment); + continue; + } + + locatedComments.push({ + doc: located.doc, + quotePosition: located.quotePosition, + quoteText: located.quoteText, + section: parsedComment.section, + importance: parsedComment.importance, + comment: parsedComment.comment, + }); + } + + if (locatedComments.length === 0) { + throw new ReviewInsertError( + "Review comments were generated, but I could not match them to sections in the current TeX source.", + ); + } + + const overleafComments = locatedComments.map((located) => buildLocalOverleafComment(projectId, located)); + + await addTexComments( + overleafComments.map((comment) => ({ + ...comment, + comment: formatTexSourceComment(comment.importance, comment.section, comment.comment), + })), + ); + + const uniqueSections = Array.from(new Set(overleafComments.map((comment) => comment.section).filter(Boolean))); + const detail = + uniqueSections.length > 0 + ? `${uniqueSections.slice(0, 3).join(", ")}${uniqueSections.length > 3 ? ` +${uniqueSections.length - 3} more` : ""}` + : "the current paper"; + + successToast( + skippedComments.length > 0 + ? `Inserted ${overleafComments.length} TeX review comment(s) into ${detail}. ${skippedComments.length} item(s) could not be matched automatically.` + : `Inserted ${overleafComments.length} TeX review comment(s) into ${detail}.`, + "Review Comments Inserted", + ); + + return { + comments: overleafComments, + generatedCount: parsedComments.length, + insertedCount: overleafComments.length, + summaryPrompt: buildInsertedCommentsPrompt(originalPrompt, overleafComments), + }; + } finally { + disconnectSocket(); + } + }, + [adapter, connectSocket, createSnapshot, addTexComments, disconnectSocket], + ); + + const reviewAndInsert = useCallback( + async (originalPrompt: string): Promise => { + if (adapter.platform !== "overleaf") { + throw new ReviewInsertError("Direct TeX comment insertion is only available in Overleaf."); + } + + const projectId = adapter.getDocumentId?.() || getProjectId(); + if (!projectId) { + throw new ReviewInsertError("Overleaf project id not found."); + } + + try { + const fetchAnchoredComments = async (): Promise => { + const reviewResponse = await runProjectPaperScoreComment({ + projectId, + conversationId: currentConversation.id, + }); + + const generatedEntries = reviewResponse.comments.flatMap((result) => result.results); + if (generatedEntries.length === 0) { + throw new ReviewInsertError("No review comments were generated for this paper."); + } + + const anchoredComments: OverleafComment[] = []; + for (const entry of generatedEntries) { + const overleafResponse = await runProjectOverleafComment({ + projectId, + section: entry.section, + anchorText: entry.anchorText, + comment: entry.weakness, + importance: entry.importance, + }); + anchoredComments.push(...overleafResponse.comments); + } + + if (anchoredComments.length === 0) { + throw new ReviewInsertError( + "Review comments were generated, but none could be anchored into the current TeX source.", + ); + } + + return anchoredComments; + }; + + let anchoredComments: OverleafComment[]; + + try { + anchoredComments = await fetchAnchoredComments(); + } catch (error) { + if (shouldUseAssistantTextFallback(error)) { + throw error; + } + + const syncResult = await sync(); + if (!syncResult.success) { + throw new ReviewInsertError(syncResult.error?.message ?? "Failed to sync the Overleaf project."); + } + + anchoredComments = await fetchAnchoredComments(); + } + + await insertAnchoredCommentsIntoCurrentProject( + projectId, + anchoredComments, + connectSocket, + disconnectSocket, + addTexComments, + ); + + const uniqueSections = Array.from(new Set(anchoredComments.map((comment) => comment.section).filter(Boolean))); + const detail = + uniqueSections.length > 0 + ? `${uniqueSections.slice(0, 3).join(", ")}${uniqueSections.length > 3 ? ` +${uniqueSections.length - 3} more` : ""}` + : "the current paper"; + + successToast(`Inserted ${anchoredComments.length} TeX review comment(s) into ${detail}.`, "Review Comments Inserted"); + + return { + comments: anchoredComments, + generatedCount: anchoredComments.length, + insertedCount: anchoredComments.length, + summaryPrompt: buildInsertedCommentsPrompt(originalPrompt, anchoredComments), + }; + } catch (error) { + const message = getErrorMessage(error); + if (shouldUseAssistantTextFallback(error)) { + throw new ReviewInsertError(message, true); + } + throw new ReviewInsertError(message); + } + }, + [adapter, currentConversation.id, addTexComments, connectSocket, disconnectSocket, sync], + ); + + const insertCommentsFromLatestAssistantResponse = useCallback( + async (originalPrompt: string): Promise => { + const latestAssistantContent = await waitForLatestAssistantContent(); + if (!latestAssistantContent) { + throw new ReviewInsertError("The review response finished, but no assistant text was available to convert into TeX comments."); + } + + const parsedComments = parseAssistantReviewComments(latestAssistantContent); + if (parsedComments.length === 0) { + logWarn("Could not parse review comments from assistant response", latestAssistantContent); + } + return insertLocally(originalPrompt, parsedComments); + }, + [insertLocally], + ); + + const insertCommentsFromLatestAssistantResponseWithToast = useCallback( + async (originalPrompt: string): Promise => { + try { + return await insertCommentsFromLatestAssistantResponse(originalPrompt); + } catch (error) { + const message = getErrorMessage(error); + errorToast(message, "Review & Insert Failed"); + throw error; + } + }, + [insertCommentsFromLatestAssistantResponse], + ); + + return { + reviewAndInsert, + insertCommentsFromLatestAssistantResponse: insertCommentsFromLatestAssistantResponseWithToast, + }; +} diff --git a/webapp/_webapp/src/intermediate.ts b/webapp/_webapp/src/intermediate.ts index 0873a27d..b8a98602 100644 --- a/webapp/_webapp/src/intermediate.ts +++ b/webapp/_webapp/src/intermediate.ts @@ -36,11 +36,21 @@ function getBrowserAPI(): typeof chrome | undefined { try { // @ts-expect-error: browser may not be defined in all environments const candidateBrowser = typeof browser !== "undefined" ? browser : undefined; - if (candidateBrowser && typeof candidateBrowser.runtime?.sendMessage === "function") { + if ( + candidateBrowser && + typeof candidateBrowser.runtime?.sendMessage === "function" && + typeof candidateBrowser.runtime?.id === "string" && + candidateBrowser.runtime.id.length > 0 + ) { return candidateBrowser; } const candidateChrome = typeof chrome !== "undefined" ? chrome : undefined; - if (candidateChrome && typeof candidateChrome.runtime?.sendMessage === "function") { + if ( + candidateChrome && + typeof candidateChrome.runtime?.sendMessage === "function" && + typeof candidateChrome.runtime?.id === "string" && + candidateChrome.runtime.id.length > 0 + ) { return candidateChrome; } } catch { diff --git a/webapp/_webapp/src/libs/helpers.ts b/webapp/_webapp/src/libs/helpers.ts index 5ce47185..905c48dc 100644 --- a/webapp/_webapp/src/libs/helpers.ts +++ b/webapp/_webapp/src/libs/helpers.ts @@ -172,8 +172,14 @@ export function generateSHA1Hash(inputString: string): string { return result.map((b) => b.toString(16).padStart(2, "0")).join(""); } +export function generateOverleafDocSHA1(content: string): string { + const runeCount = Array.from(content).length; + return generateSHA1Hash(`blob ${runeCount}\x00${content}`); +} + // --- Overleaf Comments Clicked Storage --- const OVERLEAF_COMMENTS_CLICKED_PREFIX = "pd.overleaf_comments_clicked."; +const OVERLEAF_TEX_COMMENTS_CLICKED_PREFIX = "pd.overleaf_tex_comments_clicked."; const MAX_CLICKED_COMMENTS = 200; export function getClickedOverleafComments(projectId: string): string[] { @@ -210,6 +216,38 @@ export function hasClickedOverleafComment(projectId: string, messageId: string): return arr.includes(messageId); } +export function getClickedOverleafTexComments(projectId: string): string[] { + if (!projectId) return []; + const key = OVERLEAF_TEX_COMMENTS_CLICKED_PREFIX + projectId; + try { + const raw = storage.getItem(key); + if (!raw) return []; + const arr = JSON.parse(raw); + if (Array.isArray(arr)) return arr; + return []; + } catch { + return []; + } +} + +export function addClickedOverleafTexComment(projectId: string, messageId: string) { + if (!projectId || !messageId) return; + const key = OVERLEAF_TEX_COMMENTS_CLICKED_PREFIX + projectId; + let arr = getClickedOverleafTexComments(projectId); + arr = arr.filter((id) => id !== messageId); + arr.push(messageId); + if (arr.length > MAX_CLICKED_COMMENTS) { + arr = arr.slice(arr.length - MAX_CLICKED_COMMENTS); + } + storage.setItem(key, JSON.stringify(arr)); +} + +export function hasClickedOverleafTexComment(projectId: string, messageId: string): boolean { + if (!projectId || !messageId) return false; + const arr = getClickedOverleafTexComments(projectId); + return arr.includes(messageId); +} + // Classic debounce, suitable for event callbacks export function debounce(fn: (...args: unknown[]) => void, wait: number) { let timer: ReturnType | null = null; diff --git a/webapp/_webapp/src/libs/overleaf-socket.ts b/webapp/_webapp/src/libs/overleaf-socket.ts index 01d4615e..c4141f7c 100644 --- a/webapp/_webapp/src/libs/overleaf-socket.ts +++ b/webapp/_webapp/src/libs/overleaf-socket.ts @@ -127,8 +127,10 @@ export interface RequestResponse { // can be any type of request request: { name: string; args: unknown[] }; // can be any type of response - response: object | null; - callback?: (response: object) => void; + response: unknown | null; + callback?: (response: unknown) => void; + reject?: (error: Error) => void; + timeoutId?: ReturnType; } export interface OverleafVersionedDoc { diff --git a/webapp/_webapp/src/query/api.ts b/webapp/_webapp/src/query/api.ts index 4098a018..412ed089 100644 --- a/webapp/_webapp/src/query/api.ts +++ b/webapp/_webapp/src/query/api.ts @@ -27,6 +27,12 @@ import { import { GetProjectRequest, GetProjectResponseSchema, + RunProjectOverleafCommentRequest, + RunProjectOverleafCommentResponseSchema, + RunProjectPaperScoreCommentRequest, + RunProjectPaperScoreCommentResponseSchema, + RunProjectPaperScoreRequest, + RunProjectPaperScoreResponseSchema, UpsertProjectRequest, UpsertProjectResponseSchema, GetProjectInstructionsRequest, @@ -159,6 +165,27 @@ export const upsertProject = async (data: PlainMessage) => return fromJson(UpsertProjectResponseSchema, response); }; +export const runProjectPaperScore = async (data: PlainMessage) => { + const response = await apiclient.post(`/projects/${data.projectId}/paper-score`, data, { + ignoreErrorToast: true, + }); + return fromJson(RunProjectPaperScoreResponseSchema, response); +}; + +export const runProjectPaperScoreComment = async (data: PlainMessage) => { + const response = await apiclient.post(`/projects/${data.projectId}/paper-score-comment`, data, { + ignoreErrorToast: true, + }); + return fromJson(RunProjectPaperScoreCommentResponseSchema, response); +}; + +export const runProjectOverleafComment = async (data: PlainMessage) => { + const response = await apiclient.post(`/projects/${data.projectId}/overleaf-comment`, data, { + ignoreErrorToast: true, + }); + return fromJson(RunProjectOverleafCommentResponseSchema, response); +}; + export const listPrompts = async () => { if (!apiclient.hasToken()) { return fromJson(ListPromptsResponseSchema, { prompts: [] }); diff --git a/webapp/_webapp/src/query/utils.ts b/webapp/_webapp/src/query/utils.ts index 8d77f676..5e7f1fe7 100644 --- a/webapp/_webapp/src/query/utils.ts +++ b/webapp/_webapp/src/query/utils.ts @@ -30,7 +30,7 @@ export function getQueryParamsAsString< export const processStream = async ( stream: ReadableStream, schema: DescMessage, - onMessage: (chunk: T) => void, + onMessage: (chunk: T) => void | Promise, ) => { const { slowStreamingMode } = useDevtoolStore.getState(); const reader = stream.getReader(); @@ -51,7 +51,7 @@ export const processStream = async ( try { const parsedValue = JSON.parse(message); const messageData = parsedValue.result || parsedValue; - onMessage(fromJson(schema, messageData) as T); + await onMessage(fromJson(schema, messageData) as T); } catch (err) { logError("Error parsing message from stream", err, message); } diff --git a/webapp/_webapp/src/stores/conversation/conversation-ui-store.ts b/webapp/_webapp/src/stores/conversation/conversation-ui-store.ts index 8728c1fd..0bd716a6 100644 --- a/webapp/_webapp/src/stores/conversation/conversation-ui-store.ts +++ b/webapp/_webapp/src/stores/conversation/conversation-ui-store.ts @@ -117,7 +117,7 @@ export const useConversationUiStore = create()( heightCollapseRequired: false, setHeightCollapseRequired: (heightCollapseRequired: boolean) => set({ heightCollapseRequired }), - lastUsedModelSlug: "openai/gpt-4.1", + lastUsedModelSlug: "openai/gpt-5.4", setLastUsedModelSlug: (lastUsedModelSlug: string) => set({ lastUsedModelSlug }), resetPosition: () => { diff --git a/webapp/_webapp/src/stores/socket-store.ts b/webapp/_webapp/src/stores/socket-store.ts index 844c2eca..2fb119e8 100644 --- a/webapp/_webapp/src/stores/socket-store.ts +++ b/webapp/_webapp/src/stores/socket-store.ts @@ -11,12 +11,47 @@ import { wsConnect, } from "../libs/overleaf-socket"; import { generateId } from "../libs/helpers"; +import { generateOverleafDocSHA1 } from "../libs/helpers"; import { upsertProject } from "../query/api"; -import { UpsertProjectRequest, ProjectDoc } from "../pkg/gen/apiclient/project/v1/project_pb"; +import { UpsertProjectRequest, ProjectDoc, OverleafComment } from "../pkg/gen/apiclient/project/v1/project_pb"; import { PlainMessage } from "../query/types"; import { logError } from "../libs/logger"; import googleAnalytics from "../libs/google-analytics"; +const SOCKET_REQUEST_TIMEOUT_MS = 60000; + +function clampPosition(position: number, max: number): number { + return Math.max(0, Math.min(position, max)); +} + +function findClosestAnchorIndex(content: string, anchor: string, requestedPosition: number): number { + if (!anchor) { + return requestedPosition; + } + + if (content.slice(requestedPosition, requestedPosition + anchor.length) === anchor) { + return requestedPosition; + } + + let bestIndex = -1; + let bestDistance = Number.POSITIVE_INFINITY; + let searchIndex = content.indexOf(anchor); + + while (searchIndex >= 0) { + const distance = Math.abs(searchIndex - requestedPosition); + if (distance < bestDistance) { + bestDistance = distance; + bestIndex = searchIndex; + if (distance === 0) { + break; + } + } + searchIndex = content.indexOf(anchor, searchIndex + 1); + } + + return bestIndex; +} + // Types export interface SocketStore { // State @@ -55,15 +90,17 @@ export interface SocketStore { comment: string, csrfToken: string, ) => Promise; + addTexComment: (comment: OverleafComment) => Promise; + addTexComments: (comments: OverleafComment[]) => Promise; // Internal API - Document Management _updateDocById: (docId: string, options: { newPath?: string; newVersion?: number; newLines?: string[] }) => void; _overleafJoinDoc: (docId: string) => Promise; _overleafLeaveDoc: (docId: string) => Promise; - _applyOtUpdate: (docId: string, hash: string, op: unknown, version: number) => Promise; + _applyOtUpdate: (docId: string, hash: string, op: unknown, version: number) => Promise; // Internal API - WebSocket Communication - _sendRequest: (message: OverleafSocketRequest) => Promise; + _sendRequest: (message: OverleafSocketRequest) => Promise; _overleafUpdatePosition: (docId: string | null, position: number | undefined) => void; _overleafMessageHandler: (event: MessageEvent) => void; _overleafJsonMessageHandler: (data: any) => void; // eslint-disable-line @typescript-eslint/no-explicit-any @@ -166,10 +203,19 @@ export const useSocketStore = create((set, get) => ({ // Configure WebSocket event handlers ws.onclose = () => { + const { socketRequestResponse } = get(); + for (const [seq, requestResponse] of socketRequestResponse.entries()) { + if (requestResponse.timeoutId) { + clearTimeout(requestResponse.timeoutId); + } + requestResponse.reject?.(new Error(`Socket closed before response for ${requestResponse.request.name} (${seq})`)); + } + socketRequestResponse.clear(); set({ docs: new Map(), socketRef: null, socketJoined: false, + socketRequestResponse: new Map(), }); }; @@ -203,7 +249,14 @@ export const useSocketStore = create((set, get) => ({ * Disconnect from Overleaf WebSocket and clean up state */ disconnectSocket: () => { - const { socketRef } = get(); + const { socketRef, socketRequestResponse } = get(); + for (const requestResponse of socketRequestResponse.values()) { + if (requestResponse.timeoutId) { + clearTimeout(requestResponse.timeoutId); + } + requestResponse.reject?.(new Error(`Socket disconnected before response for ${requestResponse.request.name}`)); + } + socketRequestResponse.clear(); if (socketRef) socketRef.close(); set({ @@ -276,6 +329,85 @@ export const useSocketStore = create((set, get) => ({ return threadId; }, + addTexComment: async (comment) => { + await get().addTexComments([comment]); + }, + + addTexComments: async (comments) => { + if (comments.length === 0) return; + + const { _overleafJoinDoc, _overleafLeaveDoc, _applyOtUpdate, docs, _updateDocById } = get(); + const commentsByDoc = new Map(); + + for (const comment of comments) { + if (!comment.docId) { + throw new Error("Document id is missing for TeX comment insertion."); + } + const list = commentsByDoc.get(comment.docId) ?? []; + list.push(comment); + commentsByDoc.set(comment.docId, list); + } + + for (const [docId, docComments] of commentsByDoc.entries()) { + await _overleafJoinDoc(docId); + + try { + const liveDoc = docs.get(docId); + if (!liveDoc) { + throw new Error("Document content is not available."); + } + + const originalContent = liveDoc.lines.join("\n"); + let workingContent = originalContent; + const sortedComments = [...docComments].sort((left, right) => right.quotePosition - left.quotePosition); + const ops: Array<{ p: number; i: string }> = []; + + for (const comment of sortedComments) { + const currentContent = workingContent; + const anchor = comment.quoteText || ""; + const requestedPosition = clampPosition(comment.quotePosition, currentContent.length); + + let anchorStart = requestedPosition; + if (anchor) { + const closestAnchorIndex = findClosestAnchorIndex(currentContent, anchor, requestedPosition); + if (closestAnchorIndex >= 0) { + anchorStart = closestAnchorIndex; + } + } + + let insertPosition = anchor ? anchorStart + anchor.length : requestedPosition; + const nextNewlineIndex = currentContent.indexOf("\n", insertPosition); + if (nextNewlineIndex >= 0) { + insertPosition = nextNewlineIndex + 1; + } else { + insertPosition = currentContent.length; + } + + const insertText = comment.comment; + workingContent = currentContent.slice(0, insertPosition) + insertText + currentContent.slice(insertPosition); + ops.push({ p: insertPosition, i: insertText }); + } + + const currentHash = generateOverleafDocSHA1(originalContent); + await _applyOtUpdate(docId, currentHash, ops, liveDoc.version); + + _updateDocById(docId, { + newVersion: liveDoc.version + 1, + newLines: workingContent.split("\n"), + }); + } finally { + const { socketRef } = get(); + if (socketRef?.readyState === WebSocket.OPEN) { + try { + await _overleafLeaveDoc(docId); + } catch (error) { + logError(`Failed to leave doc ${docId} after TeX comment insertion:`, error); + } + } + } + } + }, + // Internal API - Document Management /** * Update a document by ID with new properties @@ -341,14 +473,18 @@ export const useSocketStore = create((set, get) => ({ set({ socketMessageSeq: socketMessageSeq + 1 }); // Store callback to resolve promise when response arrives + const timeoutId = setTimeout(() => { + socketRequestResponse.delete(`${socketMessageSeq}`); + reject(new Error(`Response timeout for ${message.name}`)); + }, SOCKET_REQUEST_TIMEOUT_MS); + socketRequestResponse.set(`${socketMessageSeq}`, { request: message, response: null, - callback: (response: object) => resolve(response), + callback: (response: unknown) => resolve(response), + reject, + timeoutId, }); - - // Set timeout to prevent hanging promises - setTimeout(() => reject(new Error("Response timeout")), 5000); }); }, @@ -458,41 +594,47 @@ export const useSocketStore = create((set, get) => ({ const responseBodyText = data.slice(responseHeaderText.length + 1); const contentData = JSON.parse(responseBodyText); - const { _responseReceivedWithoutData, _updateDocById } = get(); - _responseReceivedWithoutData(parseInt(seq), contentData); + const { _updateDocById, socketRequestResponse } = get(); + const existingRequestResponse = socketRequestResponse.get(seq); + if (!existingRequestResponse) { + return; + } - // Handle joinDoc response specifically - const { socketRequestResponse } = get(); - if (socketRequestResponse.get(seq)?.request.name === "joinDoc") { - const docId = (socketRequestResponse.get(seq)?.request.args[0] as string) || ""; + if (existingRequestResponse.request.name === "joinDoc") { + const docId = (existingRequestResponse.request.args[0] as string) || ""; const nullArg = contentData[0]; // Should be null per Overleaf API. Check `overleaf/services/real-time/app/js/WebsocketController.js:354` if (nullArg !== null) { logError("joinDoc response[0] is not null:", nullArg); - return; + } else { + const escapedLines = contentData[1] || ["CANT_FIND_DOC_CONTENT"]; + const version = contentData[2] || 0; + // const ops = contentData[3]; + // const comments = contentData[4]; + + // Decode the lines to UTF-8 + const decodedLines = escapedLines.map((line: string) => { + try { + const bytes = new Uint8Array([...line].map((c) => c.charCodeAt(0))); + return new TextDecoder("utf-8").decode(bytes); + } catch (e) { + logError("Failed to decode line:", e); + return line; + } + }); + + _updateDocById(docId, { + newVersion: version, + newLines: decodedLines, + }); } + } - const escapedLines = contentData[1] || ["CANT_FIND_DOC_CONTENT"]; - const version = contentData[2] || 0; - // const ops = contentData[3]; - // const comments = contentData[4]; - - // Decode the lines to UTF-8 - const decodedLines = escapedLines.map((line: string) => { - try { - const bytes = new Uint8Array([...line].map((c) => c.charCodeAt(0))); - return new TextDecoder("utf-8").decode(bytes); - } catch (e) { - logError("Failed to decode line:", e); - return line; - } - }); - - _updateDocById(docId, { - newVersion: version, - newLines: decodedLines, - }); + if (existingRequestResponse.timeoutId) { + clearTimeout(existingRequestResponse.timeoutId); } + socketRequestResponse.delete(seq); + existingRequestResponse.callback?.(contentData); }, /** @@ -501,21 +643,15 @@ export const useSocketStore = create((set, get) => ({ _responseReceivedWithoutData: (seq: number, data: OverleafSocketResponse) => { const { socketRequestResponse } = get(); const existingRequestResponse = socketRequestResponse.get(seq.toString()); + if (!existingRequestResponse) { + return; + } - // Update request/response map - socketRequestResponse.set(seq.toString(), { - request: existingRequestResponse?.request || { - name: "unknown", - args: [], - }, - response: data || null, - callback: existingRequestResponse?.callback, - }); - - // Call the callback to resolve waiting promise - if (existingRequestResponse?.callback) { - existingRequestResponse.callback(data); + if (existingRequestResponse.timeoutId) { + clearTimeout(existingRequestResponse.timeoutId); } + socketRequestResponse.delete(seq.toString()); + existingRequestResponse.callback?.(data); }, })); diff --git a/webapp/_webapp/src/views/chat/actions/actions.ts b/webapp/_webapp/src/views/chat/actions/actions.ts index 791e9abc..e3093af3 100644 --- a/webapp/_webapp/src/views/chat/actions/actions.ts +++ b/webapp/_webapp/src/views/chat/actions/actions.ts @@ -11,9 +11,10 @@ export type Action = { type useActionsProps = { enabled?: boolean; filter?: string; + onReviewAndInsert?: () => void; }; -export const useActions = ({ enabled, filter }: useActionsProps) => { +export const useActions = ({ enabled, filter, onReviewAndInsert }: useActionsProps) => { const { setPrompt } = useConversationUiStore(); const actions: Action[] = useMemo(() => { const items = [ @@ -33,6 +34,19 @@ export const useActions = ({ enabled, filter }: useActionsProps) => { ShowHistory(); }, }, + { + name: ":review", + description: "Review paper and insert TeX comments into Overleaf", + action: () => { + if (onReviewAndInsert) { + onReviewAndInsert(); + return; + } + setPrompt( + "Review this paper and add direct comments into the Overleaf TeX source. Use the paper_score and paper_score_comment tools, then insert the generated comments into the paper.", + ); + }, + }, ]; return items.filter( @@ -42,7 +56,7 @@ export const useActions = ({ enabled, filter }: useActionsProps) => { item.name.toLowerCase().includes(filter.toLowerCase()) || item.description.toLowerCase().includes(filter.toLowerCase())), ); - }, [enabled, filter, setPrompt]); + }, [enabled, filter, onReviewAndInsert, setPrompt]); return actions; }; diff --git a/webapp/_webapp/src/views/chat/footer/index.tsx b/webapp/_webapp/src/views/chat/footer/index.tsx index 7fb5fd36..2b50334e 100644 --- a/webapp/_webapp/src/views/chat/footer/index.tsx +++ b/webapp/_webapp/src/views/chat/footer/index.tsx @@ -16,6 +16,11 @@ import { useActions } from "../actions/actions"; import { ChatActions } from "./toolbar/chat-actions"; import { ModelSelection } from "./toolbar/model-selection"; import { useSettingStore } from "../../../stores/setting-store"; +import { + shouldAutoReviewAndInsert, + shouldUseAssistantTextFallback, + useReviewAndInsert, +} from "../../../hooks/useReviewAndInsert"; // Add animation keyframes const blinkAnimation = `@keyframes blink { @@ -45,14 +50,8 @@ export function PromptInput() { const searchPrompts = usePromptLibraryStore((s) => s.searchPrompts); const [showModelSelection, setShowModelSelection] = useState(false); - const prompts = useMemo( - () => (!prompt.startsWith("/") ? [] : searchPrompts(prompt.slice(1))), - [prompt, searchPrompts], - ); - const actions = useActions({ - enabled: prompt.startsWith(":"), - filter: prompt.startsWith(":") ? prompt.slice(1) : undefined, - }); + const reviewAndInsertPrompt = + 'Review this paper and add direct comments into the Overleaf TeX source. Prefer using the paper_score and paper_score_comment tools. If tools are unavailable, format every issue as `Section name:` followed by a ```latex block that contains exactly one `% REVIEW: ...` line so PaperDebugger can insert it into the paper automatically.'; const user = useAuthStore((s) => s.user); const isStreaming = useConversationStore((s) => s.isStreaming); @@ -64,39 +63,86 @@ export function PromptInput() { const setSurroundingText = useSelectionStore((s) => s.setSurroundingText); const { sendMessageStream } = useSendMessageStream(); + const { reviewAndInsert, insertCommentsFromLatestAssistantResponse } = useReviewAndInsert(); const minimalistMode = useSettingStore((s) => s.minimalistMode); + const submitPrompt = useCallback( + async (message: string, selectedTextOverride?: string) => { + const trimmedMessage = message.trim(); + if (!trimmedMessage) { + return; + } + + googleAnalytics.fireEvent(user?.id, "Send Chat Message", { + promptLength: trimmedMessage.length, + selectedTextLength: selectedTextOverride?.length, + userId: user?.id, + }); + setPrompt(""); + if (selectedTextOverride) { + setSelectedText(null); + setSurroundingText(null); + } else { + clearSelection(); + } + setIsStreaming(true); + + let messageForChat = trimmedMessage; + const shouldInsertDirectly = shouldAutoReviewAndInsert(trimmedMessage); + let shouldTryAssistantFallback = false; + + try { + if (shouldInsertDirectly) { + try { + const insertResult = await reviewAndInsert(trimmedMessage); + messageForChat = insertResult.summaryPrompt; + } catch (error) { + shouldTryAssistantFallback = shouldUseAssistantTextFallback(error); + messageForChat = trimmedMessage; + } + } + + await sendMessageStream(messageForChat, selectedTextOverride ?? ""); + + if (shouldTryAssistantFallback) { + await insertCommentsFromLatestAssistantResponse(trimmedMessage); + } + } finally { + setIsStreaming(false); + } + }, + [ + clearSelection, + insertCommentsFromLatestAssistantResponse, + reviewAndInsert, + sendMessageStream, + setIsStreaming, + setPrompt, + setSelectedText, + setSurroundingText, + user?.id, + ], + ); + + const prompts = useMemo( + () => (!prompt.startsWith("/") ? [] : searchPrompts(prompt.slice(1))), + [prompt, searchPrompts], + ); + const actions = useActions({ + enabled: prompt.startsWith(":"), + filter: prompt.startsWith(":") ? prompt.slice(1) : undefined, + onReviewAndInsert: () => { + void submitPrompt(reviewAndInsertPrompt, ""); + }, + }); + const handleModelSelect = useCallback(() => { setShowModelSelection(false); }, []); const submit = useCallback(async () => { - googleAnalytics.fireEvent(user?.id, "Send Chat Message", { - promptLength: prompt.length, - selectedTextLength: selectedText?.length, - userId: user?.id, - }); - setPrompt(""); - if (selectedText) { - setSelectedText(null); - setSurroundingText(null); - } else { - clearSelection(); - } - setIsStreaming(true); - await sendMessageStream(prompt, selectedText ?? ""); - setIsStreaming(false); - }, [ - sendMessageStream, - prompt, - selectedText, - user?.id, - setIsStreaming, - setPrompt, - clearSelection, - setSelectedText, - setSurroundingText, - ]); + await submitPrompt(prompt, selectedText ?? ""); + }, [prompt, selectedText, submitPrompt]); const handleKeyDown = useCallback( async (e: React.KeyboardEvent) => { // Check if IME composition is in progress to avoid submitting during Chinese input @@ -128,7 +174,13 @@ export function PromptInput() { )}
- setShowModelSelection(true)} /> + setShowModelSelection(true)} + onReviewAndInsert={() => { + void submitPrompt(reviewAndInsertPrompt, ""); + }} + reviewAndInsertDisabled={isStreaming} + />
{selectedText && } diff --git a/webapp/_webapp/src/views/chat/footer/toolbar/chat-actions.tsx b/webapp/_webapp/src/views/chat/footer/toolbar/chat-actions.tsx index e68a9ce7..2d69e94a 100644 --- a/webapp/_webapp/src/views/chat/footer/toolbar/chat-actions.tsx +++ b/webapp/_webapp/src/views/chat/footer/toolbar/chat-actions.tsx @@ -4,6 +4,8 @@ import { ChatButton } from "../../header/chat-button"; type ChatActionsProps = { onShowModelSelection: () => void; + onReviewAndInsert: () => void; + reviewAndInsertDisabled?: boolean; }; // Map provider names to their respective icons @@ -24,7 +26,7 @@ const getProviderIcon = (provider: string | undefined): string => { } }; -export function ChatActions({ onShowModelSelection }: ChatActionsProps) { +export function ChatActions({ onShowModelSelection, onReviewAndInsert, reviewAndInsertDisabled }: ChatActionsProps) { const { inputRef, setPrompt, prompt } = useConversationUiStore(); const { currentModel } = useLanguageModels(); @@ -59,6 +61,14 @@ export function ChatActions({ onShowModelSelection }: ChatActionsProps) { } }} /> + e.stopPropagation()} + icon="tabler:file-pencil" + text="Review & Insert" + alwaysShowText + disabled={reviewAndInsertDisabled} + onClick={onReviewAndInsert} + />
{ }; const handleAddStreamingAssistant = () => { const newMessage = createAssistantMessage(randomUUID(), "Assistant Response Preparing " + randomText(), { - modelSlug: "gpt-4.1", + modelSlug: "gpt-5.4", status: "streaming", }); updateStreamingMessage((prev) => ({ ...prev, parts: [...prev.parts, newMessage] })); diff --git a/webapp/_webapp/src/views/login/advanced-settings.tsx b/webapp/_webapp/src/views/login/advanced-settings.tsx index eb3b464f..f3875cc0 100644 --- a/webapp/_webapp/src/views/login/advanced-settings.tsx +++ b/webapp/_webapp/src/views/login/advanced-settings.tsx @@ -1,13 +1,25 @@ -import { useEffect, useState } from "react"; +import { useEffect, useRef, useState } from "react"; import { SettingItemInput } from "../settings/setting-item-input"; import apiclient, { apiclientV2, getEndpointFromLocalStorage, resetApiClientEndpoint } from "../../libs/apiclient"; +import { useAuthStore } from "../../stores/auth-store"; export default function AdvancedSettings() { const [endpoint, setEndpoint] = useState(() => getEndpointFromLocalStorage()); + const [infoMessage, setInfoMessage] = useState(""); + const { logout } = useAuthStore(); + const previousEndpointRef = useRef(endpoint); useEffect(() => { apiclient.updateBaseURL(endpoint, "v1"); apiclientV2.updateBaseURL(endpoint, "v2"); + + if (previousEndpointRef.current !== endpoint) { + previousEndpointRef.current = endpoint; + setInfoMessage("Endpoint changed. Please sign in again for this backend."); + logout().catch(() => { + // Best effort: local auth state is still cleared even if backend logout fails. + }); + } }, [endpoint]); return ( @@ -22,8 +34,14 @@ export default function AdvancedSettings() { onReset={() => { resetApiClientEndpoint(); setEndpoint(getEndpointFromLocalStorage()); + setInfoMessage(""); }} /> +

+ Changing the endpoint invalidates the current PaperDebugger login session. You need to log in again on the + selected backend. +

+ {infoMessage ?

{infoMessage}

: null}
); }