Files
ragflow/internal/service/bot.go
Zhichang Yu dfe2dc346d feat[Go]: port agent attachment download, chatbot + agentbot completion/info endpoints from Python (#16405)
## Summary

Ports five Python agent APIs to Go under the v1 Gin router:

- `GET  /api/v1/agents/attachments/<attachment_id>/download`
- `POST /api/v1/chatbots/<dialog_id>/completions`  (SSE)
- `GET  /api/v1/chatbots/<dialog_id>/info`
- `POST /api/v1/agentbots/<agent_id>/completions` (SSE)
- `GET  /api/v1/agentbots/<agent_id>/inputs`

Mirrors the existing Python wire shape (`{code, message,
data:{answer,reference,...}}` per Python `canvas_service.completion`) so
the iframe SDK and existing JS widgets keep working.

## Behavioural parity with Python

| # | Concern | How it's met |
|---|---------|--------------|
| R0 | Bot routes must not require regular user session | Routes mount
on `apiNoAuth` (router.go:198-202), with `BetaAuthMiddleware` only |
| R3 | Two SSE formats in Go drift | F2: `AgentChatCompletions` and
`AgentbotCompletion` share `service.WriteChatbotRunEvent` |
| R7 | `GetBySessionID` returns `(nil, nil)` on miss | Defensive
nil-check before `session.UserID != tenantID` |
| R8 | Begin component name vs ID | `FindBeginComponentID` resolves name
→ ID first, then `ExtractComponentInputForm(dsl, beginID)` |
| R9 | Defensive PromptConfig parsing | `stringFromMap` helper used for
`prologue` and `tavily_api_key` |
| R10 | `BetaAuthMiddleware` Bearer-prefix pre-filter | Removed —
`GetUserByToken` is called unconditionally, falls back to
`GetUserByBetaAPIToken` |
| F8 | Multi-turn chatbot history | `ChatbotCompletion` reads prior
turns from `session.Message`, appends user turn, calls LLM, persists new
pair via new `API4ConversationDAO.Update` |
| F9 | UUID gate stricter than plan | Removed — only `filepath.Base` +
CR/LF/quote header sanitization remains |
| H2 | Defence-in-depth IDOR | `AgentbotCompletion` calls `loadCanvas`
before delegating to `RunAgent` |
| M2 | SSE error leakage | `WriteChatbotFrame` emits generic `"an
internal error occurred"`; real error logged via `common.Error` |

## Verification

```bash
$ go vet ./...                                     # clean (only pre-existing issues)
$ go build ./...                                   # success
$ go test ./internal/handler/ ./internal/service/ ./internal/agent/dsl/ ./internal/common/ ./internal/dao/
ok  ragflow/internal/handler     0.617s
ok  ragflow/internal/service     1.729s
ok  ragflow/internal/agent/dsl   0.008s
ok  ragflow/internal/common      0.087s
ok  ragflow/internal/dao         0.083s
```

1199 tests pass across 5 packages.

## Known follow-ups (out of scope for this PR)

- **F1**: token-level streaming in `ChatbotCompletion` (currently emits
one frame per turn)
- **F3**: per-route `auth_types` attribute in Go (currently applied via
route group middleware)

---------

Co-authored-by: Claude <noreply@anthropic.com>
2026-06-29 09:45:16 +08:00

260 lines
9.8 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.
//
// BotService is the shared service layer for the public
// chatbot/agentbot endpoints (api/v1/chatbots/...,
// api/v1/agentbots/...) plus the agent attachment download. It is
// intentionally a thin aggregator — it sequences DAO lookups, the
// tenant/status authorisation guard, and delegates the heavy work
// (LLM call, canvas run) to the existing services.
package service
import (
"context"
"errors"
"fmt"
"strings"
"ragflow/internal/agent/canvas"
"ragflow/internal/agent/dsl"
"ragflow/internal/common"
"ragflow/internal/dao"
"ragflow/internal/entity"
)
// BotService coordinates chatbot + agentbot reads and the matching
// completion paths. Mirrors the Python
// `api/db/services/conversation_service.py::async_iframe_completion`
// + `api/db/services/canvas_service.py::completion` flow but stays
// stateless — it does not own the LLM or canvas runner; it just
// sequences them.
type BotService struct {
chatDAO *dao.ChatSessionDAO
canvasDAO *dao.UserCanvasDAO
api4ConversationDAO *dao.API4ConversationDAO
agentService *AgentService
llmService *LLMService
}
// NewBotService wires a fresh BotService. agentSvc is required for
// AgentbotCompletion; llmSvc is required for ChatbotCompletion (in
// step 6). Both are nullable in unit tests.
func NewBotService(agentSvc *AgentService, llmSvc *LLMService) *BotService {
return &BotService{
chatDAO: dao.NewChatSessionDAO(),
canvasDAO: dao.NewUserCanvasDAO(),
api4ConversationDAO: dao.NewAPI4ConversationDAO(),
agentService: agentSvc,
llmService: llmSvc,
}
}
// ChatbotInfo returns the public metadata of a chatbot dialog.
//
// Mirrors the python `bot_api.py::chatbot_info` handler. The
// authorisation check is: dialog must exist, the requester must own
// it (TenantID match), and Status must equal common.StatusDialogValid
// (the python StatusEnum.VALID.value).
func (s *BotService) ChatbotInfo(ctx context.Context, tenantID, dialogID string) (
title, avatar, prologue, llmID string, hasTavilyKey bool, ec common.ErrorCode, err error,
) {
dialog, err := s.chatDAO.GetDialogByID(dialogID)
if err != nil {
return "", "", "", "", false, common.CodeDataError, err
}
if dialog == nil || dialog.TenantID != tenantID ||
dialog.Status == nil || *dialog.Status != common.StatusDialogValid {
return "", "", "", "", false, common.CodeDataError,
errors.New("Authentication error: no access to this chatbot!")
}
pc := dialog.PromptConfig
// Defensive lookups mirroring python's
// dialog.prompt_config.get("prologue", "") and
// dialog.prompt_config.get("tavily_api_key", "").strip()
// semantics. A hard type assertion here would panic on a missing
// or non-string prologue field — this endpoint is public over
// persisted JSON config and the schema is not guaranteed.
prologue = stringFromMap(pc, "prologue")
tk := stringFromMap(pc, "tavily_api_key")
return botDerefStr(dialog.Name), botDerefStr(dialog.Icon), prologue,
dialog.LLMID, strings.TrimSpace(tk) != "", common.CodeSuccess, nil
}
// AgentbotInputs returns the public metadata of an agentbot canvas.
//
// Mirrors the python `bot_api.py::agentbot_inputs` handler. The
// authorisation check is the same IDOR guard the production
// AgentService uses (canvas must be visible to the requesting user).
func (s *BotService) AgentbotInputs(ctx context.Context, tenantID, agentID string) (
title, avatar, prologue, mode string, inputs map[string]any,
ec common.ErrorCode, err error,
) {
cv, err := s.loadCanvas(ctx, tenantID, agentID)
if err != nil {
return "", "", "", "", nil, common.CodeDataError, err
}
dslMap := canvasDSLMap(cv)
// Resolve the begin component ID first, then pass that ID to
// ExtractComponentInputForm. ExtractComponentInputForm is keyed
// by component ID, NOT component name — passing the literal
// "begin" would only succeed when the canvas happens to use
// "begin" as the component ID.
beginID, idErr := dsl.FindBeginComponentID(dslMap)
if idErr != nil {
// No begin component (or malformed DSL). Degrade gracefully —
// empty prologue / mode / inputs, matching the Python
// behaviour when Canvas.get_component_input_form returns an
// empty dict.
return botDerefStr(cv.Title), botDerefStr(cv.Avatar), "", "", nil, common.CodeSuccess, nil
}
inputs, _ = dsl.ExtractComponentInputForm(dslMap, beginID)
prologue, _ = dsl.ExtractPrologue(dslMap)
mode, _ = dsl.ExtractMode(dslMap)
return botDerefStr(cv.Title), botDerefStr(cv.Avatar), prologue, mode, inputs, common.CodeSuccess, nil
}
// AgentbotCompletion is a thin wrapper around AgentService.RunAgent
// for the /api/v1/agentbots/<agent_id>/completions endpoint.
//
// Defence-in-depth (security H2): the IDOR guard runs BEFORE the
// delegate so an unauthorised caller can never trigger canvas
// compile/invoke (which would spend LLM tokens + emit canvas
// telemetry even for "not found" paths). RunAgent re-runs the
// same guard internally — this is intentional; the upstream check
// is the cheap fast-fail that costs a single DAO roundtrip
// instead of a full canvas compile.
func (s *BotService) AgentbotCompletion(
ctx context.Context, tenantID, agentID string, req AgentbotCompletionRequest,
) (<-chan canvas.RunEvent, common.ErrorCode, error) {
if s.agentService == nil {
return nil, common.CodeServerError, fmt.Errorf("bot: agent service not wired")
}
if _, err := s.loadCanvas(ctx, tenantID, agentID); err != nil {
return nil, common.CodeDataError, err
}
// Compose the canvas user input from req.UserInput (the
// `inputs` dict body field) plus the top-level `question` and
// `files` fields. The python canvas_service.completion at
// api/db/services/canvas_service.py:313 reads all three; the
// previous code dropped question/files, so a body like
// `{"question":"hi"}` reached the canvas with empty inputs.
userInput := make(map[string]any, len(req.UserInput)+2)
for k, v := range req.UserInput {
userInput[k] = v
}
if req.Question != "" {
userInput["question"] = req.Question
}
if len(req.Files) > 0 {
userInput["files"] = req.Files
}
ch, err := s.agentService.RunAgent(ctx, tenantID, agentID,
req.SessionID, "", userInput)
if err != nil {
return nil, common.CodeDataError, err
}
return ch, common.CodeSuccess, nil
}
// AgentbotCompletionRequest is the request body for
// /api/v1/agentbots/<agent_id>/completions. We intentionally accept
// the same fields the production /agents/chat/completions handler
// accepts; the URL-bound agent_id is the authoritative canvas id
// (matches python bot_api.py:159).
type AgentbotCompletionRequest struct {
SessionID string `json:"session_id"`
UserID string `json:"user_id"`
Stream bool `json:"stream"`
// UserInput is the dict-shaped root input the canvas run expects
// (mirrors the python "question"/"files"/"inputs" trio collapsed
// into one map).
UserInput map[string]any `json:"inputs"`
Question string `json:"question"`
Files []string `json:"files"`
}
// ChatbotCompletionRequest is the request body for
// /api/v1/chatbots/<dialog_id>/completions. Mirrors the python
// `async_iframe_completion` body shape (session_id, question,
// tts (unused) and a freeform dict).
type ChatbotCompletionRequest struct {
SessionID string `json:"session_id"`
Question string `json:"question"`
Stream bool `json:"stream"`
Inputs map[string]any `json:"inputs"`
}
// loadCanvas is the IDOR guard for agentbot reads. It mirrors the
// private loadCanvasForUser helper on AgentService without taking a
// dependency on the agentService pointer (so BotService can be unit-
// tested with a nil agentService).
func (s *BotService) loadCanvas(ctx context.Context, tenantID, agentID string) (*entity.UserCanvas, error) {
if agentID == "" {
return nil, dao.ErrUserCanvasNotFound
}
if tenantID == "" {
return nil, dao.ErrUserCanvasNotFound
}
userTenantDAO := dao.NewUserTenantDAO()
tenants, err := userTenantDAO.GetTenantIDsByUserID(tenantID)
if err != nil {
return nil, fmt.Errorf("bot: tenants for user %s: %w", tenantID, err)
}
return s.canvasDAO.GetByIDForUser(agentID, tenantID, tenants)
}
// canvasDSLMap projects a UserCanvas.DSL JSONMap into a
// map[string]any. Returns an empty map (not nil) on miss so
// downstream dsl helpers can still scan it.
func canvasDSLMap(cv *entity.UserCanvas) map[string]any {
if cv == nil {
return map[string]any{}
}
// cv.DSL is entity.JSONMap (alias for map[string]interface{}).
// We must return a fresh map[string]any because the dsl
// helpers expect that concrete type.
return map[string]any(cv.DSL)
}
// botDerefStr returns *s or "" if nil. Used to read pointer-string
// fields on entities (Name, Icon, Title, Avatar). Prefixed with bot
// to avoid colliding with the test-only botDerefStr in
// openai_chat_test.go.
func botDerefStr(s *string) string {
if s == nil {
return ""
}
return *s
}
// stringFromMap returns m[key] as a string. Returns "" if the key is
// absent or the value is not a string. Used for defensive reads
// over JSONMap-shaped fields (dialog.prompt_config) where a hard
// type assertion would panic.
func stringFromMap(m entity.JSONMap, key string) string {
if m == nil {
return ""
}
v, ok := m[key]
if !ok || v == nil {
return ""
}
if s, ok := v.(string); ok {
return s
}
return ""
}