Files
ragflow/internal/agent/component/message.go
Zhichang Yu 3fa15c0e2f feat(agent): Go port — canvas engine, 22 components, DSL v2, 13 endpoints (#15952)
Ports the agent canvas subsystem from Python to Go.

## What's included

### Canvas Engine (Phase 0/1)
- State engine, scheduler, variable resolver, Redis checkpoint store,
cancel protocol
- **209 tests** across canvas / component / io packages

### 22 Components (P0–P4)
| Tier | Components |
|---|---|
| P0 T1+T2+T3 | LLM, Agent, ExitLoop, Switch, Categorize, Begin,
Message, Invoke |
| P1 T3 | VariableAggregator, VariableAssigner, StringTransform,
ListOperations, DataOperations |
| P2 T3 | Iteration, IterationItem, Loop, LoopItem |
| P3 T3 | UserFillUp, Fillup |
| P4 T5 | Browser, ExcelProcessor, DocsGenerator |

### DSL v2 Schema (Phase 2.5)
- Typed v2 in-memory model with v1-to-v2 auto-detect converter
- v1 legacy field stripping per plan §2.11.7

### HTTP Endpoints & Bug Fixes (Plans PR1–PR3)
- **DELETE SQL bug fix**: gorm v2 `Where("id = ?", id).Delete(...)`
pattern
- **CreateAgent validation**: title/DSL required, duplicate check, 103
envelope
- **13 new endpoints**: templates, prompts, tags, sessions CRUD,
chat/completions (SSE + non-stream stubs), rerun, test_db_connection,
logs, webhook/logs
- **756 Go unit tests** (745 → 756, +18)
- **17 → 0 Python integration test failures** (test_agents.py +
test_session_management/)

### Tools
21 eino tools: HTTPHelper, search tools, financial/data tools, mandatory
stubs

### Infrastructure
OTel observability, NATS message queue, DeepDoc gRPC client, SSRF
guards, IDOR mitigation
2026-06-12 22:58:28 +08:00

182 lines
6.3 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.
//
// Package component — Message component (T3, plan §2.11.3 row 4).
//
// Message is the canvas terminal output node. It resolves a Jinja2-style
// {{...}} template against the current *CanvasState and (optionally) emits
// the result as a single SSE chunk. Memory persistence and chunked
// streaming are deferred (plan §2.11.6 P0 scope): the P0 implementation
// resolves the template once, returns it as outputs["content"], and
// exposes Stream() that yields one chunk + closes.
package component
import (
"context"
"fmt"
"log"
"maps"
"ragflow/internal/agent/runtime"
)
const componentNameMessage = "Message"
// MessageComponent is the canvas terminal output node. It owns the
// resolved text template as a per-instance field — the factory sets
// it from the DSL params at build time, and Invoke falls back to it
// when the input map does not carry a fresh "text" override.
type MessageComponent struct {
name string
text string
}
// NewMessageComponent constructs a Message component. The params map
// may carry:
//
// - "text" (string) — the canonical v2 name
// - "content" (string | []string | []any) — the v1 name; when a
// list, the first element is taken as the template (matches the
// Python v1 message surface where content is a list of paragraphs)
//
// At least one must produce a non-empty string; otherwise the node
// emits an empty content (it is the canvas terminal, so a runtime
// error would be louder than a missing template).
func NewMessageComponent(params map[string]any) (Component, error) {
tpl := extractMessageText(params)
return &MessageComponent{name: componentNameMessage, text: tpl}, nil
}
// extractMessageText reads text / content from params in the v1 / v2
// order documented on NewMessageComponent. Returns the empty string
// when neither key is present or the value is not a string-shaped
// scalar.
func extractMessageText(params map[string]any) string {
if v, ok := params["text"].(string); ok {
return v
}
if v, ok := params["content"]; ok {
switch x := v.(type) {
case string:
return x
case []string:
if len(x) > 0 {
return x[0]
}
case []any:
if len(x) > 0 {
if s, ok := x[0].(string); ok {
return s
}
}
}
}
return ""
}
// Name returns the registered component name.
func (m *MessageComponent) Name() string { return m.name }
// Invoke resolves inputs["text"] (or the per-instance text seeded
// from params at build time) as a template against the current
// *CanvasState, returns the resolved string at outputs["content"], and
// (if inputs["stream"] == true) records the number of chunks in
// outputs["streamed_chunks"]. Memory persistence (memory_save) is
// logged as deferred to a later phase per the P0 plan.
//
// inputs["text"] takes precedence over the per-instance text so the
// same node can be reused with different templates at run time when
// the orchestrator wants to override the DSL-declared value.
func (m *MessageComponent) Invoke(ctx context.Context, inputs map[string]any) (map[string]any, error) {
state, _, err := runtime.GetStateFromContext[*runtime.CanvasState](ctx)
if err != nil {
return nil, fmt.Errorf("Message: %w", err)
}
if state == nil {
return nil, fmt.Errorf("Message: nil canvas state")
}
text, _ := inputs["text"].(string)
if text == "" {
text = m.text
}
resolved, err := runtime.ResolveTemplate(text, state)
if err != nil {
// ResolveTemplate surfaces unresolved references as errors, but
// the partial output (with empty-string substitutions) is still
// returned so the SSE consumer can choose to log it. Match
// the existing canvas package's contract here.
return nil, fmt.Errorf("Message: template resolve: %w", err)
}
if memSave, _ := inputs["memory_save"].(bool); memSave {
log.Printf("Message: memory_save=true (memory persistence deferred to Phase 2.5)")
}
out := map[string]any{"content": resolved}
if streamOn, _ := inputs["stream"].(bool); streamOn {
// P0: one chunk for the whole resolved content. A later phase
// can split on token / sentence boundaries.
out["streamed_chunks"] = 1
}
return out, nil
}
// Stream is the SSE variant. The P0 implementation produces a single
// chunk containing the resolved content (key "content") and closes the
// channel. A future phase can split the resolved string into multiple
// chunks; for now the contract is "one chunk, then close".
func (m *MessageComponent) Stream(ctx context.Context, inputs map[string]any) (<-chan map[string]any, error) {
out, err := m.Invoke(ctx, inputs)
if err != nil {
return nil, err
}
ch := make(chan map[string]any, 1)
ch <- out
close(ch)
return ch, nil
}
// Inputs returns the public parameter surface. Field types match the
// Python DSL contract (text template, stream toggle, memory_save toggle).
func (m *MessageComponent) Inputs() map[string]string {
return map[string]string{
"text": "Template string with {{...}} references; resolved against the canvas state.",
"stream": "When true, the resolved content is delivered as an SSE stream (P0: one chunk).",
"memory_save": "When true, persist the message to API4Conversation (deferred to Phase 2.5).",
}
}
// Outputs returns the resolved template plus the streamed-chunk counter.
func (m *MessageComponent) Outputs() map[string]string {
return map[string]string{
"content": "Resolved template string (the message text).",
"streamed_chunks": "Number of SSE chunks emitted (present when stream=true).",
}
}
// mapCopy shallow-copies src into a fresh map. Used to keep Message's
// passthrough outputs un-aliased from the caller's inputs map.
func mapCopy(src map[string]any) map[string]any {
out := make(map[string]any, len(src))
maps.Copy(out, src)
return out
}
func init() {
Register(componentNameMessage, NewMessageComponent)
}