mirror of
https://github.com/infiniflow/ragflow.git
synced 2026-07-06 19:38:36 +08:00
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:
@@ -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.",
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user