mirror of
https://github.com/infiniflow/ragflow.git
synced 2026-07-16 04:37:21 +08:00
Ported retrieval node, added Keenable web search tool - [x] New Feature (non-breaking change which adds functionality)
406 lines
14 KiB
Go
406 lines
14 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.
|
|
//
|
|
|
|
// interrupt_resume.go — eino v0.9.8 interrupt/resume wrappers for the
|
|
// canvas layer.
|
|
//
|
|
// Background (plan §3): the previous "wait for user" mechanism was a
|
|
// sentinel chain (`__wait_for_user__` / `_user_input_provided`) that
|
|
// never actually connected end-to-end — UserFillUpComponent.Invoke did
|
|
// not emit `__wait_for_user__`, so the orchestrator's IsWaitForUser
|
|
// branch never fired. This file replaces the sentinel chain with eino's
|
|
// native interrupt/resume API:
|
|
//
|
|
// - UserFillUpNodeBody — returns a node func that calls
|
|
// compose.Interrupt on first execution and reads the user's input
|
|
// via compose.GetResumeContext on resume.
|
|
// - IsInterruptError / ExtractInterruptContexts — error-side helpers
|
|
// used by the orchestrator Driver to detect a wait-for-user signal
|
|
// and forward it as a `waiting_for_user` SSE event.
|
|
// - BuildInputSpec — extracts the UserFillUp form-field definition
|
|
// from DSL params; this is what we attach to compose.Interrupt's
|
|
// `info` argument so the orchestrator can surface the form schema
|
|
// to the front-end.
|
|
//
|
|
// v0.9.8 API surface used here (file-level diff against v0.9.5 verified
|
|
// identical for these signatures):
|
|
//
|
|
// compose.Interrupt(ctx, info) error
|
|
// compose.GetResumeContext[T any](ctx) (isResumeFlow, hasData bool, data T)
|
|
// compose.ResumeWithData(ctx, interruptID, data) context.Context
|
|
// compose.ExtractInterruptInfo(err) (*InterruptInfo, bool)
|
|
// compose.WithCheckPointID(checkPointID) Option
|
|
// compose.WithInterruptBeforeNodes(nodes) GraphCompileOption
|
|
package canvas
|
|
|
|
import (
|
|
"context"
|
|
"errors"
|
|
"fmt"
|
|
"strings"
|
|
|
|
"github.com/cloudwego/eino/compose"
|
|
)
|
|
|
|
// BuildInputSpec turns the DSL's UserFillUp params into the user-visible
|
|
// info payload that travels with the interrupt signal. The orchestrator
|
|
// Driver reads this from InterruptCtx.Info on the SSE side and ships it
|
|
// to the front-end so the form renderer knows what fields to render.
|
|
//
|
|
// We deliberately keep the schema tiny: enable_tips + tips + an
|
|
// `inputs` map for the field definitions. Anything richer would couple
|
|
// the canvas layer to the component package, which is forbidden (the
|
|
// component package already knows the UserFillUp shape — it owns the
|
|
// form-field schema in userfillup.go; this function only carries the
|
|
// minimum the orchestrator needs to round-trip the form schema without
|
|
// re-reading the DSL).
|
|
func BuildInputSpec(params map[string]any) map[string]any {
|
|
spec := make(map[string]any, 4)
|
|
if params != nil {
|
|
if v, ok := params["inputs"]; ok {
|
|
spec["inputs"] = v
|
|
}
|
|
if v, ok := params["enable_tips"]; ok {
|
|
spec["enable_tips"] = v
|
|
}
|
|
if v, ok := params["tips"]; ok {
|
|
spec["tips"] = v
|
|
}
|
|
}
|
|
spec["kind"] = "user_fill_up" // tag so cancel-vs-wait can be distinguished in Driver
|
|
return spec
|
|
}
|
|
|
|
// UserFillUpNodeBody returns an eino node function implementing
|
|
// "wait for user input" semantics.
|
|
//
|
|
// Flow:
|
|
//
|
|
// - First execution (no resume context): build an inputSpec and call
|
|
// compose.Interrupt, returning the resulting error. The engine
|
|
// catches the interrupt signal, persists a checkpoint, and surfaces
|
|
// the error to the orchestrator (which renders it as a
|
|
// `waiting_for_user` SSE event).
|
|
// - Resumed execution: compose.GetResumeContext returns
|
|
// (true, true, userInput). We emit two output keys: `user_input`
|
|
// (the canonical v1 form-fill output name, mirroring the Python
|
|
// fillup.py:66 contract) and the cpnID key (so downstream nodes can
|
|
// reference `{{user_fill_up_1}}`).
|
|
//
|
|
// Idempotency: the resume branch is the very first thing the node does.
|
|
// Anything we did before the Interrupt call on the first run (we did
|
|
// nothing — no LLM calls, no file writes) cannot be repeated. The
|
|
// "node re-execution from start" risk called out in the plan §5 row 1
|
|
// is therefore a non-issue for UserFillUpNodeBody specifically.
|
|
func UserFillUpNodeBody(cpnID string, params map[string]any) func(ctx context.Context, input map[string]any) (map[string]any, error) {
|
|
inputSpec := BuildInputSpec(params)
|
|
body := func(ctx context.Context, input map[string]any) (map[string]any, error) {
|
|
// Resume branch: the orchestrator decorated ctx with
|
|
// compose.ResumeWithData(ctx, interruptID, userInput).
|
|
// isResumeFlow is true when THIS node is the explicit target;
|
|
// hasData is true when the caller supplied non-nil resume data.
|
|
if isResume, hasData, data := compose.GetResumeContext[any](ctx); isResume && hasData {
|
|
out := buildUserFillUpResumeOutput(cpnID, inputSpec, data)
|
|
out["__cpn_id__"] = cpnID
|
|
return out, nil
|
|
}
|
|
|
|
// Initial-run fast path: match the legacy Python canvas behavior
|
|
// where Begin/UserFillUp consume the current run's inputs
|
|
// directly. We only auto-consume when the node declares form
|
|
// fields; plain wait-for-user nodes (no inputs schema) still
|
|
// interrupt on first execution.
|
|
if data, ok := initialUserFillUpData(ctx, inputSpec); ok {
|
|
out := buildUserFillUpResumeOutput(cpnID, inputSpec, data)
|
|
out["__cpn_id__"] = cpnID
|
|
return out, nil
|
|
}
|
|
|
|
// First-call branch: emit the interrupt signal. The returned
|
|
// error implements error; eino's runner catches it, persists a
|
|
// checkpoint, and bubbles it up.
|
|
if err := compose.Interrupt(ctx, inputSpec); err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
// Unreachable on a healthy eino runner — Interrupt either
|
|
// returns an interrupt error or panics on engine misuse. Keep
|
|
// the guard so test runs without a runner surface a clear
|
|
// message rather than a panic.
|
|
return nil, fmt.Errorf("canvas: UserFillUp %q: interrupt did not halt execution", cpnID)
|
|
}
|
|
return body
|
|
}
|
|
|
|
func buildUserFillUpResumeOutput(cpnID string, inputSpec map[string]any, data any) map[string]any {
|
|
out := map[string]any{
|
|
"user_input": data,
|
|
cpnID: data,
|
|
}
|
|
|
|
fields, _ := inputSpec["inputs"].(map[string]any)
|
|
if _, hasValue := fields["value"]; hasValue {
|
|
out["value"] = data
|
|
}
|
|
if len(fields) == 1 {
|
|
for name := range fields {
|
|
out[name] = data
|
|
}
|
|
return out
|
|
}
|
|
|
|
if values, ok := data.(map[string]any); ok {
|
|
for name := range fields {
|
|
if v, exists := values[name]; exists {
|
|
out[name] = v
|
|
}
|
|
}
|
|
}
|
|
return out
|
|
}
|
|
|
|
func initialUserFillUpData(ctx context.Context, inputSpec map[string]any) (any, bool) {
|
|
fields, _ := inputSpec["inputs"].(map[string]any)
|
|
if len(fields) == 0 {
|
|
return nil, false
|
|
}
|
|
|
|
state, _, err := GetStateFromContext[*CanvasState](ctx)
|
|
if err != nil || state == nil {
|
|
return nil, false
|
|
}
|
|
if consumed, _ := state.Sys["__initial_user_input_consumed__"].(bool); consumed {
|
|
return nil, false
|
|
}
|
|
raw, err := state.GetVar("sys.query")
|
|
if err != nil || raw == nil {
|
|
return nil, false
|
|
}
|
|
if values, ok := raw.(map[string]any); ok {
|
|
state.Sys["__initial_user_input_consumed__"] = true
|
|
return values, true
|
|
}
|
|
text, ok := raw.(string)
|
|
if !ok || text == "" {
|
|
return nil, false
|
|
}
|
|
state.Sys["__initial_user_input_consumed__"] = true
|
|
return text, true
|
|
}
|
|
|
|
// IsInterruptError reports whether err carries an eino interrupt signal.
|
|
//
|
|
// Used by the orchestrator Driver to distinguish wait-for-user from
|
|
// genuine run failures. context.Canceled / context.DeadlineExceeded
|
|
// are explicitly excluded so cancel-timeout paths don't trigger
|
|
// `waiting_for_user` events.
|
|
//
|
|
// Two detection paths cover the surface:
|
|
// - compose.ExtractInterruptInfo matches wrapped forms
|
|
// (`*interruptError` / `*subGraphInterruptError`) — the shapes
|
|
// the eino runner returns after propagating through the engine.
|
|
// - compose.IsInterruptRerunError matches the raw `*core.InterruptSignal`
|
|
// returned by a direct `compose.Interrupt(...)` call. Useful in
|
|
// unit tests that exercise the helper without spinning up a runner.
|
|
func IsInterruptError(err error) bool {
|
|
if err == nil {
|
|
return false
|
|
}
|
|
if errors.Is(err, context.Canceled) || errors.Is(err, context.DeadlineExceeded) {
|
|
return false
|
|
}
|
|
if _, ok := compose.ExtractInterruptInfo(err); ok {
|
|
return true
|
|
}
|
|
if _, ok := compose.IsInterruptRerunError(err); ok {
|
|
return true
|
|
}
|
|
return false
|
|
}
|
|
|
|
// ExtractInterruptContexts walks the error chain and returns every
|
|
// InterruptCtx the engine surfaced. Returns nil if err is not an
|
|
// interrupt error.
|
|
//
|
|
// This handles two wrapping cases that come up in practice:
|
|
//
|
|
// 1. workflowx.AddLoopNode wraps sub-workflow interrupts as
|
|
// ErrLoopSubGraphInterrupted (workflowx/loop.go:122-126). The
|
|
// original interrupt error is reachable via errors.As/Is.
|
|
// 2. Composite interrupts (ToolsNode, parallel branches) carry a
|
|
// list of nested InterruptCtx — we flatten them so the orchestrator
|
|
// sees a single flat list to pick a target from.
|
|
// 3. Raw `*core.InterruptSignal` (the form `compose.Interrupt`
|
|
// returns directly) — handled here so unit tests don't need a
|
|
// full runner. The engine wraps this into `*interruptError` at
|
|
// propagation time, so the wrapped path is the production one.
|
|
//
|
|
// Single-interrupt vs composite: a plain UserFillUp produces one
|
|
// context. The orchestrator currently uses the first; a future phase
|
|
// that wants multi-target resume would iterate.
|
|
func ExtractInterruptContexts(err error) []*compose.InterruptCtx {
|
|
if err == nil {
|
|
return nil
|
|
}
|
|
if info, ok := extractInterruptInfoDeep(err); ok && info != nil {
|
|
ctxs := collectInterruptContexts(info)
|
|
if len(ctxs) > 0 {
|
|
return ctxs
|
|
}
|
|
}
|
|
// Fallback: raw signal. Use the deprecated IsInterruptRerunError
|
|
// helper which gives us (info, state, ok). We don't have access
|
|
// to InterruptCtx here in the raw form (the engine hasn't wrapped
|
|
// the signal yet), so we return nil — callers that care about
|
|
// the context list rely on the wrapped form, which is what
|
|
// production paths see.
|
|
if _, ok := compose.IsInterruptRerunError(err); ok {
|
|
return nil
|
|
}
|
|
return nil
|
|
}
|
|
|
|
func extractInterruptInfoDeep(err error) (*compose.InterruptInfo, bool) {
|
|
if err == nil {
|
|
return nil, false
|
|
}
|
|
if info, ok := compose.ExtractInterruptInfo(err); ok {
|
|
return info, true
|
|
}
|
|
type multiUnwrapper interface {
|
|
Unwrap() []error
|
|
}
|
|
if mw, ok := err.(multiUnwrapper); ok {
|
|
for _, sub := range mw.Unwrap() {
|
|
if info, ok := extractInterruptInfoDeep(sub); ok {
|
|
return info, true
|
|
}
|
|
}
|
|
}
|
|
if unwrapped := errors.Unwrap(err); unwrapped != nil {
|
|
return extractInterruptInfoDeep(unwrapped)
|
|
}
|
|
return nil, false
|
|
}
|
|
|
|
func collectInterruptContexts(info *compose.InterruptInfo) []*compose.InterruptCtx {
|
|
if info == nil {
|
|
return nil
|
|
}
|
|
var out []*compose.InterruptCtx
|
|
out = append(out, info.InterruptContexts...)
|
|
for _, sub := range info.SubGraphs {
|
|
out = append(out, collectInterruptContexts(sub)...)
|
|
}
|
|
return out
|
|
}
|
|
|
|
// FirstInterruptID is a tiny convenience used by the Driver when it
|
|
// picks a single target for the SSE `cpn_id` field. Returns "" when
|
|
// no contexts are present. Keeps the Driver code from doing its own
|
|
// nil-check dance.
|
|
func FirstInterruptID(ctxs []*compose.InterruptCtx) string {
|
|
if ctx := FirstUserFillUpInterrupt(ctxs); ctx != nil {
|
|
return ctx.ID
|
|
}
|
|
if len(ctxs) == 0 {
|
|
return ""
|
|
}
|
|
return ctxs[0].ID
|
|
}
|
|
|
|
// RootInterruptID returns the interrupt id that should be passed to
|
|
// compose.ResumeWithData. In composite/subgraph cases this is the
|
|
// root-cause context, which is not necessarily the same leaf context we
|
|
// want to expose to the front-end as the waiting UserFillUp node.
|
|
func RootInterruptID(ctxs []*compose.InterruptCtx) string {
|
|
for _, ctx := range ctxs {
|
|
for cur := ctx; cur != nil; cur = cur.Parent {
|
|
if cur.IsRootCause {
|
|
return cur.ID
|
|
}
|
|
}
|
|
}
|
|
if len(ctxs) == 0 {
|
|
return ""
|
|
}
|
|
return ctxs[0].ID
|
|
}
|
|
|
|
func FirstUserFillUpInterrupt(ctxs []*compose.InterruptCtx) *compose.InterruptCtx {
|
|
for _, ctx := range ctxs {
|
|
for cur := ctx; cur != nil; cur = cur.Parent {
|
|
if info, ok := cur.Info.(map[string]any); ok {
|
|
if kind, _ := info["kind"].(string); kind == "user_fill_up" {
|
|
return cur
|
|
}
|
|
}
|
|
}
|
|
}
|
|
return nil
|
|
}
|
|
|
|
func formatInterruptContexts(ctxs []*compose.InterruptCtx) string {
|
|
if len(ctxs) == 0 {
|
|
return "[]"
|
|
}
|
|
parts := make([]string, 0, len(ctxs))
|
|
for _, ctx := range ctxs {
|
|
if ctx == nil {
|
|
parts = append(parts, "<nil>")
|
|
continue
|
|
}
|
|
kind := ""
|
|
if info, ok := ctx.Info.(map[string]any); ok {
|
|
kind, _ = info["kind"].(string)
|
|
}
|
|
addr := ctx.Address.String()
|
|
parentAddr := ""
|
|
if ctx.Parent != nil {
|
|
parentAddr = ctx.Parent.Address.String()
|
|
}
|
|
if kind != "" {
|
|
parts = append(parts, fmt.Sprintf("{id:%q kind:%q addr:%q parent:%q}", ctx.ID, kind, addr, parentAddr))
|
|
} else {
|
|
parts = append(parts, fmt.Sprintf("{id:%q info:%T addr:%q parent:%q}", ctx.ID, ctx.Info, addr, parentAddr))
|
|
}
|
|
}
|
|
return "[" + strings.Join(parts, ", ") + "]"
|
|
}
|
|
|
|
// AutoDiscoverUserFillUpIDs returns the cpnIDs of every component whose
|
|
// name (case-insensitive) is UserFillUp. The compiler option
|
|
// compose.WithInterruptBeforeNodes needs a []string; we compute it
|
|
// here so callers don't have to walk the Canvas twice.
|
|
//
|
|
// Centralised here (rather than inlined in compile.go) so any future
|
|
// interrupt-emitting component (e.g. Answer, when ported) can register
|
|
// itself by adding to the switch.
|
|
func AutoDiscoverUserFillUpIDs(c *Canvas) []string {
|
|
if c == nil {
|
|
return nil
|
|
}
|
|
var ids []string
|
|
for cpnID, comp := range c.Components {
|
|
name := strings.ToLower(comp.Obj.ComponentName)
|
|
switch name {
|
|
case "userfillup":
|
|
ids = append(ids, cpnID)
|
|
}
|
|
}
|
|
return ids
|
|
}
|