Go: Add create search (#13998)

### What problem does this PR solve?

As title

### Type of change

- [x] New Feature (non-breaking change which adds functionality)

---------

Signed-off-by: Jin Hai <haijin.chn@gmail.com>
This commit is contained in:
Jin Hai
2026-04-09 20:04:06 +08:00
committed by GitHub
parent b33d2fdea5
commit 5951e2b564
9 changed files with 464 additions and 114 deletions

125
internal/common/app_name.go Normal file
View File

@@ -0,0 +1,125 @@
//
// Copyright 2026 The InfiniFlow Authors. All Rights Reserved.
//
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.
//
package common
import (
"fmt"
"path"
"regexp"
"strings"
"github.com/google/uuid"
)
// splitNameCounter splits a filename into base name and counter
// Handles names in format "filename(123)" pattern
//
// Parameters:
// - filename: The filename to split
//
// Returns:
// - string: The base name without counter
// - *int: The counter value, or nil if no counter exists
//
// Example:
//
// splitNameCounter("test(5)") returns ("test", 5)
// splitNameCounter("test") returns ("test", nil)
func splitNameCounter(filename string) (string, *int) {
re := regexp.MustCompile(`^(.+)\((\d+)\)$`)
matches := re.FindStringSubmatch(filename)
if len(matches) >= 3 {
counter := -1
fmt.Sscanf(matches[2], "%d", &counter)
stem := strings.TrimRight(matches[1], " ")
return stem, &counter
}
return filename, nil
}
// DuplicateName generates a unique name by appending a counter if the name already exists
// It tries up to 1000 times to generate a unique name
//
// Parameters:
// - queryFunc: Function to check if a name already exists (returns true if exists)
// - name: The original name
// - tenantID: The tenant ID for name uniqueness check
//
// Returns:
// - string: A unique name (either original or with counter appended)
//
// Example:
//
// DuplicateName(func(name string, tid string) bool { return false }, "test", "tenant1") returns "test"
// DuplicateName(func(name string, tid string) bool { return true }, "test", "tenant1") returns "test(1)"
func DuplicateName(queryFunc func(name string, tenantID string) bool, name string, tenantID string) (string, error) {
const maxRetries = 1000
originalName := name
currentName := name
retries := 0
for retries < maxRetries {
if !queryFunc(currentName, tenantID) {
return currentName, nil
}
stem, counter := splitNameCounter(currentName)
ext := path.Ext(stem)
stemBase := strings.TrimSuffix(stem, ext)
newCounter := 1
if counter != nil {
newCounter = *counter + 1
}
currentName = fmt.Sprintf("%s(%d)%s", stemBase, newCounter, ext)
retries++
if err := ValidateName(currentName); err != nil {
return "", err
}
}
return "", fmt.Errorf("failed to generate unique name after %d attempts, conflict name: %s", maxRetries, originalName)
}
const AppNameLimit = 256
func ValidateName(name string) error {
// Validate name is not empty after trimming
trimmedName := strings.TrimSpace(name)
if trimmedName == "" {
return fmt.Errorf("name can't be empty")
}
// Validate name length in bytes (not characters) - same as Python len(search_name.encode("utf-8"))
if len([]byte(name)) > AppNameLimit {
return fmt.Errorf("name length is %d which is large than %d", len([]byte(name)), AppNameLimit)
}
return nil
}
// GenerateUUID generates a UUID without dashes
func GenerateUUID() string {
newID := strings.ReplaceAll(uuid.New().String(), "-", "")
if len(newID) > 32 {
newID = newID[:32]
}
return newID
}

52
internal/common/time.go Normal file
View File

@@ -0,0 +1,52 @@
//
// Copyright 2026 The InfiniFlow Authors. All Rights Reserved.
//
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.
//
package common
import (
"time"
)
// DeltaSeconds calculates seconds elapsed from a given date string to now.
//
// Supports multiple time formats:
// - "YYYY-MM-DD HH:MM:SS" (e.g., "2024-01-01 12:00:00")
// - ISO 8601 / RFC3339 (e.g., "2026-04-09T18:55:46+08:00")
//
// Args:
// dateString: Date string in supported format
//
// Returns:
// float64: Number of seconds between the given date and current time
//
// Example:
// DeltaSeconds("2024-01-01 12:00:00")
// DeltaSeconds("2026-04-09T18:55:46+08:00")
func DeltaSeconds(dateString string) (float64, error) {
// Try RFC3339 format first (ISO 8601 with timezone, e.g., "2026-04-09T18:55:46+08:00")
dt, err := time.Parse(time.RFC3339, dateString)
if err == nil {
return time.Since(dt).Seconds(), nil
}
// Try custom format without timezone (e.g., "2024-01-01 12:00:00")
const layout = "2006-01-02 15:04:05"
dt, err = time.ParseInLocation(layout, dateString, time.Local)
if err != nil {
return 0, err
}
return time.Since(dt).Seconds(), nil
}

View File

@@ -124,3 +124,24 @@ func (dao *SearchDAO) GetByID(id string) (*entity.Search, error) {
}
return &search, nil
}
// GetByNameAndTenant gets search by name and tenant ID
func (dao *SearchDAO) GetByNameAndTenant(name string, tenantID string) ([]*entity.Search, error) {
var searches []*entity.Search
err := DB.Where("name = ? AND tenant_id = ? AND status = ?", name, tenantID, "1").Find(&searches).Error
return searches, err
}
// Create creates a new search
func (dao *SearchDAO) Create(search *entity.Search) error {
return DB.Create(search).Error
}
// QueryByTenantIDAndID checks if a search exists with given tenant_id and id
// Reference: Python SearchService.query(tenant_id=tenant.tenant_id, id=search_id)
// Used for permission verification in detail API
func (dao *SearchDAO) QueryByTenantIDAndID(tenantID string, searchID string) ([]*entity.Search, error) {
var searches []*entity.Search
err := DB.Where("tenant_id = ? AND id = ? AND status = ?", tenantID, searchID, "1").Find(&searches).Error
return searches, err
}

