diff --git a/internal/service/chat_pipeline.go b/internal/service/chat_pipeline.go index fc81b22df9..636696b231 100644 --- a/internal/service/chat_pipeline.go +++ b/internal/service/chat_pipeline.go @@ -846,6 +846,12 @@ func (s *ChatPipelineService) AsyncChat( // If empty_response is not configured, fall through to the LLM call // with an empty knowledge context. // + // EXCEPTION: when the user attached files to their message, the + // attachment text provides context that should be sent to the LLM + // even if KB retrieval returned nothing. In that case we skip the + // early return and fall through to the normal LLM call where + // attachments are appended to the system prompt. + // // Two results are yielded (mirroring Python dialog_service.py): // 1. Final=false — carries the answer text so streaming consumers // actually display the fallback message. @@ -854,7 +860,7 @@ func (s *ChatPipelineService) AsyncChat( // final event (dialog_service.py:807); consumers that only look at // the final event (e.g. the OpenAI-compatible endpoint) would // otherwise see an empty reply. - if len(knowledges) == 0 { + if len(knowledges) == 0 && attachments == "" { if emptyResp, ok := promptConfig["empty_response"].(string); ok && emptyResp != "" { out <- AsyncChatResult{ Answer: emptyResp, diff --git a/internal/service/file/file_content.go b/internal/service/file/file_content.go index b18afd4da1..c3fb251143 100644 --- a/internal/service/file/file_content.go +++ b/internal/service/file/file_content.go @@ -95,6 +95,19 @@ func (s *FileService) DownloadAgentFile(tenantID, location string) ([]byte, erro // GetFileContents fetches file contents (text + image) from storage // for the given file dicts. +// +// File dicts are the descriptors returned by the upload_info endpoint +// (UploadInfos / storeUploadInfoBlob). They contain: +// +// - "id": storage location UUID (key in the downloads bucket) +// - "created_by": the user ID who owns the downloads bucket +// - "name": the original filename +// - "mime_type": the content type +// +// Blobs are stored directly in "{created_by}-downloads/{id}" in object +// storage WITHOUT a corresponding File entity row in the database. +// Mirrors Python's FileService.get_files → get_blob(user_id, file_id). +// // - raw=false: images returned as base64 data URIs in images; non-images parsed and returned as text. // - raw=true: images returned as raw bytes in images; non-images parsed and returned as text. func (s *FileService) GetFileContents(ctx context.Context, uid string, fileDicts []map[string]interface{}, raw bool) (texts []string, images []string, err error) { @@ -108,28 +121,36 @@ func (s *FileService) GetFileContents(ctx context.Context, uid string, fileDicts if id == "" { continue } - file, ferr := s.fileDAO.GetByID(id) - if ferr != nil || file == nil || file.Location == nil || *file.Location == "" { - continue + name, _ := fd["name"].(string) + mimeType, _ := fd["mime_type"].(string) + createdBy, _ := fd["created_by"].(string) + if createdBy == "" { + createdBy = uid } - if !s.checkFilePerm(s.fileDAO, file, uid) { + // Permission: only the owner can access their uploads bucket. + if createdBy != uid { return nil, nil, fmt.Errorf("No authorization.") } - data, derr := storageImpl.Get(file.ParentID, *file.Location) + + data, derr := storageImpl.Get(createdBy+"-downloads", id) if derr != nil || len(data) == 0 { continue } - ft := utility.FilenameType(file.Name) + + ft := utility.FilenameType(name) if ft == utility.FileTypeVISUAL { if raw { images = append(images, string(data)) } else { - ext := utility.GetFileExtension(file.Name) - mime := utility.GetContentType(ext, string(ft)) - images = append(images, "data:"+mime+";base64,"+base64.StdEncoding.EncodeToString(data)) + mediaType := strings.ToLower(strings.TrimSpace(strings.Split(mimeType, ";")[0])) + if mediaType == "" { + ext := utility.GetFileExtension(name) + mediaType = utility.GetContentType(ext, string(ft)) + } + images = append(images, "data:"+mediaType+";base64,"+base64.StdEncoding.EncodeToString(data)) } } else { - texts = append(texts, parseFileContent(ctx, file.Name, data)) + texts = append(texts, parseFileContent(ctx, name, data)) } } return texts, images, nil diff --git a/internal/service/file/file_test.go b/internal/service/file/file_test.go index 4018aaca1a..0ea818b78b 100644 --- a/internal/service/file/file_test.go +++ b/internal/service/file/file_test.go @@ -11,9 +11,6 @@ import ( "testing" "time" - "github.com/glebarez/sqlite" - "gorm.io/gorm" - "ragflow/internal/dao" "ragflow/internal/entity" "ragflow/internal/storage" @@ -100,108 +97,23 @@ func (f *fakeStorage) Move(srcBucket, srcPath, destBucket, destPath string) bool func (f *fakeStorage) Close() error { return nil } -func setupFileContentPermissionDB(t *testing.T, accessible bool) { - t.Helper() - - db, err := gorm.Open(sqlite.Open(":memory:"), &gorm.Config{ - TranslateError: true, - }) - if err != nil { - t.Fatalf("failed to open sqlite: %v", err) - } - if err := db.AutoMigrate( - &entity.File{}, - &entity.File2Document{}, - &entity.Document{}, - &entity.Knowledgebase{}, - &entity.Tenant{}, - &entity.UserTenant{}, - ); err != nil { - t.Fatalf("failed to migrate file content tables: %v", err) - } - - location := "doc.txt" - if err := db.Create(&entity.File{ - ID: "file-1", - ParentID: "bucket-1", - TenantID: "owner-user", - Name: "doc.txt", - Location: &location, - Type: "file", - }).Error; err != nil { - t.Fatalf("insert file: %v", err) - } - if err := db.Create(&entity.Document{ - ID: "doc-1", - KbID: "kb-owner", - ParserID: "naive", - ParserConfig: entity.JSONMap{}, - Status: sptr(string(entity.StatusValid)), - }).Error; err != nil { - t.Fatalf("insert document: %v", err) - } - fileID := "file-1" - docID := "doc-1" - if err := db.Create(&entity.File2Document{ - ID: "f2d-1", - FileID: &fileID, - DocumentID: &docID, - }).Error; err != nil { - t.Fatalf("insert file2document: %v", err) - } - if err := db.Create(&entity.Knowledgebase{ - ID: "kb-owner", - TenantID: "tenant-owner", - Name: "owner-kb", - EmbdID: "embd-1", - CreatedBy: "owner-user", - Permission: string(entity.TenantPermissionTeam), - Status: sptr(string(entity.StatusValid)), - }).Error; err != nil { - t.Fatalf("insert knowledgebase: %v", err) - } - if err := db.Create(&entity.Tenant{ - ID: "tenant-owner", - LLMID: "llm-1", - EmbdID: "embd-1", - ASRID: "asr-1", - Status: sptr(string(entity.StatusValid)), - }).Error; err != nil { - t.Fatalf("insert tenant: %v", err) - } - if accessible { - if err := db.Create(&entity.UserTenant{ - ID: "ut-user-1", - UserID: "user-1", - TenantID: "tenant-owner", - Role: "normal", - Status: sptr(string(entity.StatusValid)), - }).Error; err != nil { - t.Fatalf("insert user_tenant: %v", err) - } - } - - orig := dao.DB - dao.DB = db - t.Cleanup(func() { dao.DB = orig }) -} - func TestFileService_GetFileContents_NotAccessible(t *testing.T) { - ctx := t.Context() - setupFileContentPermissionDB(t, false) - - orig := testFilePerm - testFilePerm = func(_ *dao.FileDAO, _ *entity.File, _ string) bool { return false } - t.Cleanup(func() { testFilePerm = orig }) - - mockStorage := &fakeStorage{blob: []byte("secret")} + memory := storage.NewMemoryStorage() + if err := memory.Put("other-user-downloads", "loc-1", []byte("secret")); err != nil { + t.Fatalf("put: %v", err) + } factory := storage.GetStorageFactory() originalStorage := factory.GetStorage() - factory.SetStorage(mockStorage) + factory.SetStorage(memory) t.Cleanup(func() { factory.SetStorage(originalStorage) }) svc := testFileService() - texts, images, err := svc.GetFileContents(ctx, "user-1", []map[string]interface{}{{"id": "file-1"}}, false) + texts, images, err := svc.GetFileContents(t.Context(), "user-1", []map[string]interface{}{{ + "id": "loc-1", + "name": "secret.txt", + "mime_type": "text/plain", + "created_by": "other-user", + }}, false) if err == nil { t.Fatal("expected authorization error") } @@ -211,23 +123,25 @@ func TestFileService_GetFileContents_NotAccessible(t *testing.T) { if len(texts) != 0 || len(images) != 0 { t.Fatalf("expected no content, got texts=%v images=%v", texts, images) } - if mockStorage.getCalls != 0 { - t.Fatalf("storage should not be read without permission, got %d calls", mockStorage.getCalls) - } } func TestFileService_GetFileContents_Accessible(t *testing.T) { - ctx := t.Context() - setupFileContentPermissionDB(t, true) - - mockStorage := &fakeStorage{blob: []byte("allowed content")} + memory := storage.NewMemoryStorage() + if err := memory.Put("user-1-downloads", "loc-1", []byte("allowed content")); err != nil { + t.Fatalf("put: %v", err) + } factory := storage.GetStorageFactory() originalStorage := factory.GetStorage() - factory.SetStorage(mockStorage) + factory.SetStorage(memory) t.Cleanup(func() { factory.SetStorage(originalStorage) }) svc := testFileService() - texts, images, err := svc.GetFileContents(ctx, "user-1", []map[string]interface{}{{"id": "file-1"}}, false) + texts, images, err := svc.GetFileContents(t.Context(), "user-1", []map[string]interface{}{{ + "id": "loc-1", + "name": "doc.txt", + "mime_type": "text/plain", + "created_by": "user-1", + }}, false) if err != nil { t.Fatalf("GetFileContents failed: %v", err) } @@ -237,12 +151,6 @@ func TestFileService_GetFileContents_Accessible(t *testing.T) { if len(texts) != 1 || !strings.Contains(texts[0], "allowed content") { t.Fatalf("unexpected texts: %v", texts) } - if mockStorage.getCalls != 1 { - t.Fatalf("storage get calls = %d, want 1", mockStorage.getCalls) - } - if mockStorage.lastBucket != "bucket-1" || mockStorage.lastFnm != "doc.txt" { - t.Fatalf("storage read %s/%s, want bucket-1/doc.txt", mockStorage.lastBucket, mockStorage.lastFnm) - } } func TestFileService_ParseAgentUploads_TextAndImageInRequestOrder(t *testing.T) {