//
// 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.
//
// Output-format renderer. The Message component exposes a
// `output_format` field that selects between "html", "markdown",
// and "plain" rendering of the resolved content + downloads list.
// This file is the renderer; it stays dependency-free (no
// html/template or blackfriday) and ships in lockstep with the
// message.go scaffold without dragging in new third-party deps.
//
// Render conventions:
// - "html" — wrap in a minimal
+ escape HTML; downloads
// become
filename
// - "markdown" — pass through verbatim; downloads become
// [filename](url) links. Python also passes
// markdown through with light normalization; we
// match that for parity.
// - "plain" — strip HTML tags (best-effort) and present
// downloads as "filename (url)" lines. Default
// when the field is unset, matching Python's
// "no renderer" fallback.
//
// The renderer is intentionally pure (no I/O) so the message
// component can call it inside Stream() chunks without blocking
// on a downstream service.
package component
import (
"html"
"regexp"
"strings"
)
// OutputFormat is the value type of the `output_format` field on
// the Message component. The string constants match the Python
// DSL field values.
type OutputFormat string
const (
OutputFormatHTML OutputFormat = "html"
OutputFormatMarkdown OutputFormat = "markdown"
OutputFormatPlain OutputFormat = "plain"
// OutputFormatEmpty means "no renderer" — content passes through
// as-is. Python's default. The string value differs from "" only
// in the way the user expressed the choice ("none" vs unset).
OutputFormatEmpty OutputFormat = ""
)
// DownloadInfo is the normalized shape of an extracted download
// entry. Mirrors agent/component/message.py:_is_download_info
// (the {doc_id, filename, mime_type} tuple).
type DownloadInfo struct {
DocID string `json:"doc_id"`
Filename string `json:"filename"`
MimeType string `json:"mime_type"`
URL string `json:"url,omitempty"`
Content string `json:"content,omitempty"`
}
// RenderRequest is the renderer input. Text is the resolved
// message body; Downloads is the list of extracted attachment
// descriptors. The renderer is pure — the caller decides where
// the rendered string goes.
type RenderRequest struct {
Format OutputFormat
Text string
Downloads []DownloadInfo
}
// Render applies the format to the request. Unknown formats
// fall back to plain text so downstream nodes always see a
// non-empty string.
func Render(req RenderRequest) string {
format := req.Format
if format == OutputFormatEmpty {
format = OutputFormatPlain
}
body := req.Text
var dlBlock string
if len(req.Downloads) > 0 {
dlBlock = renderDownloads(format, req.Downloads)
}
switch format {
case OutputFormatHTML:
return wrapHTML(body, dlBlock)
case OutputFormatMarkdown:
return joinMarkdown(body, dlBlock)
default:
return joinPlain(body, dlBlock)
}
}
func renderDownloads(format OutputFormat, dls []DownloadInfo) string {
switch format {
case OutputFormatHTML:
var b strings.Builder
b.WriteString(`
")
return b.String()
case OutputFormatMarkdown:
var b strings.Builder
for _, d := range dls {
b.WriteString("- [")
b.WriteString(d.Filename)
b.WriteString("](")
b.WriteString(d.URL)
b.WriteString(")\n")
}
return strings.TrimRight(b.String(), "\n")
default:
var b strings.Builder
for _, d := range dls {
b.WriteString(d.Filename)
b.WriteString(" (")
b.WriteString(d.URL)
b.WriteString(")\n")
}
return strings.TrimRight(b.String(), "\n")
}
}
func wrapHTML(body, dlBlock string) string {
if dlBlock == "" {
return "
" + html.EscapeString(body) + "
"
}
return "
" + html.EscapeString(body) + dlBlock + "
"
}
func joinMarkdown(body, dlBlock string) string {
if dlBlock == "" {
return body
}
return body + "\n\n" + dlBlock
}
func joinPlain(body, dlBlock string) string {
if dlBlock == "" {
return body
}
return body + "\n" + dlBlock
}
// htmlTagRe is the loose "best-effort" HTML stripper used by the
// plain renderer. It removes paired and unpaired tags without
// attempting to keep attribute content. This matches the
// pragmatic behaviour in Python's `_stringify_message_value`
// fallback path: "if markdown → use as-is, if plain → strip tags".
var htmlTagRe = regexp.MustCompile(`<[^>]*>`)
// StripHTMLTags removes HTML tags from s. Used by callers that
// want a "plain" preview of an HTML-rendered body (e.g. console
// logging). Public so the message component can reuse it.
func StripHTMLTags(s string) string {
return strings.TrimSpace(htmlTagRe.ReplaceAllString(s, ""))
}
// IsDownloadInfo mirrors the Python `_is_download_info` static
// method. A value is a download descriptor iff it is a map carrying
// the three canonical keys (doc_id, filename, mime_type). Other
// keys are allowed and ignored.
func IsDownloadInfo(value any) bool {
m, ok := value.(map[string]any)
if !ok {
return false
}
for _, k := range []string{"doc_id", "filename", "mime_type"} {
if _, ok := m[k]; !ok {
return false
}
}
return true
}
// ExtractDownloads walks a single input value (string, map, list)
// and returns the download descriptors it carries. The Python
// version does the same walk recursively on message-value trees;
// we keep the same recursive shape so the Go port's semantics
// match. Returns an empty slice when nothing is found.
func ExtractDownloads(value any) []DownloadInfo {
switch v := value.(type) {
case nil:
return nil
case string:
// A plain string is not a download descriptor. (Python also
// tries json.loads here; we deliberately do not — the
// message component resolves templates into strings, and
// download descriptors are passed through as maps.)
return nil
case map[string]any:
if IsDownloadInfo(v) {
return []DownloadInfo{downloadFromMap(v)}
}
return nil
case []any:
var out []DownloadInfo
for _, item := range v {
out = append(out, ExtractDownloads(item)...)
}
return out
case []DownloadInfo:
return v
}
return nil
}
func downloadFromMap(m map[string]any) DownloadInfo {
d := DownloadInfo{}
if s, ok := m["doc_id"].(string); ok {
d.DocID = s
}
if s, ok := m["filename"].(string); ok {
d.Filename = s
}
if s, ok := m["mime_type"].(string); ok {
d.MimeType = s
}
if s, ok := m["url"].(string); ok {
d.URL = s
}
if s, ok := m["content"].(string); ok {
d.Content = s
}
return d
}