fix: chat attachments not used in Go mode (#17259)

This commit is contained in:
euvre
2026-07-23 20:24:13 +08:00
committed by GitHub
parent d12fd3b79d
commit 4dfa5b145b
3 changed files with 60 additions and 125 deletions

View File

@@ -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,

View File

@@ -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

View File

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