Port agent PRs to GO - 4 (#16652)

### Summary

Port

https://github.com/infiniflow/ragflow/pull/15399
https://github.com/infiniflow/ragflow/pull/16469
This commit is contained in:
qinling0210
2026-07-06 10:58:40 +08:00
committed by GitHub
parent 09eb9dbd21
commit 3d2f60c34f
7 changed files with 301 additions and 99 deletions

View File

@@ -40,6 +40,7 @@ import (
"github.com/google/uuid"
iow "ragflow/internal/agent/component/io"
"ragflow/internal/utility"
)
const componentNameDocsGenerator = "DocsGenerator"
@@ -272,15 +273,21 @@ func (d *DocsGenerator) Invoke(ctx context.Context, inputs map[string]any) (map[
docID := uuid.New().String()
size := len(payload)
downloadStub := fmt.Sprintf("inline://docs/%s/%s", docID, safeName)
// ext from formatExtension includes a leading dot (".pdf"); strip it
// so the preview URL has ?ext=pdf rather than ?ext=.pdf, matching the
// Python branch which passes the raw output_format string.
extWithoutDot := strings.TrimPrefix(ext, ".")
previewURL := utility.AgentAttachmentPreviewPath(docID, extWithoutDot, mime)
return map[string]any{
"doc_id": docID,
"filename": safeName,
"mime_type": mime,
"size": size,
"bytes": payload,
"download": downloadStub,
"created": time.Now().UTC().Format(time.RFC3339),
"doc_id": docID,
"filename": safeName,
"mime_type": mime,
"size": size,
"bytes": payload,
"download": downloadStub,
"preview_url": previewURL,
"created": time.Now().UTC().Format(time.RFC3339),
}, nil
}
@@ -308,13 +315,14 @@ func (d *DocsGenerator) Inputs() map[string]string {
// Outputs returns the response surface.
func (d *DocsGenerator) Outputs() map[string]string {
return map[string]string{
"doc_id": "Generated document id (UUID).",
"filename": "Sanitized filename (extension matches output_format).",
"mime_type": "MIME type for the payload.",
"size": "Payload size in bytes.",
"bytes": "Raw document bytes (for storage upload).",
"download": "Stub URI the canvas engine can resolve to a signed URL.",
"created": "RFC3339 timestamp of the generation.",
"doc_id": "Generated document id (UUID).",
"filename": "Sanitized filename (extension matches output_format).",
"mime_type": "MIME type for the payload.",
"size": "Payload size in bytes.",
"bytes": "Raw document bytes (for storage upload).",
"download": "Stub URI the canvas engine can resolve to a signed URL.",
"preview_url": "URL path for inline preview of the generated document.",
"created": "RFC3339 timestamp of the generation.",
}
}

View File

@@ -1009,14 +1009,13 @@ func mergeLLMParam(base LLMParam, inputs map[string]any) LLMParam {
p.MaxTokens = &i
}
if v, ok := stringFrom(inputs, "thinking"); ok {
// Only allow "enabled" or "disabled"; arbitrary DSL
// strings are dropped. Python PR #15220 removed
// thinking from llm.py's gen_conf() — it is no
// longer forwarded to the model. The field is still
// parsed here to match the Python form parameter
// definition, but einoChatInvoker does not consume
// it, consistent with Python's behavior.
if v == "enabled" || v == "disabled" {
// Forward any non-empty, non-"default" value to match the
// lenient Python gate: hasattr(self,"thinking") and
// self.thinking and self.thinking != "default".
// Downstream (einoChatInvoker) only acts on "enabled" /
// "disabled" and silently ignores unknown values, so
// this is safe.
if v != "" && v != "default" {
p.Thinking = v
}
}

View File

@@ -199,14 +199,12 @@ func TestLLM_Registered(t *testing.T) {
}
// TestLLM_ThinkingFieldRoundTrip guards the agent-component
// portion of PR #15446 (thinking switch). The agent component
// accepts `thinking` from the DSL params (whitelisted to the
// two known sentinels) and threads it through LLMParam and the
// ChatInvokeRequest so the default invoker (or a stub) can
// translate it into the provider-specific request body
// (Qwen `enable_thinking`, Kimi/GLM `thinking.type`). Provider
// policy itself lives in internal/llm and is a separate porting
// stream.
// portion of PR #15446 (thinking switch) and PR #16640 (gen_conf
// forwarding). The agent component accepts `thinking` from the DSL
// params (any non-empty, non-"default" value) and threads it through
// LLMParam and the ChatInvokeRequest. Downstream (einoChatInvoker)
// only acts on "enabled" / "disabled" and silently ignores other
// values, so lenient forwarding is safe.
func TestLLM_ThinkingFieldRoundTrip(t *testing.T) {
t.Parallel()
@@ -231,7 +229,7 @@ func TestLLM_ThinkingFieldRoundTrip(t *testing.T) {
t.Errorf("Thinking = %q, want disabled", disabled.Thinking)
}
// Case 3: empty / system-default value is preserved (no defaulting).
// Case 3: empty / missing value → empty (system default).
defaulted := mergeLLMParam(LLMParam{}, map[string]any{
"model_id": "glm-4.6",
"user_prompt": "u",
@@ -240,16 +238,27 @@ func TestLLM_ThinkingFieldRoundTrip(t *testing.T) {
t.Errorf("Thinking = %q, want empty (system default)", defaulted.Thinking)
}
// Case 4: arbitrary string is REJECTED (DSL safety — the LLM
// driver should not see unvalidated values). Mirrors the
// python llm.py:78-79 `if get_attr("thinking") in {"enabled",
// "disabled"}` gate.
arbitrary := mergeLLMParam(LLMParam{}, map[string]any{
"thinking": "yes please",
// Case 4: "default" is explicitly rejected, matching Python's
// `self.thinking != "default"` gate in gen_conf().
defaultStr := mergeLLMParam(LLMParam{}, map[string]any{
"thinking": "default",
"model_id": "glm-4.6",
"user_prompt": "u",
})
if arbitrary.Thinking != "" {
t.Errorf("arbitrary thinking = %q, want empty (rejected)", arbitrary.Thinking)
if defaultStr.Thinking != "" {
t.Errorf(`Thinking = %q, want empty ("default" rejected)`, defaultStr.Thinking)
}
// Case 5: arbitrary / unknown values are leniently forwarded
// (matches Python gen_conf() which passes through any truthy
// non-"default" string). Downstream einoChatInvoker ignores
// unknown values, so this is safe.
arbitrary := mergeLLMParam(LLMParam{}, map[string]any{
"thinking": "auto",
"model_id": "glm-4.6",
"user_prompt": "u",
})
if arbitrary.Thinking != "auto" {
t.Errorf("arbitrary thinking = %q, want auto (lenient forwarding)", arbitrary.Thinking)
}
}

View File

@@ -14,23 +14,22 @@
// limitations under the License.
//
// Gap D — `GET /api/v1/agents/attachments/<attachment_id>/download`
// (Python api/apps/restful_apis/agent_api.py:2368).
// Attachment download & preview handlers.
//
// Mirrors the python download_agent_attachment handler:
// - auth via @login_required → GetUser
// - reads `attachment_id` from the URL path (NOT a query string)
// - default `ext` query parameter is "markdown"
// - uses utility.CONTENT_TYPE_MAP to pick the content type, falling
// back to "application/<ext>" for unknown extensions
// - streams raw bytes back with a sanitized Content-Disposition
// Mirrors the Python handlers in api/apps/restful_apis/agent_api.py:
// - download_attachment (agent_api.py:2368)
// - preview_attachment (agent_api.py:2496)
// - _attachment_request_metadata
// - _stream_agent_attachment
//
// The Python PR #15399 added a dedicated preview endpoint that returns
// Content-Disposition: inline for safe types (PDF, images, Markdown,
// etc.) while still forcing attachment on HTML, SVG, and XML.
package handler
import (
"fmt"
"net/http"
"net/url"
"path/filepath"
"strings"
@@ -46,7 +45,67 @@ type agentAttachmentFileService interface {
DownloadAgentFile(tenantID, location string) ([]byte, error)
}
// attachmentRequestMetadata holds the parsed query params used when
// streaming an attachment. Mirrors Python _attachment_request_metadata().
type attachmentRequestMetadata struct {
ContentType string
Ext string
Filename string
}
// attachmentRequestMeta parses ext, mime_type, and filename from the
// request query string and resolves the content type. Mirrors Python
// _attachment_request_metadata().
func attachmentRequestMeta(c *gin.Context) attachmentRequestMetadata {
ext := strings.TrimSpace(c.Query("ext"))
mimeType := strings.TrimSpace(c.Query("mime_type"))
filename := strings.TrimSpace(c.Query("filename"))
contentType, resolvedExt := utility.ResolveAttachmentContentType(ext, mimeType)
return attachmentRequestMetadata{
ContentType: contentType,
Ext: resolvedExt,
Filename: filename,
}
}
// streamAgentAttachment fetches the blob from storage and writes it to
// the response with the appropriate Content-Disposition header.
// When inline=true, uses SetPreviewFileResponseHeaders (inline for
// safe types, attachment for dangerous ones). When inline=false,
// always uses attachment disposition. Mirrors Python
// _stream_agent_attachment().
func (h *AgentHandler) streamAgentAttachment(c *gin.Context, tenantID, attachmentID string, inline bool) {
if h.fileService == nil {
jsonError(c, common.CodeServerError, "file service not configured")
return
}
blob, err := h.fileService.DownloadAgentFile(tenantID, attachmentID)
if err != nil {
jsonError(c, common.CodeDataError, "Attachment not found!")
return
}
meta := attachmentRequestMeta(c)
if inline {
utility.SetPreviewFileResponseHeaders(c.Writer.Header(), meta.ContentType, meta.Ext, meta.Filename)
} else {
utility.SetDownloadFileResponseHeaders(c.Writer.Header(), meta.ContentType, meta.Ext, meta.Filename)
}
// If content type was not resolved, fall back to octet-stream.
contentType := meta.ContentType
if contentType == "" {
contentType = "application/octet-stream"
}
c.Data(http.StatusOK, contentType, blob)
}
// DownloadAttachment GET /api/v1/agents/attachments/<attachment_id>/download
//
// Supports optional ?disposition=inline for browsers that prefer inline
// rendering. Mirrors Python download_attachment() at agent_api.py:2507.
func (h *AgentHandler) DownloadAttachment(c *gin.Context) {
user, code, msg := GetUser(c)
if code != common.CodeSuccess {
@@ -74,44 +133,34 @@ func (h *AgentHandler) DownloadAttachment(c *gin.Context) {
return
}
// Normalize the ext query once. A blank or dotted input like
// `?ext=` or `?ext=.pdf` would otherwise produce a malformed
// MIME type like `application/` or `application/.pdf`. Trim
// whitespace, lowercase, strip any leading dot, then fall back
// to markdown when the value is empty.
ext := strings.ToLower(strings.TrimSpace(c.DefaultQuery("ext", "markdown")))
ext = strings.TrimPrefix(ext, ".")
if ext == "" {
ext = "markdown"
}
// IDOR note: the Go User struct collapses user/tenant into one
// identifier (same model as the python download_agent_file
// endpoint at agent_api.py:523-530). The python attachment
// endpoint relies on the storage bucket's tenant scoping for
// authorisation. The Go port preserves that shape.
if h.fileService == nil {
jsonError(c, common.CodeServerError, "file service not configured")
return
}
blob, err := h.fileService.DownloadAgentFile(user.ID, attachmentID)
if err != nil {
// Mirror agent_download.go error mapping — DAO/transport
// errors collapse to a generic 102 so we don't leak storage
// internals in the response body.
jsonError(c, common.CodeDataError, "Attachment not found!")
return
}
contentType := utility.CONTENT_TYPE_MAP[ext]
if contentType == "" {
// Fallback for unknown extensions — keep the wire shape
// consistent with the python handler.
contentType = "application/" + ext
}
c.Header("Content-Disposition", fmt.Sprintf(
`attachment; filename="%s"; filename*=UTF-8''%s`,
safe, url.PathEscape(safe),
))
c.Data(http.StatusOK, contentType, blob)
// Support ?disposition=inline for optional inline viewing.
inline := strings.ToLower(strings.TrimSpace(c.Query("disposition"))) == "inline"
h.streamAgentAttachment(c, user.ID, attachmentID, inline)
}
// PreviewAttachment GET /api/v1/agents/attachments/<attachment_id>/preview
//
// Returns the attachment with Content-Disposition: inline for safe types
// (PDF, images, Markdown, etc.) and forces attachment for dangerous types
// (HTML, SVG, XML). This is the endpoint used by MCP clients and the
// preview_url generated by DocGenerator. Mirrors Python preview_attachment()
// at agent_api.py:2496.
func (h *AgentHandler) PreviewAttachment(c *gin.Context) {
user, code, msg := GetUser(c)
if code != common.CodeSuccess {
jsonError(c, code, msg)
return
}
attachmentID := c.Param("attachment_id")
if attachmentID == "" {
jsonError(c, common.CodeArgumentError, "`attachment_id` is required.")
return
}
safe := filepath.Base(attachmentID)
if safe == "" || safe == "." || safe == "/" || strings.ContainsAny(safe, "\r\n\"") {
jsonError(c, common.CodeArgumentError, "invalid attachment id.")
return
}
h.streamAgentAttachment(c, user.ID, attachmentID, true)
}

View File

@@ -307,14 +307,12 @@ func (h *DocumentHandler) GetDocumentPreview(c *gin.Context) {
}
ext := utility.GetFileExtension(preview.FileName)
if preview.ContentType != "" {
c.Header("Content-Type", preview.ContentType)
}
if utility.ShouldForceAttachment(ext, preview.ContentType) {
c.Header("X-Content-Type-Options", "nosniff")
c.Header("Content-Disposition", "attachment")
}
// Use the shared preview-headers helper so that safe types get
// Content-Disposition: inline with filename, while dangerous
// types (HTML, SVG, XML) fall back to forced attachment with
// nosniff. Mirrors Python document_api.py:2063 which calls
// apply_preview_file_response_headers() with the document name.
utility.SetPreviewFileResponseHeaders(c.Writer.Header(), preview.ContentType, ext, preview.FileName)
c.Data(http.StatusOK, preview.ContentType, preview.Data)
}

View File

@@ -59,6 +59,7 @@ func RegisterAgentRoutes(g *gin.RouterGroup, h *handler.AgentHandler) {
// File operations.
g.GET("/download", h.DownloadAgentFile)
g.GET("/attachments/:attachment_id/download", h.DownloadAttachment)
g.GET("/attachments/:attachment_id/preview", h.PreviewAttachment)
g.POST("/:canvas_id/upload", h.UploadAgentFile)
// Component introspection + debug.

View File

@@ -17,6 +17,9 @@
package utility
import (
"fmt"
"net/http"
"net/url"
"path/filepath"
"regexp"
"strings"
@@ -266,13 +269,22 @@ var FORCE_ATTACHMENT_CONTENT_TYPES = map[string]bool{
"multipart/related": true,
}
// stripContentTypeParams strips "; charset=..." and similar parameters
// from a content type string. Mirrors Python's .split(";")[0].strip().
func stripContentTypeParams(ct string) string {
if before, _, found := strings.Cut(ct, ";"); found {
return strings.TrimSpace(before)
}
return strings.TrimSpace(ct)
}
// ShouldForceAttachment determines if the file should be forced as attachment
func ShouldForceAttachment(ext string, contentType string) bool {
normalizedExt := strings.ToLower(strings.TrimPrefix(ext, "."))
if normalizedExt != "" && FORCE_ATTACHMENT_EXTENSIONS[normalizedExt] {
return true
}
normalizedType := strings.ToLower(contentType)
normalizedType := strings.ToLower(stripContentTypeParams(contentType))
return FORCE_ATTACHMENT_CONTENT_TYPES[normalizedType]
}
@@ -292,3 +304,129 @@ func GetContentType(ext string, fileType string) string {
}
return fallbackPrefix + "/" + normalizedExt
}
// SanitizeContentDispositionFilename sanitizes a filename for use in
// Content-Disposition headers. Strips non-ASCII, path separators,
// control characters, and quotes/percent signs. Falls back to "file"
// when the result is empty. Mirrors Python file_response.py:
// sanitize_content_disposition_filename().
func SanitizeContentDispositionFilename(filename string) string {
if filename == "" {
return "file"
}
// Strip non-ASCII.
var asciiOnly strings.Builder
for _, r := range filename {
if r < 0x80 {
asciiOnly.WriteRune(r)
}
}
sanitized := asciiOnly.String()
// Replace path separators, special chars.
sanitized = strings.ReplaceAll(sanitized, "/", "_")
sanitized = strings.ReplaceAll(sanitized, "\\", "_")
sanitized = strings.ReplaceAll(sanitized, ":", "_")
sanitized = strings.ReplaceAll(sanitized, "\"", "")
sanitized = strings.ReplaceAll(sanitized, "'", "")
sanitized = strings.ReplaceAll(sanitized, "%", "")
// Strip remaining control characters.
ctrlRe := regexp.MustCompile(`[\x00-\x1f\x7f]`)
sanitized = ctrlRe.ReplaceAllString(sanitized, "")
sanitized = strings.TrimSpace(sanitized)
if sanitized == "" {
return "file"
}
return sanitized
}
// ResolveAttachmentContentType resolves a content type and extension from
// query-parameter values. When mimeType is non-empty it is preferred and
// reverse-looked up in CONTENT_TYPE_MAP to also resolve the extension;
// otherwise the extension is looked up in CONTENT_TYPE_MAP, falling back
// to "application/<ext>". Returns (contentType, ext). Mirrors Python
// file_response.py: resolve_attachment_content_type().
func ResolveAttachmentContentType(ext string, mimeType string) (string, string) {
ext = strings.ToLower(strings.TrimSpace(ext))
ext = strings.TrimPrefix(ext, ".")
mimeType = strings.TrimSpace(mimeType)
contentType := ""
if mimeType != "" {
normalizedType := strings.ToLower(stripContentTypeParams(mimeType))
contentType = normalizedType
// Reverse-lookup extension from CONTENT_TYPE_MAP only when
// no explicit ext was provided. Never overwrite a caller-
// supplied extension (e.g. ext=svg&mime_type=image/png must
// stay ext=svg for the force-attachment check).
if ext == "" {
for knownExt, knownType := range CONTENT_TYPE_MAP {
if knownType == normalizedType {
ext = knownExt
break
}
}
}
} else if ext != "" {
if ct, ok := CONTENT_TYPE_MAP[ext]; ok {
contentType = ct
} else {
contentType = "application/" + ext
}
}
return contentType, ext
}
// SetPreviewFileResponseHeaders sets response headers for inline file
// preview. For force-attachment types (HTML, SVG, XML) it falls back to
// attachment disposition with nosniff. Mirrors Python file_response.py:
// apply_preview_file_response_headers().
func SetPreviewFileResponseHeaders(h http.Header, contentType, ext, filename string) {
if contentType != "" {
h.Set("Content-Type", contentType)
}
if ShouldForceAttachment(ext, contentType) {
h.Set("Content-Disposition", "attachment")
h.Set("X-Content-Type-Options", "nosniff")
} else {
safe := SanitizeContentDispositionFilename(filename)
h.Set("Content-Disposition", fmt.Sprintf(`inline; filename="%s"`, safe))
}
}
// SetDownloadFileResponseHeaders sets response headers for file download
// (always attachment disposition). Mirrors Python file_response.py:
// apply_download_file_response_headers().
func SetDownloadFileResponseHeaders(h http.Header, contentType, ext, filename string) {
if contentType != "" {
h.Set("Content-Type", contentType)
}
if ShouldForceAttachment(ext, contentType) {
h.Set("X-Content-Type-Options", "nosniff")
h.Set("Content-Disposition", "attachment")
return
}
safe := SanitizeContentDispositionFilename(filename)
h.Set("Content-Disposition", fmt.Sprintf(`attachment; filename="%s"`, safe))
}
// AgentAttachmentPreviewPath builds the preview URL path for an agent
// attachment. Query parameters ext and mime_type are URL-encoded when
// provided. Mirrors Python file_response.py:
// agent_attachment_preview_path().
func AgentAttachmentPreviewPath(attachmentID, ext, mimeType string) string {
path := "/api/v1/agents/attachments/" + attachmentID + "/preview"
params := url.Values{}
if ext != "" {
params.Set("ext", ext)
}
if mimeType != "" {
params.Set("mime_type", mimeType)
}
if qs := params.Encode(); qs != "" {
return path + "?" + qs
}
return path
}