View File

@@ -114,3 +114,140 @@ func (h *SearchHandler) ListSearches(c *gin.Context) {
"message": "success",
})
}
// CreateSearch create a new search app
// @Summary Create Search App
// @Description Create a new search app for the current user
// @Tags search
// @Accept json
// @Produce json
// @Param request body service.CreateSearchRequest true "search creation parameters"
// @Success 200 {object} service.CreateSearchResponse
// @Router /api/v1/searches [post]
type CreateSearchRequest struct {
Name string `json:"name" binding:"required"` // required field, max 255 bytes
Description *string `json:"description,omitempty"` // optional description
}
func (h *SearchHandler) CreateSearch(c *gin.Context) {
// Get current user from context (same as Python current_user)
user, errorCode, errorMessage := GetUser(c)
if errorCode != common.CodeSuccess {
jsonError(c, errorCode, errorMessage)
return
}
userID := user.ID
// Parse request body (same as Python get_request_json())
var req CreateSearchRequest
if err := c.ShouldBindJSON(&req); err != nil {
c.JSON(http.StatusBadRequest, gin.H{
"code": common.CodeBadRequest,
"data": nil,
"message": "Invalid request body: " + err.Error(),
})
return
}
if err := common.ValidateName(req.Name); err != nil {
c.JSON(http.StatusOK, gin.H{
"code": common.CodeDataError,
"data": nil,
"message": err.Error(),
})
return
}
// Create search (same as Python SearchService.save within DB.atomic())
result, err := h.searchService.CreateSearch(userID, req.Name, req.Description)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{
"code": common.CodeServerError,
"data": nil,
"message": err.Error(),
})
return
}
// Return success response (same as Python get_json_result(data={"search_id": req["id"]}))
c.JSON(http.StatusOK, gin.H{
"code": common.CodeSuccess,
"data": result,
"message": "success",
})
}
// GetSearch get search app detail
// @Summary Get Search App Detail
// @Description Get detail of a search app by ID
// @Tags search
// @Accept json
// @Produce json
// @Param search_id path string true "search app ID"
// @Success 200 {object} entity.Search
// @Router /api/v1/searches/{search_id} [get]
func (h *SearchHandler) GetSearch(c *gin.Context) {
// Get current user from context (same as Python current_user)
user, errorCode, errorMessage := GetUser(c)
if errorCode != common.CodeSuccess {
jsonError(c, errorCode, errorMessage)
return
}
userID := user.ID
// Get search_id from path parameter (same as Python <search_id>)
searchID := c.Param("search_id")
if searchID == "" {
c.JSON(http.StatusBadRequest, gin.H{
"code": common.CodeBadRequest,
"data": nil,
"message": "search_id is required",
})
return
}
// Get search detail with permission check
search, err := h.searchService.GetSearchDetail(userID, searchID)
if err != nil {
// Check if it's a permission error
if err.Error() == "has no permission for this operation" {
c.JSON(http.StatusOK, gin.H{
"code": common.CodeOperatingError,
"data": false,
"message": "Has no permission for this operation.",
})
return
}
// Not found error
c.JSON(http.StatusOK, gin.H{
"code": common.CodeDataError,
"data": nil,
"message": err.Error(),
})
return
}
// Convert to response format (same as Python get_json_result(data=search))
result := map[string]interface{}{
"id": search.ID,
"tenant_id": search.TenantID,
"name": search.Name,
"description": search.Description,
"created_by": search.CreatedBy,
"create_time": search.CreateTime,
"update_time": search.UpdateTime,
"search_config": search.SearchConfig,
}
if search.Avatar != nil {
result["avatar"] = *search.Avatar
}
// Return success response
c.JSON(http.StatusOK, gin.H{
"code": common.CodeSuccess,
"data": result,
"message": "success",
})
}

