Files
ragflow/internal/agent/component/render.go
Zhichang Yu e45659868a feat(agent): ship the Go agent canvas port — eino interrupt/resume + Redis check-pointing (#16035)
Replaces the Python agent canvas runtime with a Go implementation that
runs inside `cmd/server_main`.

The canvas compiles into an eino Workflow that pauses on wait-for-user
via native Interrupt/Resume (no sentinel flag) and resumes from a
Redis-backed CheckPointStore.

All 21 Python agent components and ~35 tools are ported with functional
parity.

Sandbox providers now read their JSON config from the admin-panel
system_settings table with env fallback.

234 files / +35,413 / -6,111. All Go files are gofmt-clean (CI gate
added); drops the v2 DSL E2E step and the gap-analysis plan (both
redundant after the port ships).

## Type of change

- [x] Refactoring
- [x] New feature
- [x] Bug fix

🤖 Generated with [Claude Code](https://claude.com/claude-code)

---------

Co-authored-by: Claude <noreply@anthropic.com>
2026-06-17 13:24:03 +08:00

250 lines
7.5 KiB
Go

//
// 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 <div> + escape HTML; downloads
// become <a href="..." download="...">filename</a>
// - "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(`<ul class="rf-downloads">`)
for _, d := range dls {
b.WriteString(`<li><a href="`)
b.WriteString(html.EscapeString(d.URL))
b.WriteString(`" download="`)
b.WriteString(html.EscapeString(d.Filename))
b.WriteString(`" type="`)
b.WriteString(html.EscapeString(d.MimeType))
b.WriteString(`">`)
b.WriteString(html.EscapeString(d.Filename))
b.WriteString("</a></li>")
}
b.WriteString("</ul>")
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 "<div class=\"rf-message\">" + html.EscapeString(body) + "</div>"
}
return "<div class=\"rf-message\">" + html.EscapeString(body) + dlBlock + "</div>"
}
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
}