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

@@ -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)