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>
This commit is contained in:
Zhichang Yu
2026-06-27 16:52:21 +08:00
committed by GitHub
parent 5b09910d52
commit 5d7f0fda2b
20 changed files with 2786 additions and 102 deletions

View File

@@ -391,9 +391,15 @@ func (h *AgentHandler) RunAgent(c *gin.Context) {
c.Writer.Header().Set("Content-Type", "text/event-stream")
c.Writer.Header().Set("Cache-Control", "no-cache")
c.Writer.Header().Set("Connection", "keep-alive")
flusher, _ := c.Writer.(http.Flusher)
for ev := range events {
writeRunEventSSE(c.Writer, flusher, ev)
if err := service.WriteChatbotRunEvent(c.Writer, ev); err != nil {
common.Debug("agent run: client disconnected",
zap.String("canvas_id", canvasID),
zap.String("session_id", sessionID),
zap.Error(err),
)
return
}
}
}
@@ -423,31 +429,6 @@ func readUserInput(c *gin.Context) string {
return c.Query("user_input")
}
// writeRunEventSSE writes one canvas.RunEvent as an SSE frame in the
// Python envelope format (same as writeChatCompletionSSE):
//
// data:{"event":"<ev.Type>","message_id":"...","created_at":...,"task_id":"...","session_id":"...","data":<ev.Data>}
//
// The "done" type emits `data: [DONE]\n\n`.
func writeRunEventSSE(w io.Writer, flusher http.Flusher, ev canvas.RunEvent) {
if ev.Type == "done" {
fmt.Fprintf(w, "data: [DONE]\n\n")
if flusher != nil {
flusher.Flush()
}
return
}
data := ev.Data
if data == "" {
data = "{}"
}
envelope := sseEnvelope(ev.Type, ev.MessageID, ev.CreatedAt, ev.TaskID, ev.SessionID, data)
fmt.Fprintf(w, "data: %s\n\n", envelope)
if flusher != nil {
flusher.Flush()
}
}
// sanitiseRunEventError passes through the error event payload
// unchanged. The runner serialises canvas.ErrorEvent ({"message": ...})
// before push, so when the payload round-trips through JSON the
@@ -1066,17 +1047,19 @@ func (h *AgentHandler) AgentChatCompletions(c *gin.Context) {
c.Writer.Header().Set("Content-Type", "text/event-stream")
c.Writer.Header().Set("Cache-Control", "no-cache")
c.Writer.Header().Set("Connection", "keep-alive")
flusher, _ := c.Writer.(http.Flusher)
// SSE wire format mirrors Python's `completion()` at
// api/db/services/canvas_service.py:368: each canvas event is one
// `data: <json>\n\n` frame, and the channel close is signalled by
// `data: [DONE]\n\n`. We do NOT emit an `event:` line — the
// front-end's `use-send-message.ts` parser feeds each `data:` line
// directly into JSON.parse and breaks on the `e` of `event:`
// (browser console: "SyntaxError: Unexpected token 'e', \"event:
// mes\"…"). The richer `writeRunEventSSE` helper still owns the
// /api/v1/agents/{id}/run endpoint's wire format — see
// writeRunEventSSE at agent.go for that path.
// SSE wire format is the unified python envelope used by both
// /api/v1/agents/chat/completions and /api/v1/agentbots/<id>/completions.
// One frame per canvas event, all routed through
// service.WriteChatbotRunEvent so the two paths share one writer
// and one shape — see internal/service/bot_completion.go for the
// frame definition. The same unified envelope is used by the
// /api/v1/agents/{canvas_id}/run and /api/v1/agentbots/<id>/completions
// endpoints, all going through service.WriteChatbotRunEvent. The
// channel close is signalled by `data: [DONE]\n\n`. We do NOT emit
// an SSE `event:` line — the front-end's `use-send-message.ts`
// parser feeds each `data:` line directly into JSON.parse and
// breaks on the `e` of `event:` (browser console: "SyntaxError:
// Unexpected token 'e', \"event: mes\"…").
for ev := range events {
common.Debug("agent chat completions: streaming event",
zap.String("agent_id", req.AgentID),
@@ -1085,7 +1068,13 @@ func (h *AgentHandler) AgentChatCompletions(c *gin.Context) {
zap.String("message_id", ev.MessageID),
zap.String("task_id", ev.TaskID),
)
writeChatCompletionSSE(c.Writer, flusher, req.AgentID, ev)
if err := service.WriteChatbotRunEvent(c.Writer, ev); err != nil {
common.Debug("agent chat completions: client disconnected",
zap.String("agent_id", req.AgentID),
zap.Error(err),
)
return
}
}
common.Debug("agent chat completions: stream closed",
zap.String("agent_id", req.AgentID),
@@ -1093,53 +1082,6 @@ func (h *AgentHandler) AgentChatCompletions(c *gin.Context) {
)
}
// writeChatCompletionSSE emits one canvas.RunEvent in the
// Python-shaped chat-completion SSE envelope:
//
// data:{"event":"<ev.Type>","message_id":"<ev.MessageID>","created_at":<ev.CreatedAt>,"task_id":"<ev.TaskID>","session_id":"<ev.SessionID>","data":<ev.Data>}
//
// The special "done" type sends `data: [DONE]\n\n` (no JSON envelope).
func writeChatCompletionSSE(w io.Writer, flusher http.Flusher, agentID string, ev canvas.RunEvent) {
if ev.Type == "done" {
common.Debug("agent chat completions: writing done sentinel",
zap.String("agent_id", agentID),
zap.String("session_id", ev.SessionID),
zap.String("task_id", ev.TaskID),
)
fmt.Fprint(w, "data: [DONE]\n\n")
if flusher != nil {
flusher.Flush()
}
return
}
data := ev.Data
if data == "" {
data = "{}"
}
common.Debug("agent chat completions: writing sse frame",
zap.String("agent_id", agentID),
zap.String("event_type", ev.Type),
zap.String("message_id", ev.MessageID),
zap.String("session_id", ev.SessionID),
zap.String("task_id", ev.TaskID),
)
envelope := sseEnvelope(ev.Type, ev.MessageID, ev.CreatedAt, ev.TaskID, ev.SessionID, data)
fmt.Fprintf(w, "data: %s\n\n", envelope)
if flusher != nil {
flusher.Flush()
}
}
// sseEnvelope builds the Python-shaped SSE JSON payload:
//
// {"event":"<typ>","message_id":"<mid>","created_at":<ts>,"task_id":"<tid>","session_id":"<sid>","data":<raw>}
func sseEnvelope(typ, mid string, ts int64, tid, sid, rawData string) string {
return fmt.Sprintf(
`{"event":%q,"message_id":%q,"created_at":%d,"task_id":%q,"session_id":%q,"data":%s}`,
typ, mid, ts, tid, sid, rawData,
)
}
// RerunAgent POST /api/v1/agents/rerun — requires id, dsl, and
// component_id. The Python agent API uses PipelineOperationLogService
// and the dataflow queue, none of which the Go port has implemented

View File

@@ -0,0 +1,117 @@
//
// 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.
//
// Gap D — `GET /api/v1/agents/attachments/<attachment_id>/download`
// (Python api/apps/restful_apis/agent_api.py:2368).
//
// Mirrors the python download_agent_attachment handler:
// - auth via @login_required → GetUser
// - reads `attachment_id` from the URL path (NOT a query string)
// - default `ext` query parameter is "markdown"
// - uses utility.CONTENT_TYPE_MAP to pick the content type, falling
// back to "application/<ext>" for unknown extensions
// - streams raw bytes back with a sanitized Content-Disposition
package handler
import (
"fmt"
"net/http"
"net/url"
"path/filepath"
"strings"
"github.com/gin-gonic/gin"
"ragflow/internal/common"
"ragflow/internal/utility"
)
// agentAttachmentFileService is the subset of FileService used by
// the attachment-download handler.
type agentAttachmentFileService interface {
DownloadAgentFile(tenantID, location string) ([]byte, error)
}
// DownloadAttachment GET /api/v1/agents/attachments/<attachment_id>/download
func (h *AgentHandler) DownloadAttachment(c *gin.Context) {
user, code, msg := GetUser(c)
if code != common.CodeSuccess {
jsonError(c, code, msg)
return
}
attachmentID := c.Param("attachment_id")
if attachmentID == "" {
jsonError(c, common.CodeArgumentError, "`attachment_id` is required.")
return
}
// Note (review F9): the plan explicitly defers attachment-id
// shape validation to the storage layer. The python download
// endpoint at api/apps/restful_apis/agent_api.py:2368 and the
// existing Go DownloadAgentFile path rely on storage lookup +
// header sanitization; we DO NOT gate on UUID here because
// attachment IDs in storage are not guaranteed UUIDs and the
// review found no evidence of a UUID invariant. The
// filepath.Base + CR/LF/quote check below is the only defensive
// layer and runs BEFORE the file-service call so an unsafe id
// never crosses the service boundary.
safe := filepath.Base(attachmentID)
if safe == "" || safe == "." || safe == "/" || strings.ContainsAny(safe, "\r\n\"") {
jsonError(c, common.CodeArgumentError, "invalid attachment id.")
return
}
// Normalize the ext query once. A blank or dotted input like
// `?ext=` or `?ext=.pdf` would otherwise produce a malformed
// MIME type like `application/` or `application/.pdf`. Trim
// whitespace, lowercase, strip any leading dot, then fall back
// to markdown when the value is empty.
ext := strings.ToLower(strings.TrimSpace(c.DefaultQuery("ext", "markdown")))
ext = strings.TrimPrefix(ext, ".")
if ext == "" {
ext = "markdown"
}
// IDOR note: the Go User struct collapses user/tenant into one
// identifier (same model as the python download_agent_file
// endpoint at agent_api.py:523-530). The python attachment
// endpoint relies on the storage bucket's tenant scoping for
// authorisation. The Go port preserves that shape.
if h.fileService == nil {
jsonError(c, common.CodeServerError, "file service not configured")
return
}
blob, err := h.fileService.DownloadAgentFile(user.ID, attachmentID)
if err != nil {
// Mirror agent_download.go error mapping — DAO/transport
// errors collapse to a generic 102 so we don't leak storage
// internals in the response body.
jsonError(c, common.CodeDataError, "Attachment not found!")
return
}
contentType := utility.CONTENT_TYPE_MAP[ext]
if contentType == "" {
// Fallback for unknown extensions — keep the wire shape
// consistent with the python handler.
contentType = "application/" + ext
}
c.Header("Content-Disposition", fmt.Sprintf(
`attachment; filename="%s"; filename*=UTF-8''%s`,
safe, url.PathEscape(safe),
))
c.Data(http.StatusOK, contentType, blob)
}

View File

@@ -729,9 +729,11 @@ func (s *stubChatRunner) RunAgent(_ context.Context, _, _, _, _ string, _ any) (
// TestAgentChatCompletions_StreamSetsContentType covers the SSE
// path: the handler streams canvas.RunEvent frames as
// `data: {...}\n\n` with a trailing `data: [DONE]\n\n` terminator,
// matching the Python `completion()` wire format in
// api/db/services/canvas_service.py:368.
// `data: {...}\n\n` with a trailing `data: [DONE]\n\n` terminator.
// The frame shape is the unified python envelope
// {code:0, message:"", data:{answer, reference, audio_binary, id,
// session_id}} — the same shape /api/v1/agentbots/<id>/completions
// emits. See service.WriteChatbotRunEvent and WriteChatbotFrame.
//
// The stubChatRunner emits one `message` frame and one `done` frame
// so the test verifies the body contains both the framed event and
@@ -757,8 +759,12 @@ func TestAgentChatCompletions_StreamSetsContentType(t *testing.T) {
t.Errorf("Content-Type = %q, want text/event-stream", got)
}
body := w.Body.String()
if !strings.Contains(body, "\"event\":\"message\"") || !strings.Contains(body, "\"answer\":\"hi back\"") {
t.Errorf("body should contain framed message event, got %q", body)
// Body must contain the unified python envelope (`code/data.answer`)
// and the [DONE] terminator. The iframe SDK JSON.parse()s `answer`
// to extract the inner fields, so the embedded JSON is double-encoded
// (escaped quotes inside the outer `"answer"` string).
if !strings.Contains(body, "\"code\":0") || !strings.Contains(body, `"answer":"{\"answer\":\"hi back\",\"reference\":[]}"`) {
t.Errorf("body should contain unified python envelope with answer, got %q", body)
}
if !strings.HasSuffix(body, "data: [DONE]\n\n") {
t.Errorf("body should end with [DONE] terminator, got %q", body)
@@ -768,9 +774,9 @@ func TestAgentChatCompletions_StreamSetsContentType(t *testing.T) {
// TestAgentChatCompletions_DefaultBranchStreamsSSE covers the
// scenario the user actually hit: `openai-compatible: false` with no
// `stream` field on the body. The handler must still invoke the
// canvas runner and stream the result as SSE — matching Python's
// `completion()` which always yields SSE on the non-openai path
// regardless of the stream flag.
// canvas runner and stream the result as SSE — the SSE envelope is
// the unified python shape shared with
// /api/v1/agentbots/<id>/completions regardless of the stream flag.
func TestAgentChatCompletions_DefaultBranchStreamsSSE(t *testing.T) {
gin.SetMode(gin.TestMode)
w := httptest.NewRecorder()
@@ -792,8 +798,8 @@ func TestAgentChatCompletions_DefaultBranchStreamsSSE(t *testing.T) {
t.Errorf("Content-Type = %q, want text/event-stream (default branch must stream)", got)
}
body := w.Body.String()
if !strings.Contains(body, "\"event\":\"message\"") || !strings.Contains(body, "\"answer\":\"hello back\"") {
t.Errorf("body should contain framed message event, got %q", body)
if !strings.Contains(body, "\"code\":0") || !strings.Contains(body, `"answer":"{\"answer\":\"hello back\",\"reference\":[]}"`) {
t.Errorf("body should contain unified python envelope with answer, got %q", body)
}
if !strings.HasSuffix(body, "data: [DONE]\n\n") {
t.Errorf("body should end with [DONE] terminator, got %q", body)

View File

@@ -20,6 +20,7 @@ import (
"fmt"
"net/http"
"ragflow/internal/common"
"ragflow/internal/entity"
"ragflow/internal/server/local"
"ragflow/internal/service"
@@ -28,7 +29,17 @@ import (
// AuthHandler auth handler
type AuthHandler struct {
userService *service.UserService
userService userTokenResolver
}
// userTokenResolver is the subset of UserService the auth
// middleware actually depends on. We keep it as a small interface
// so the test suite can swap in a stub without spinning up the
// full UserService (which requires a live Redis + JWT secret).
type userTokenResolver interface {
GetUserByToken(authorization string) (*entity.User, common.ErrorCode, error)
GetUserByAPIToken(token string) (*entity.User, common.ErrorCode, error)
GetUserByBetaAPIToken(token string) (*entity.User, common.ErrorCode, error)
}
// NewAuthHandler create auth handler
@@ -38,6 +49,50 @@ func NewAuthHandler() *AuthHandler {
}
}
// BetaAuthMiddleware resolves a `beta` API token from the Authorization
// header and sets the user on the gin.Context, mirroring Python's
// @login_required(auth_types=AUTH_BETA) used by /chatbots and
// /agentbots route groups.
//
// A beta token can also be a regular user JWT — in that case we
// delegate to the existing AuthMiddleware logic. Order of precedence:
//
// 1. JWT (regular session) → existing UserService.GetUserByToken
// 2. Beta API token → GetUserByBetaAPIToken
// 3. Fall through → 401
//
// IMPORTANT: the regular-user branch is NOT gated on a "Bearer "
// prefix. UserService.GetUserByToken accepts the raw Authorization
// header value and ExtractAccessToken handles Bearer stripping
// internally. The existing AuthMiddleware() above also passes the
// raw header to GetUserByToken without pre-filtering, so a non-Bearer
// regular user token must keep working here too.
func (h *AuthHandler) BetaAuthMiddleware() gin.HandlerFunc {
return func(c *gin.Context) {
auth := c.GetHeader("Authorization")
if auth == "" {
jsonError(c, common.CodeUnauthorized, "Authorization required")
c.Abort()
return
}
// Try regular user session first (handles JWT, Bearer, or
// raw access_token — same dispatch as AuthMiddleware()).
if u, code, err := h.userService.GetUserByToken(auth); err == nil && code == common.CodeSuccess {
c.Set("user", u)
c.Next()
return
}
// Fall back to beta API token (public bot access).
if u, code, err := h.userService.GetUserByBetaAPIToken(auth); err == nil && code == common.CodeSuccess {
c.Set("user", u)
c.Next()
return
}
jsonError(c, common.CodeUnauthorized, "Invalid auth credentials")
c.Abort()
}
}
// AuthMiddleware JWT auth middleware
// Validates that the user is authenticated and is a superuser (admin)
func (h *AuthHandler) AuthMiddleware() gin.HandlerFunc {

View File

@@ -0,0 +1,66 @@
//
// 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 handler
import (
"encoding/json"
"net/http"
"net/http/httptest"
"testing"
"github.com/gin-gonic/gin"
"ragflow/internal/common"
)
// TestBetaAuthMiddleware_MissingHeader pins the no-header branch —
// the middleware must short-circuit with 401/CodeUnauthorized and
// must not call into UserService. The other branches (regular JWT
// and beta token) require a live DB to resolve, so they are covered
// by the cross-cutting TestBotRoutes_RequireAuth criterion in
// bot_test.go.
func TestBetaAuthMiddleware_MissingHeader(t *testing.T) {
gin.SetMode(gin.TestMode)
ah := &AuthHandler{userService: nil}
rec := httptest.NewRecorder()
c, _ := gin.CreateTestContext(rec)
c.Request = httptest.NewRequest(http.MethodGet, "/", nil)
mw := ah.BetaAuthMiddleware()
mw(c)
if !c.IsAborted() {
t.Fatalf("context not aborted, want aborted (no Authorization header)")
}
if rec.Code != http.StatusOK {
t.Errorf("status = %d, want %d", rec.Code, http.StatusOK)
}
// jsonError writes 200 with a CodeUnauthorized body. Confirm the
// body shape matches the wire contract used by the rest of the
// bot handlers by decoding the JSON envelope and asserting the
// code field rather than just checking for a non-empty body.
var resp struct {
Code common.ErrorCode `json:"code"`
}
if err := json.Unmarshal(rec.Body.Bytes(), &resp); err != nil {
t.Fatalf("unmarshal response: %v; body = %s", err, rec.Body.String())
}
if resp.Code != common.CodeUnauthorized {
t.Errorf("code = %d, want %d; body = %s",
resp.Code, common.CodeUnauthorized, rec.Body.String())
}
}

260
internal/handler/bot.go Normal file
View File

@@ -0,0 +1,260 @@
//
// 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 handler
import (
"context"
"github.com/gin-gonic/gin"
"ragflow/internal/agent/canvas"
"ragflow/internal/common"
"ragflow/internal/service"
)
// BotHandler is the handler for the public chatbot/agentbot
// endpoints mounted on /api/v1/chatbots/* and /api/v1/agentbots/*.
// The two route groups share BetaAuthMiddleware (set up at
// registration time via g.Use(mw)) and share the same handler
// struct because they are wired to the same BotService.
type BotHandler struct {
botService botService
}
// botService is the subset of BotService used by these handlers. It
// is interface-typed so the test suite can inject a stub.
type botService interface {
ChatbotInfo(ctx context.Context, tenantID, dialogID string) (
title, avatar, prologue, llmID string, hasTavilyKey bool, ec common.ErrorCode, err error)
AgentbotInputs(ctx context.Context, tenantID, agentID string) (
title, avatar, prologue, mode string, inputs map[string]any,
ec common.ErrorCode, err error)
AgentbotCompletion(ctx context.Context, tenantID, agentID string, req service.AgentbotCompletionRequest) (
<-chan canvas.RunEvent, common.ErrorCode, error)
ChatbotCompletion(ctx context.Context, tenantID, dialogID string, req service.ChatbotCompletionRequest) (
<-chan service.ChatbotSSEFrame, common.ErrorCode, error)
}
// NewBotHandler wires a BotHandler with the production BotService.
func NewBotHandler(svc *service.BotService) *BotHandler {
return &BotHandler{botService: svc}
}
// ChatbotInfo GET /api/v1/chatbots/<dialog_id>/info
//
// Mirrors python bot_api.py:126-154. Returns the public metadata of
// a chatbot dialog (title, avatar, prologue, tavily key flag, llm_id).
func (h *BotHandler) ChatbotInfo(c *gin.Context) {
user, code, msg := GetUser(c)
if code != common.CodeSuccess {
jsonError(c, code, msg)
return
}
dialogID := c.Param("dialog_id")
if dialogID == "" {
jsonError(c, common.CodeArgumentError, "`dialog_id` is required.")
return
}
title, avatar, prologue, llmID, hasTavily, ec, err := h.botService.ChatbotInfo(
c.Request.Context(), user.ID, dialogID)
if err != nil {
jsonError(c, ec, err.Error())
return
}
c.JSON(200, gin.H{
"code": common.CodeSuccess,
"data": gin.H{
"title": title,
"avatar": avatar,
"prologue": prologue,
"has_tavily_key": hasTavily,
"llm_id": llmID,
},
"message": "success",
})
}
// AgentbotInputs GET /api/v1/agentbots/<agent_id>/inputs
//
// Mirrors python bot_api.py:239-250. Returns the public metadata of
// an agentbot canvas (title, avatar, inputs, prologue, mode).
func (h *BotHandler) AgentbotInputs(c *gin.Context) {
user, code, msg := GetUser(c)
if code != common.CodeSuccess {
jsonError(c, code, msg)
return
}
agentID := c.Param("agent_id")
if agentID == "" {
jsonError(c, common.CodeArgumentError, "`agent_id` is required.")
return
}
title, avatar, prologue, mode, inputs, ec, err := h.botService.AgentbotInputs(
c.Request.Context(), user.ID, agentID)
if err != nil {
jsonError(c, ec, err.Error())
return
}
c.JSON(200, gin.H{
"code": common.CodeSuccess,
"data": gin.H{
"title": title,
"avatar": avatar,
"inputs": inputs,
"prologue": prologue,
"mode": mode,
},
"message": "success",
})
}
// AgentbotCompletion POST /api/v1/agentbots/<agent_id>/completions
//
// Mirrors python bot_api.py:157 (canvas_service.completion wrapper).
// Streams SSE frames in the Python envelope shape. The URL-bound
// agent_id is authoritative — the body must NOT override it.
//
// Each canvas.RunEvent is re-formatted into the Python
// {code, message, data} envelope: a "message" event's Data string is
// treated as the assistant text, "message_end" terminates the
// stream with the python completion marker.
func (h *BotHandler) AgentbotCompletion(c *gin.Context) {
user, code, msg := GetUser(c)
if code != common.CodeSuccess {
jsonError(c, code, msg)
return
}
agentID := c.Param("agent_id")
if agentID == "" {
jsonError(c, common.CodeArgumentError, "`agent_id` is required.")
return
}
var body service.AgentbotCompletionRequest
// ContentLength != 0 (not > 0) so chunked requests carrying a
// valid JSON body with ContentLength == -1 still bind. The old
// `> 0` guard silently dropped those payloads and the canvas
// then ran with empty inputs.
if c.Request.ContentLength != 0 {
if err := c.ShouldBindJSON(&body); err != nil {
jsonError(c, common.CodeArgumentError, "Invalid request: "+err.Error())
return
}
}
events, ec, err := h.botService.AgentbotCompletion(
c.Request.Context(), user.ID, agentID, body)
if err != nil {
jsonError(c, ec, err.Error())
return
}
c.Writer.Header().Set("Content-Type", "text/event-stream")
c.Writer.Header().Set("Cache-Control", "no-cache")
c.Writer.Header().Set("Connection", "keep-alive")
for ev := range events {
switch ev.Type {
case "message":
// The python iframe_completion wrapper flattens each
// message chunk into a {code:0, data:{answer:...}}
// frame. We forward the message Data as the assistant
// text payload so the iframe SDK's `data.answer`
// parser keeps working. The agentbot path uses
// WriteAgentbotFrame (a thin alias for
// WriteChatbotFrame) to keep the two paths visually
// distinct in the handler.
frame := service.ChatbotSSEFrame{
Data: ev.Data,
Reference: map[string]any{},
SessionID: ev.SessionID,
}
if err := service.WriteAgentbotFrame(c.Writer, frame); err != nil {
return
}
case "message_end", "done":
// Terminator events. message_end occasionally carries
// a final payload (e.g. structured output); forward
// it as a final answer frame when present, then close
// the stream with the standard python completion
// marker. A bare `done` event closes the stream
// directly.
if ev.Data != "" {
frame := service.ChatbotSSEFrame{
Data: ev.Data,
Reference: map[string]any{},
SessionID: ev.SessionID,
}
_ = service.WriteAgentbotFrame(c.Writer, frame)
}
_ = service.WriteDoneFrame(c.Writer)
return
default:
// Non-message events (node_started, node_finished, …)
// are silently dropped on the agentbot path. The
// python canvas_service.completion wrapper only
// forwards the assistant text frames, not the run
// telemetry; we mirror that behaviour so external
// widgets see the same wire shape.
}
}
}
// ChatbotCompletion POST /api/v1/chatbots/<dialog_id>/completions
//
// Mirrors python bot_api.py:55 (async_iframe_completion). Streams
// SSE frames in the Python envelope shape. The streaming helper
// lives in service/bot_completion.go.
func (h *BotHandler) ChatbotCompletion(c *gin.Context) {
user, code, msg := GetUser(c)
if code != common.CodeSuccess {
jsonError(c, code, msg)
return
}
dialogID := c.Param("dialog_id")
if dialogID == "" {
jsonError(c, common.CodeArgumentError, "`dialog_id` is required.")
return
}
var body service.ChatbotCompletionRequest
// ContentLength != 0 (not > 0) so chunked requests carrying a
// valid JSON body with ContentLength == -1 still bind. The old
// `> 0` guard silently dropped those payloads and the chatbot
// then ran with empty session_id/question.
if c.Request.ContentLength != 0 {
if err := c.ShouldBindJSON(&body); err != nil {
jsonError(c, common.CodeArgumentError, "Invalid request: "+err.Error())
return
}
}
frames, ec, err := h.botService.ChatbotCompletion(
c.Request.Context(), user.ID, dialogID, body)
if err != nil {
jsonError(c, ec, err.Error())
return
}
c.Writer.Header().Set("Content-Type", "text/event-stream")
c.Writer.Header().Set("Cache-Control", "no-cache")
c.Writer.Header().Set("Connection", "keep-alive")
for f := range frames {
if f.Done {
if err := service.WriteDoneFrame(c.Writer); err != nil {
return
}
continue
}
if err := service.WriteChatbotFrame(c.Writer, f); err != nil {
return
}
}
}

1081
internal/handler/bot_test.go Normal file

File diff suppressed because it is too large Load Diff