From 315e5725c25df2ccf583961a42a947003eea5d39 Mon Sep 17 00:00:00 2001
From: euvre <93761161+euvre@users.noreply.github.com>
Date: Tue, 14 Jul 2026 17:04:47 +0800
Subject: [PATCH] Fix: Search mind map not displaying (#16866)
---
internal/handler/mindmap.go | 45 +++++++++++++++++++-----------
internal/handler/searchbot_test.go | 41 +++++++++++++++++++++++++++
2 files changed, 70 insertions(+), 16 deletions(-)
diff --git a/internal/handler/mindmap.go b/internal/handler/mindmap.go
index 92ee3edfe4..439bfc97e2 100644
--- a/internal/handler/mindmap.go
+++ b/internal/handler/mindmap.go
@@ -17,10 +17,12 @@
package handler
import (
+ "context"
"encoding/json"
"fmt"
"regexp"
"strings"
+ "time"
"ragflow/internal/common"
"ragflow/internal/entity"
@@ -61,20 +63,33 @@ func runMindMap(config mindMapRunConfig) (mindMapNode, error) {
return mindMapNode{ID: "root", Children: []mindMapNode{}}, nil
}
modelID, _ := config.SearchConfig["chat_id"].(string)
- if modelID == "" && config.TenantSvc != nil {
- defaultModel, err := config.TenantSvc.GetDefaultModelName(modelTenantID, entity.ModelTypeChat)
- if err == nil {
- modelID = defaultModel
+ messages := []modelModule.Message{{Role: "system", Content: mindMapPrompt(strings.Join(sections, "\n"))}, {Role: "user", Content: "Output:"}}
+ ctx, cancel := context.WithTimeout(context.Background(), 10*time.Minute)
+ defer cancel()
+
+ // search_config chat_id can be a stale tenant_model ID that no longer
+ // exists. ResolveModelConfig tries ID lookup first, then falls back to
+ // composite-name parsing which fails for bare IDs. If the configured
+ // model can't be resolved, fall back to the tenant's default chat model
+ // (mirrors Python's gen_mindmap get_tenant_default_model_by_type).
+ ch, streamErr := config.LLM.ChatStream(ctx, modelTenantID, modelID, messages, &modelModule.ChatConfig{})
+ if streamErr != nil && config.TenantSvc != nil {
+ if defaultModel, err := config.TenantSvc.GetDefaultModelName(modelTenantID, entity.ModelTypeChat); err == nil && defaultModel != "" && defaultModel != modelID {
+ ch, streamErr = config.LLM.ChatStream(ctx, modelTenantID, defaultModel, messages, &modelModule.ChatConfig{})
}
}
- response, err := config.LLM.Chat(modelTenantID, modelID, []modelModule.Message{{Role: "user", Content: mindMapPrompt(strings.Join(sections, "\n"))}, {Role: "user", Content: "Output:"}}, &modelModule.ChatConfig{})
- if err != nil {
- return mindMapNode{}, err
+ if streamErr != nil {
+ return mindMapNode{}, streamErr
}
- if response == nil || response.Answer == nil {
+ var sb strings.Builder
+ for delta := range ch {
+ sb.WriteString(delta)
+ }
+ fullText := sb.String()
+ if fullText == "" {
return mindMapNode{ID: "root", Children: []mindMapNode{}}, nil
}
- return parseMindMapMarkdown(*response.Answer), nil
+ return parseMindMapMarkdown(fullText), nil
}
func searchConfigFromDetail(detail map[string]interface{}) map[string]interface{} {
@@ -232,22 +247,20 @@ type mindMapNode struct {
var mindMapHeadingRe = regexp.MustCompile(`^(#{1,6})\s+(.+)$`)
var mindMapListRe = regexp.MustCompile(`^(\s*)(?:[-*+]|\d+\.)\s+(.+)$`)
+var mindMapThinkRe = regexp.MustCompile(`(?s).*?(?:|$)`)
+var mindMapFenceRe = regexp.MustCompile("(?m)^```[^\n]*$")
func parseMindMapMarkdown(text string) mindMapNode {
+ text = mindMapThinkRe.ReplaceAllString(text, "")
+ text = mindMapFenceRe.ReplaceAllString(text, "")
lines := strings.Split(strings.ReplaceAll(text, "\r\n", "\n"), "\n")
root := mindMapNode{ID: "root", Children: []mindMapNode{}}
stack := []*mindMapNode{&root}
- inFence := false
listBaseLevel := 1
lastWasList := false
for _, line := range lines {
trimmed := strings.TrimSpace(line)
- if strings.HasPrefix(trimmed, "```") {
- inFence = !inFence
- lastWasList = false
- continue
- }
- if inFence || trimmed == "" {
+ if trimmed == "" {
lastWasList = false
continue
}
diff --git a/internal/handler/searchbot_test.go b/internal/handler/searchbot_test.go
index 04b246d8a5..cca2a40353 100644
--- a/internal/handler/searchbot_test.go
+++ b/internal/handler/searchbot_test.go
@@ -584,6 +584,47 @@ func TestParseMindMapMarkdown_ListUnderHeading(t *testing.T) {
}
}
+func TestParseMindMapMarkdown_CodeFence(t *testing.T) {
+ input := "```markdown\n# Title\n## Section\n- Item\n```"
+ got := parseMindMapMarkdown(input)
+ if got.ID != "Title" {
+ t.Fatalf("root = %q, want Title", got.ID)
+ }
+ if len(got.Children) != 1 || got.Children[0].ID != "Section" {
+ t.Fatalf("children = %+v, want Section", got.Children)
+ }
+}
+
+func TestParseMindMapMarkdown_ThinkTag(t *testing.T) {
+ input := "reasoning here\n# Title\n- Item"
+ got := parseMindMapMarkdown(input)
+ if got.ID != "Title" {
+ t.Fatalf("root = %q, want Title", got.ID)
+ }
+ if len(got.Children) != 1 || got.Children[0].ID != "Item" {
+ t.Fatalf("children = %+v, want Item", got.Children)
+ }
+}
+
+func TestParseMindMapMarkdown_ThinkTagMultiple(t *testing.T) {
+ input := "first\n# A\n- x\nsecond\n# B\n- y"
+ got := parseMindMapMarkdown(input)
+ if got.ID != "root" {
+ t.Fatalf("root = %q, want root (two top-level headings)", got.ID)
+ }
+ if len(got.Children) != 2 || got.Children[0].ID != "A" || got.Children[1].ID != "B" {
+ t.Fatalf("children = %+v, want [A, B]", got.Children)
+ }
+}
+
+func TestParseMindMapMarkdown_ThinkTagUnclosed(t *testing.T) {
+ input := "reasoning that gets cut off by max tokens without a close tag"
+ got := parseMindMapMarkdown(input)
+ if len(got.Children) != 0 {
+ t.Fatalf("children = %+v, want empty root for unclosed think block", got.Children)
+ }
+}
+
// ---- SSE helper direct tests ----
func TestSseAnswer_Final(t *testing.T) {