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) {