View File

@@ -192,10 +192,12 @@ func (r *Router) Setup(engine *gin.Engine) {
chats.GET("", r.chatHandler.ListChats)
}
searches := v1.Group("/searches")
{
searches.GET("", r.searchHandler.ListSearches)
}
searches := v1.Group("/searches")
{
searches.GET("", r.searchHandler.ListSearches)
searches.POST("", r.searchHandler.CreateSearch)
searches.GET("/:search_id", r.searchHandler.GetSearch)
}
file := v1.Group("/files")
{

View File

@@ -28,8 +28,6 @@ import (
"ragflow/internal/utility"
"strings"
"time"
"github.com/google/uuid"
)
// KnowledgebaseService service class for managing dataset operations
@@ -147,7 +145,7 @@ func (s *KnowledgebaseService) CreateKB(req *CreateKBRequest, tenantID string) (
parserConfig["llm_id"] = tenant.LLMID
// Generate KB ID
kbID := strings.ReplaceAll(uuid.New().String(), "-", "")
kbID := common.GenerateUUID()
// Create knowledge base model
now := time.Now().Unix()
@@ -551,11 +549,6 @@ func mergeParserConfig(base, override map[string]interface{}) map[string]interfa
return result
}
// GenerateUUID generates a UUID string without dashes
func GenerateUUID() string {
return strings.ReplaceAll(uuid.New().String(), "-", "")
}
// GetUserByToken gets user by authorization token
func (s *KnowledgebaseService) GetUserByToken(authorization string) (*entity.User, common.ErrorCode, error) {
userService := NewUserService()

View File

@@ -19,15 +19,12 @@ package service
import (
"errors"
"fmt"
"path"
"ragflow/internal/common"
"ragflow/internal/entity"
"regexp"
"strconv"
"strings"
"time"
"github.com/google/uuid"
"ragflow/internal/dao"
)
@@ -241,75 +238,6 @@ func NewMemoryService() *MemoryService {
}
}
// splitNameCounter splits a filename into base name and counter
// Handles names in format "filename(123)" pattern
//
// Parameters:
// - filename: The filename to split
//
// Returns:
// - string: The base name without counter
// - *int: The counter value, or nil if no counter exists
//
// Example:
//
// splitNameCounter("test(5)") returns ("test", 5)
// splitNameCounter("test") returns ("test", nil)
func splitNameCounter(filename string) (string, *int) {
re := regexp.MustCompile(`^(.+)\((\d+)\)$`)
matches := re.FindStringSubmatch(filename)
if len(matches) >= 3 {
counter := -1
fmt.Sscanf(matches[2], "%d", &counter)
stem := strings.TrimRight(matches[1], " ")
return stem, &counter
}
return filename, nil
}
// duplicateName generates a unique name by appending a counter if the name already exists
// It tries up to 1000 times to generate a unique name
//
// Parameters:
// - queryFunc: Function to check if a name already exists (returns true if exists)
// - name: The original name
// - tenantID: The tenant ID for name uniqueness check
//
// Returns:
// - string: A unique name (either original or with counter appended)
//
// Example:
//
// duplicateName(func(name string, tid string) bool { return false }, "test", "tenant1") returns "test"
// duplicateName(func(name string, tid string) bool { return true }, "test", "tenant1") returns "test(1)"
func duplicateName(queryFunc func(name string, tenantID string) bool, name string, tenantID string) string {
const maxRetries = 1000
originalName := name
currentName := name
retries := 0
for retries < maxRetries {
if !queryFunc(currentName, tenantID) {
return currentName
}
stem, counter := splitNameCounter(currentName)
ext := path.Ext(stem)
stemBase := strings.TrimSuffix(stem, ext)
newCounter := 1
if counter != nil {
newCounter = *counter + 1
}
currentName = fmt.Sprintf("%s(%d)%s", stemBase, newCounter, ext)
retries++
}
panic(fmt.Sprintf("Failed to generate unique name within %d attempts. Original: %s", maxRetries, originalName))
}
// CreateMemoryRequest defines the request structure for creating a memory
type CreateMemoryRequest struct {
// Name is the memory name (required, max 128 characters)
@@ -410,14 +338,12 @@ func (s *MemoryService) CreateMemory(tenantID string, req *CreateMemoryRequest)
req.TenantEmbdID = &tenantEmbdIDStr
}
memoryName := strings.TrimSpace(req.Name)
if len(memoryName) == 0 {
return nil, errors.New("memory name cannot be empty or whitespace")
}
if len(memoryName) > MemoryNameLimit {
return nil, fmt.Errorf("memory name '%s' exceeds limit of %d", memoryName, MemoryNameLimit)
if err := common.ValidateName(req.Name); err != nil {
return nil, err
}
memoryName := req.Name
if !isList(req.MemoryType) {
return nil, errors.New("memory type must be a list")
}
@@ -435,13 +361,12 @@ func (s *MemoryService) CreateMemory(tenantID string, req *CreateMemoryRequest)
uniqueMemoryTypes = append(uniqueMemoryTypes, mt)
}
memoryName = duplicateName(func(name string, tid string) bool {
memoryName, err := common.DuplicateName(func(name string, tid string) bool {
existing, _ := s.memoryDAO.GetByNameAndTenant(name, tid)
return len(existing) > 0
}, memoryName, tenantID)
if len(memoryName) > MemoryNameLimit {
return nil, fmt.Errorf("memory name %s exceeds limit of %d", memoryName, MemoryNameLimit)
if err != nil {
return nil, err
}
memoryTypeInt := dao.CalculateMemoryType(uniqueMemoryTypes)
@@ -449,10 +374,7 @@ func (s *MemoryService) CreateMemory(tenantID string, req *CreateMemoryRequest)
systemPrompt := PromptAssembler{}.AssembleSystemPrompt(uniqueMemoryTypes)
newID := strings.ReplaceAll(uuid.New().String(), "-", "")
if len(newID) > 32 {
newID = newID[:32]
}
newID := common.GenerateUUID()
memory := &entity.Memory{
ID: newID,
@@ -516,18 +438,15 @@ func (s *MemoryService) UpdateMemory(tenantID string, memoryID string, req *Upda
if req.Name != nil {
memoryName := strings.TrimSpace(*req.Name)
if len(memoryName) == 0 {
return nil, errors.New("memory name cannot be empty or whitespace")
if err := common.ValidateName(memoryName); err != nil {
return nil, err
}
if len(memoryName) > MemoryNameLimit {
return nil, fmt.Errorf("memory name '%s' exceeds limit of %d", memoryName, MemoryNameLimit)
}
memoryName = duplicateName(func(name string, tid string) bool {
memoryName, err := common.DuplicateName(func(name string, tid string) bool {
existing, _ := s.memoryDAO.GetByNameAndTenant(name, tid)
return len(existing) > 0
}, memoryName, tenantID)
if len(memoryName) > MemoryNameLimit {
return nil, fmt.Errorf("memory name %s exceeds limit of %d", memoryName, MemoryNameLimit)
if err != nil {
return nil, err
}
updateDict["name"] = memoryName
}

View File

@@ -17,6 +17,8 @@
package service
import (
"fmt"
"ragflow/internal/common"
"ragflow/internal/dao"
"ragflow/internal/entity"
)
@@ -130,3 +132,104 @@ func (s *SearchService) toSearchAppResponse(search *entity.Search) map[string]in
return result
}
// CreateSearchResponse create search response
// Reference: api/apps/restful_apis/search_api.py::create - returns {"search_id": req["id"]}
type CreateSearchResponse struct {
SearchID string `json:"search_id"` // UUID format
}
// CreateSearch creates a new search app
// Reference: api/apps/restful_apis/search_api.py::create
// Python implementation steps:
// 1. Get JSON request body with name (required) and description (optional)
// 2. Validate name is string, non-empty, and max 255 bytes
// 3. Generate unique name using duplicate_name(SearchService.query, name, tenant_id)
// 4. Generate UUID for search ID
// 5. Set fields: id, name, description, tenant_id, created_by
// 6. Save to database within DB.atomic() transaction
// 7. Return {search_id: id} on success
//
// Error handling from Python:
// - Name not string: "Search name must be string."
// - Name empty: "Search name can't be empty."
// - Name too long: "Search name length is X which is larger than 255."
// - Tenant not found: "Authorized identity."
// - Save failure: generic get_data_error_result()
//
// Note: Go implementation validates these in handler layer for cleaner separation
// Note: Similar pattern in: CreateMemory (memory.go), CreateDataset (datasets.go)
func (s *SearchService) CreateSearch(userID string, name string, description *string) (*CreateSearchResponse, error) {
// Generate UUID for search ID (same as Python get_uuid())
searchID := common.GenerateUUID()
// Generate unique name (same as Python duplicate_name)
uniqueName, err := common.DuplicateName(func(name string, tid string) bool {
existing, _ := s.searchDAO.GetByNameAndTenant(name, tid)
return len(existing) > 0
}, name, userID)
if err != nil {
return nil, err
}
// Create search entity
search := &entity.Search{
ID: searchID,
TenantID: userID,
Name: uniqueName,
CreatedBy: userID,
SearchConfig: make(entity.JSONMap),
}
if description != nil {
search.Description = description
}
// Set default status ("1" = valid/active, same as Python StatusEnum.VALID.value)
status := "1"
search.Status = &status
// Save to database
if err := s.searchDAO.Create(search); err != nil {
return nil, fmt.Errorf("failed to create search: %w", err)
}
return &CreateSearchResponse{
SearchID: searchID,
}, nil
}
func (s *SearchService) GetSearchDetail(userID string, searchID string) (*entity.Search, error) {
// Step 1: Get user tenants (same as Python UserTenantService.query(user_id=current_user.id))
tenants, err := s.userTenantDAO.GetByUserID(userID)
if err != nil {
return nil, fmt.Errorf("failed to get user tenants: %w", err)
}
// Step 2: Check if user has permission to access this search
// Python: for tenant in tenants: if SearchService.query(tenant_id=tenant.tenant_id, id=search_id): break
hasPermission := false
for _, tenant := range tenants {
searches, err := s.searchDAO.QueryByTenantIDAndID(tenant.TenantID, searchID)
if err != nil {
continue // Try next tenant
}
if len(searches) > 0 {
hasPermission = true
break
}
}
if !hasPermission {
return nil, fmt.Errorf("has no permission for this operation")
}
// Step 3: Get search detail (same as Python SearchService.get_detail(search_id))
search, err := s.searchDAO.GetByID(searchID)
if err != nil {
return nil, fmt.Errorf("can't find this Search App!")
}
return search, nil
}

View File

@@ -19,13 +19,11 @@ package service
import (
"context"
"fmt"
"ragflow/internal/entity"
"strings"
"time"
"ragflow/internal/common"
"ragflow/internal/dao"
"ragflow/internal/engine"
"ragflow/internal/entity"
"strings"
)
// TenantService tenant service
@@ -232,14 +230,14 @@ func (s *TenantService) GetTenantList(userID string) ([]*TenantListItem, error)
}
result := make([]*TenantListItem, len(tenants))
now := time.Now()
for i, t := range tenants {
// Parse update_date and calculate delta_seconds
var deltaSeconds float64
if t.UpdateDate != "" {
if updateTime, err := time.Parse("2006-01-02 15:04:05", t.UpdateDate); err == nil {
deltaSeconds = now.Sub(updateTime).Seconds()
deltaSeconds, err = common.DeltaSeconds(t.UpdateDate)
if err != nil {
return nil, err
}
}