mirror of
https://github.com/infiniflow/ragflow.git
synced 2026-07-04 09:39:32 +08:00
fix: agent chat completions can not use (#16570)
### Summary As title <img width="2370" height="2039" alt="image" src="https://github.com/user-attachments/assets/4cccf543-3908-49ee-8101-c5068fbf53ec" />
This commit is contained in:
@@ -93,6 +93,8 @@ type AgentParam struct {
|
||||
BaseURL string
|
||||
}
|
||||
|
||||
const agentUserPromptSchemaDefault = "This is the order you need to send to the agent."
|
||||
|
||||
// AgentMeta declares the OpenAI-style function-call interface for the
|
||||
// Agent component. Mirrors ragflow Python's ToolMeta shape.
|
||||
type AgentMeta struct {
|
||||
@@ -394,6 +396,10 @@ func (c *AgentComponent) Name() string { return "Agent" }
|
||||
// the output map.
|
||||
func (c *AgentComponent) Invoke(ctx context.Context, inputs map[string]any) (map[string]any, error) {
|
||||
p := mergeAgentParam(c.param, inputs)
|
||||
hasRuntimeUserPrompt := false
|
||||
if v, ok := stringFrom(inputs, "user_prompt"); ok {
|
||||
hasRuntimeUserPrompt = !shouldFallbackToSysQuery(v)
|
||||
}
|
||||
|
||||
// v3.6.1: derive the driver and bare model name from the
|
||||
// composite llm_id when the Agent DSL didn't set `driver`. The
|
||||
@@ -418,6 +424,30 @@ func (c *AgentComponent) Invoke(ctx context.Context, inputs map[string]any) (map
|
||||
}
|
||||
p.APIKey, p.BaseURL = resolveTenantLLMConfig(ctx, p.Driver, p.ModelID, p.APIKey, p.BaseURL, originalModelID)
|
||||
|
||||
var state *runtime.CanvasState
|
||||
if s, _, err := runtime.GetStateFromContext[*runtime.CanvasState](ctx); err == nil && s != nil {
|
||||
state = s
|
||||
if resolved, rerr := runtime.ResolveTemplate(p.SystemPrompt, state); resolved != p.SystemPrompt || rerr == nil {
|
||||
p.SystemPrompt = resolved
|
||||
if rerr != nil {
|
||||
common.Debug("agent: resolve system_prompt", zap.Error(rerr))
|
||||
}
|
||||
}
|
||||
if resolved, rerr := runtime.ResolveTemplate(p.UserPrompt, state); resolved != p.UserPrompt || rerr == nil {
|
||||
p.UserPrompt = resolved
|
||||
if rerr != nil {
|
||||
common.Debug("agent: resolve user_prompt", zap.Error(rerr))
|
||||
}
|
||||
}
|
||||
}
|
||||
if hasRuntimeUserPrompt {
|
||||
p.UserPrompt = formatAgentRuntimePrompt(inputs, p.UserPrompt)
|
||||
} else if shouldFallbackToSysQuery(p.UserPrompt) && state != nil {
|
||||
if query, ok := stringFromState(state, "query"); ok {
|
||||
p.UserPrompt = query
|
||||
}
|
||||
}
|
||||
|
||||
if p.ModelID == "" {
|
||||
return nil, &ParamError{Field: "model_id", Reason: "required"}
|
||||
}
|
||||
@@ -586,10 +616,7 @@ func buildAgentChatModel(p AgentParam) (*models.EinoChatModel, error) {
|
||||
if driver == "" {
|
||||
driver = "dummy"
|
||||
}
|
||||
var baseURL map[string]string
|
||||
if p.BaseURL != "" {
|
||||
baseURL = map[string]string{"default": p.BaseURL}
|
||||
}
|
||||
baseURL := baseURLMapForDriver(driver, p.BaseURL)
|
||||
// urlSuffix: see chatURLSuffixFor in llm.go for the rationale.
|
||||
// The factory's NewModelDriver stores URLSuffix verbatim; the
|
||||
// driver then appends URLSuffix.Chat to baseURL to build the
|
||||
@@ -696,6 +723,95 @@ func extractToolCalls(msg *schema.Message) []map[string]any {
|
||||
return calls
|
||||
}
|
||||
|
||||
// promptMessagesFromParams extracts the Python DSL `prompts` list into
|
||||
// the single system/user prompt shape supported by the Go ReAct runner.
|
||||
func promptMessagesFromParams(params map[string]any) (systemPrompt, userPrompt string, ok bool) {
|
||||
raw, exists := params["prompts"]
|
||||
if !exists {
|
||||
return "", "", false
|
||||
}
|
||||
switch v := raw.(type) {
|
||||
case string:
|
||||
return "", v, true
|
||||
case []any:
|
||||
var systems, users []string
|
||||
for _, item := range v {
|
||||
m, ok := item.(map[string]any)
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
content, ok := stringFrom(m, "content")
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
role, _ := stringFrom(m, "role")
|
||||
switch strings.ToLower(strings.TrimSpace(role)) {
|
||||
case "system":
|
||||
systems = append(systems, content)
|
||||
case "user", "":
|
||||
users = append(users, content)
|
||||
}
|
||||
}
|
||||
if len(systems) == 0 && len(users) == 0 {
|
||||
return "", "", false
|
||||
}
|
||||
return strings.Join(systems, "\n"), strings.Join(users, "\n"), true
|
||||
case []map[string]any:
|
||||
items := make([]any, 0, len(v))
|
||||
for _, item := range v {
|
||||
items = append(items, item)
|
||||
}
|
||||
return promptMessagesFromParams(map[string]any{"prompts": items})
|
||||
}
|
||||
return "", "", false
|
||||
}
|
||||
|
||||
func appendPromptText(base, extra string) string {
|
||||
if strings.TrimSpace(extra) == "" {
|
||||
return base
|
||||
}
|
||||
if strings.TrimSpace(base) == "" {
|
||||
return extra
|
||||
}
|
||||
return base + "\n" + extra
|
||||
}
|
||||
|
||||
func hasNonEmptyString(inputs map[string]any, name string) bool {
|
||||
v, ok := stringFrom(inputs, name)
|
||||
return ok && strings.TrimSpace(v) != ""
|
||||
}
|
||||
|
||||
func shouldFallbackToSysQuery(prompt string) bool {
|
||||
p := strings.TrimSpace(prompt)
|
||||
return p == "" || p == agentUserPromptSchemaDefault
|
||||
}
|
||||
|
||||
func stringFromState(state *runtime.CanvasState, name string) (string, bool) {
|
||||
if state == nil {
|
||||
return "", false
|
||||
}
|
||||
v, ok := state.Sys[name].(string)
|
||||
if !ok || strings.TrimSpace(v) == "" {
|
||||
return "", false
|
||||
}
|
||||
return v, true
|
||||
}
|
||||
|
||||
func formatAgentRuntimePrompt(inputs map[string]any, userPrompt string) string {
|
||||
var b strings.Builder
|
||||
if reasoning, ok := stringFrom(inputs, "reasoning"); ok && reasoning != "" {
|
||||
fmt.Fprintf(&b, "\nREASONING:\n%s\n", reasoning)
|
||||
}
|
||||
if contextText, ok := stringFrom(inputs, "context"); ok && contextText != "" {
|
||||
fmt.Fprintf(&b, "\nCONTEXT:\n%s\n", contextText)
|
||||
}
|
||||
if b.Len() == 0 {
|
||||
return userPrompt
|
||||
}
|
||||
fmt.Fprintf(&b, "\nQUERY:\n%s\n", userPrompt)
|
||||
return b.String()
|
||||
}
|
||||
|
||||
// mergeAgentParam layers raw inputs over the receiver's default param set.
|
||||
//
|
||||
// v1 aliases accepted alongside the v2 names: "llm_id" → "model_id",
|
||||
@@ -714,7 +830,13 @@ func mergeAgentParam(base AgentParam, inputs map[string]any) AgentParam {
|
||||
} else if v, ok := stringFrom(inputs, "sys_prompt"); ok {
|
||||
p.SystemPrompt = v
|
||||
}
|
||||
if v, ok := stringFrom(inputs, "user_prompt"); ok {
|
||||
if promptSystem, promptUser, ok := promptMessagesFromParams(inputs); ok {
|
||||
p.SystemPrompt = appendPromptText(p.SystemPrompt, promptSystem)
|
||||
if strings.TrimSpace(promptUser) != "" {
|
||||
p.UserPrompt = promptUser
|
||||
}
|
||||
}
|
||||
if v, ok := stringFrom(inputs, "user_prompt"); ok && !shouldFallbackToSysQuery(v) {
|
||||
p.UserPrompt = v
|
||||
}
|
||||
if v, ok := floatFrom(inputs, "top_p"); ok {
|
||||
@@ -807,7 +929,11 @@ func init() {
|
||||
} else if v, ok := stringFrom(params, "sys_prompt"); ok {
|
||||
p.SystemPrompt = v
|
||||
}
|
||||
if v, ok := stringFrom(params, "user_prompt"); ok {
|
||||
if promptSystem, promptUser, ok := promptMessagesFromParams(params); ok {
|
||||
p.SystemPrompt = appendPromptText(p.SystemPrompt, promptSystem)
|
||||
p.UserPrompt = promptUser
|
||||
}
|
||||
if v, ok := stringFrom(params, "user_prompt"); ok && p.UserPrompt == "" {
|
||||
p.UserPrompt = v
|
||||
}
|
||||
if v, ok := floatFrom(params, "top_p"); ok {
|
||||
|
||||
@@ -28,6 +28,7 @@ import (
|
||||
"github.com/cloudwego/eino/flow/agent/react"
|
||||
"github.com/cloudwego/eino/schema"
|
||||
|
||||
"ragflow/internal/agent/runtime"
|
||||
agenttool "ragflow/internal/agent/tool"
|
||||
)
|
||||
|
||||
@@ -69,6 +70,89 @@ func TestAgent_NoToolsReAct(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestAgent_ResolvesUserPromptFromCanvasState(t *testing.T) {
|
||||
var gotPrompt string
|
||||
withAgentRunner(t, func(_ context.Context, p AgentParam) (*schema.Message, error) {
|
||||
gotPrompt = p.UserPrompt
|
||||
return &schema.Message{Role: schema.Assistant, Content: "ok"}, nil
|
||||
})
|
||||
|
||||
state := runtime.NewCanvasState("run-1", "task-1")
|
||||
state.Sys["query"] = "what is marigold"
|
||||
ctx := runtime.WithState(context.Background(), state)
|
||||
|
||||
c := NewAgentComponent(AgentParam{
|
||||
ModelID: "stub",
|
||||
APIKey: "test-key",
|
||||
UserPrompt: "Question: {sys.query}",
|
||||
MaxRounds: 1,
|
||||
})
|
||||
if _, err := c.Invoke(ctx, nil); err != nil {
|
||||
t.Fatalf("Invoke: %v", err)
|
||||
}
|
||||
if gotPrompt != "Question: what is marigold" {
|
||||
t.Fatalf("runner prompt = %q, want resolved sys.query", gotPrompt)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAgent_UsesPromptsListForSysQuery(t *testing.T) {
|
||||
var gotPrompt string
|
||||
withAgentRunner(t, func(_ context.Context, p AgentParam) (*schema.Message, error) {
|
||||
gotPrompt = p.UserPrompt
|
||||
return &schema.Message{Role: schema.Assistant, Content: "ok"}, nil
|
||||
})
|
||||
|
||||
cmp, err := New("Agent", map[string]any{
|
||||
"model_id": "stub",
|
||||
"api_key": "test-key",
|
||||
"sys_prompt": "act as assistant",
|
||||
"user_prompt": "This is the order you need to send to the agent.",
|
||||
"prompts": []any{
|
||||
map[string]any{"role": "user", "content": "{sys.query}"},
|
||||
},
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("New(Agent): %v", err)
|
||||
}
|
||||
|
||||
state := runtime.NewCanvasState("run-1", "task-1")
|
||||
state.Sys["query"] = "用户真正的问题"
|
||||
ctx := runtime.WithState(context.Background(), state)
|
||||
|
||||
if _, err := cmp.Invoke(ctx, nil); err != nil {
|
||||
t.Fatalf("Invoke: %v", err)
|
||||
}
|
||||
if gotPrompt != "用户真正的问题" {
|
||||
t.Fatalf("runner prompt = %q, want sys.query from prompts list", gotPrompt)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAgent_FormatsRuntimePromptLikePython(t *testing.T) {
|
||||
var gotPrompt string
|
||||
withAgentRunner(t, func(_ context.Context, p AgentParam) (*schema.Message, error) {
|
||||
gotPrompt = p.UserPrompt
|
||||
return &schema.Message{Role: schema.Assistant, Content: "ok"}, nil
|
||||
})
|
||||
|
||||
c := NewAgentComponent(AgentParam{
|
||||
ModelID: "stub",
|
||||
APIKey: "test-key",
|
||||
MaxRounds: 1,
|
||||
})
|
||||
if _, err := c.Invoke(context.Background(), map[string]any{
|
||||
"user_prompt": "write answer",
|
||||
"reasoning": "selected because it can answer",
|
||||
"context": "known facts",
|
||||
}); err != nil {
|
||||
t.Fatalf("Invoke: %v", err)
|
||||
}
|
||||
|
||||
want := "\nREASONING:\nselected because it can answer\n\nCONTEXT:\nknown facts\n\nQUERY:\nwrite answer\n"
|
||||
if gotPrompt != want {
|
||||
t.Fatalf("runner prompt = %q, want %q", gotPrompt, want)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAgent_ToolCallRound(t *testing.T) {
|
||||
var calls int
|
||||
withAgentRunner(t, func(_ context.Context, _ AgentParam) (*schema.Message, error) {
|
||||
|
||||
@@ -218,14 +218,7 @@ func (e *einoChatInvoker) Invoke(ctx context.Context, req ChatInvokeRequest) (*C
|
||||
if driver == "" {
|
||||
driver = "dummy"
|
||||
}
|
||||
// baseURL: drivers consult map["default"] as the canonical endpoint
|
||||
// (see internal/entity/models/base_model.go:GetBaseURL). When the
|
||||
// caller did not override, leave the driver default in place by
|
||||
// passing nil — every driver seeds its own map at construction time.
|
||||
var baseURL map[string]string
|
||||
if req.BaseURL != "" {
|
||||
baseURL = map[string]string{"default": req.BaseURL}
|
||||
}
|
||||
baseURL := baseURLMapForDriver(driver, req.BaseURL)
|
||||
// urlSuffix: each driver appends URLSuffix.Chat to baseURL to form
|
||||
// the chat-completions endpoint (e.g. "chat/completions" for
|
||||
// openai-compatible drivers, "v1/messages" for anthropic). The
|
||||
@@ -333,6 +326,25 @@ func chatURLSuffixFor(driver string) models.URLSuffix {
|
||||
}
|
||||
}
|
||||
|
||||
func baseURLMapForDriver(driver, override string) map[string]string {
|
||||
if override != "" {
|
||||
return map[string]string{"default": override}
|
||||
}
|
||||
pm := models.GetProviderManager()
|
||||
if pm == nil {
|
||||
return nil
|
||||
}
|
||||
provider := pm.FindProvider(driver)
|
||||
if provider == nil || len(provider.URL) == 0 {
|
||||
return nil
|
||||
}
|
||||
baseURL := make(map[string]string, len(provider.URL))
|
||||
for region, url := range provider.URL {
|
||||
baseURL[region] = url
|
||||
}
|
||||
return baseURL
|
||||
}
|
||||
|
||||
// NewLLMComponent builds an LLMComponent from raw params.
|
||||
func NewLLMComponent(p LLMParam) *LLMComponent {
|
||||
return &LLMComponent{param: p}
|
||||
|
||||
@@ -1094,19 +1094,14 @@ 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")
|
||||
// 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`
|
||||
// SSE wire format is the flat Python agent-canvas envelope:
|
||||
// {event,message_id,task_id,session_id,created_at,data}. One
|
||||
// frame is emitted per canvas event 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\"…").
|
||||
// expects the event type in the JSON object's top-level `event`
|
||||
// field.
|
||||
emitted := false
|
||||
for ev := range events {
|
||||
emitted = true
|
||||
|
||||
@@ -730,10 +730,9 @@ 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.
|
||||
// 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 frame shape is the Python agent-canvas envelope
|
||||
// {event,message_id,task_id,session_id,data:{content}}. See
|
||||
// service.WriteChatbotRunEvent.
|
||||
//
|
||||
// The stubChatRunner emits one `message` frame and one `done` frame
|
||||
// so the test verifies the body contains both the framed event and
|
||||
@@ -749,7 +748,7 @@ func TestAgentChatCompletions_StreamSetsContentType(t *testing.T) {
|
||||
c.Set("user_id", "u1")
|
||||
|
||||
runner := &stubChatRunner{events: []canvas.RunEvent{
|
||||
{Type: "message", Data: `{"answer":"hi back","reference":[]}`},
|
||||
{Type: "message", MessageID: "msg-1", TaskID: "task-1", SessionID: "sess-1", Data: `{"content":"hi back","reference":[]}`},
|
||||
{Type: "done", Data: ""},
|
||||
}}
|
||||
h := &AgentHandler{chatRunner: runner}
|
||||
@@ -759,12 +758,12 @@ func TestAgentChatCompletions_StreamSetsContentType(t *testing.T) {
|
||||
t.Errorf("Content-Type = %q, want text/event-stream", got)
|
||||
}
|
||||
body := w.Body.String()
|
||||
// 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.Contains(body, `"event":"message"`) ||
|
||||
!strings.Contains(body, `"message_id":"msg-1"`) ||
|
||||
!strings.Contains(body, `"task_id":"task-1"`) ||
|
||||
!strings.Contains(body, `"session_id":"sess-1"`) ||
|
||||
!strings.Contains(body, `"content":"hi back"`) {
|
||||
t.Errorf("body should contain flat agent event with content, got %q", body)
|
||||
}
|
||||
if !strings.HasSuffix(body, "data: [DONE]\n\n") {
|
||||
t.Errorf("body should end with [DONE] terminator, got %q", body)
|
||||
@@ -775,8 +774,7 @@ func TestAgentChatCompletions_StreamSetsContentType(t *testing.T) {
|
||||
// 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 — the SSE envelope is
|
||||
// the unified python shape shared with
|
||||
// /api/v1/agentbots/<id>/completions regardless of the stream flag.
|
||||
// the flat Python agent-canvas shape regardless of the stream flag.
|
||||
func TestAgentChatCompletions_DefaultBranchStreamsSSE(t *testing.T) {
|
||||
gin.SetMode(gin.TestMode)
|
||||
w := httptest.NewRecorder()
|
||||
@@ -788,7 +786,7 @@ func TestAgentChatCompletions_DefaultBranchStreamsSSE(t *testing.T) {
|
||||
c.Set("user_id", "u1")
|
||||
|
||||
runner := &stubChatRunner{events: []canvas.RunEvent{
|
||||
{Type: "message", Data: `{"answer":"hello back","reference":[]}`},
|
||||
{Type: "message", MessageID: "msg-2", TaskID: "task-2", SessionID: "sess-2", Data: `{"content":"hello back","reference":[]}`},
|
||||
{Type: "done", Data: ""},
|
||||
}}
|
||||
h := &AgentHandler{chatRunner: runner}
|
||||
@@ -798,8 +796,10 @@ 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, "\"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.Contains(body, `"event":"message"`) ||
|
||||
!strings.Contains(body, `"message_id":"msg-2"`) ||
|
||||
!strings.Contains(body, `"content":"hello back"`) {
|
||||
t.Errorf("body should contain flat agent event with content, got %q", body)
|
||||
}
|
||||
if !strings.HasSuffix(body, "data: [DONE]\n\n") {
|
||||
t.Errorf("body should end with [DONE] terminator, got %q", body)
|
||||
|
||||
@@ -154,30 +154,22 @@ func WriteDoneFrame(w http.ResponseWriter) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
// WriteChatbotRunEvent translates one canvas.RunEvent into the
|
||||
// unified python-shaped chat-completion envelope (same shape as
|
||||
// WriteChatbotFrame). This unifies the SSE format across:
|
||||
// WriteChatbotRunEvent translates one canvas.RunEvent into the flat
|
||||
// Python agent-canvas SSE envelope:
|
||||
//
|
||||
// - /api/v1/agents/chat/completions (was: writeChatCompletionSSE)
|
||||
// - /api/v1/agentbots/<id>/completions (was: WriteChatbotFrame per-event)
|
||||
// data: {"event":"message","message_id":"...","task_id":"...",
|
||||
// "session_id":"...","created_at":123,"data":{"content":"..."}}\n\n
|
||||
//
|
||||
// This is intentionally different from WriteChatbotFrame's legacy
|
||||
// chatbot `{code,data:{answer:"..."}}` shape. The agent React page's
|
||||
// use-send-message.ts parser appends each parsed object directly to
|
||||
// answerList and expects top-level `event` / `message_id`, plus a
|
||||
// typed `data` payload. If RunEvent frames are double-wrapped in
|
||||
// data.answer, the browser receives bytes but cannot render the
|
||||
// assistant message or correlate the current Log panel.
|
||||
//
|
||||
// The "done" event type emits `data: [DONE]\n\n` (no envelope),
|
||||
// matching the OpenAI-style terminator and the existing
|
||||
// AgentbotCompletion wire.
|
||||
//
|
||||
// For non-done events, ev.Data is placed verbatim into the `answer`
|
||||
// field — callers pass canvas-runner output that is itself a JSON
|
||||
// string (e.g. `{"answer":"hi back","reference":[]}`); the iframe
|
||||
// SDK then JSON.parse()s the `answer` string to extract the inner
|
||||
// fields. This matches the existing AgentbotCompletion behaviour.
|
||||
//
|
||||
// The event type is forwarded as the `event` field of the envelope
|
||||
// (PR #14589) so the front-end can distinguish interactive
|
||||
// `user_inputs` / `workflow_finished` events from plain `message`
|
||||
// streams and render the UserFillUp form vs the assistant text.
|
||||
// Without this field the form UI never appears because the
|
||||
// iframe SDK has no way to know the canvas paused for human
|
||||
// input.
|
||||
// matching the Python agent API terminator.
|
||||
//
|
||||
// Returns the write error so callers can short-circuit; both nil
|
||||
// and io.ErrClosedPipe are tolerated because the client may have
|
||||
@@ -193,13 +185,65 @@ func WriteChatbotRunEvent(w http.ResponseWriter, ev canvas.RunEvent) error {
|
||||
}
|
||||
return nil
|
||||
}
|
||||
f := ChatbotSSEFrame{
|
||||
Event: ev.Type,
|
||||
Data: ev.Data,
|
||||
Reference: map[string]any{},
|
||||
SessionID: ev.SessionID,
|
||||
|
||||
var data any = map[string]any{}
|
||||
if ev.Data != "" {
|
||||
if err := json.Unmarshal([]byte(ev.Data), &data); err != nil {
|
||||
data = ev.Data
|
||||
}
|
||||
}
|
||||
return WriteChatbotFrame(w, f)
|
||||
if ev.Type == "error" {
|
||||
msg := "an internal error occurred"
|
||||
if m, ok := data.(map[string]any); ok {
|
||||
if s, _ := m["message"].(string); s != "" {
|
||||
msg = s
|
||||
}
|
||||
}
|
||||
payload := map[string]any{
|
||||
"code": 500,
|
||||
"message": msg,
|
||||
"data": false,
|
||||
}
|
||||
return writeSSEJSON(w, payload)
|
||||
}
|
||||
|
||||
payload := map[string]any{
|
||||
"data": data,
|
||||
"created_at": ev.CreatedAt,
|
||||
}
|
||||
if ev.Type != "" {
|
||||
payload["event"] = ev.Type
|
||||
}
|
||||
if ev.MessageID != "" {
|
||||
payload["message_id"] = ev.MessageID
|
||||
}
|
||||
if ev.TaskID != "" {
|
||||
payload["task_id"] = ev.TaskID
|
||||
}
|
||||
if ev.SessionID != "" {
|
||||
payload["session_id"] = ev.SessionID
|
||||
}
|
||||
return writeSSEJSON(w, payload)
|
||||
}
|
||||
|
||||
func writeSSEJSON(w http.ResponseWriter, payload map[string]any) error {
|
||||
b, err := runtime.SafeJSONMarshal(payload)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if _, err := w.Write([]byte("data:")); err != nil {
|
||||
return err
|
||||
}
|
||||
if _, err := w.Write(b); err != nil {
|
||||
return err
|
||||
}
|
||||
if _, err := w.Write([]byte("\n\n")); err != nil {
|
||||
return err
|
||||
}
|
||||
if flusher, ok := w.(http.Flusher); ok {
|
||||
flusher.Flush()
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// AgentbotSSEFrame mirrors ChatbotSSEFrame for the agentbot
|
||||
|
||||
@@ -249,6 +249,8 @@ func TestWriteChatbotRunEvent_UserInputsEvent(t *testing.T) {
|
||||
rec := &recordingResponseWriter{header: http.Header{}}
|
||||
if err := WriteChatbotRunEvent(rec, canvas.RunEvent{
|
||||
Type: "user_inputs",
|
||||
MessageID: "msg-1",
|
||||
TaskID: "task-1",
|
||||
Data: `{"components":[{"id":"email","type":"text","required":true}]}`,
|
||||
SessionID: "sess-1",
|
||||
}); err != nil {
|
||||
@@ -258,9 +260,18 @@ func TestWriteChatbotRunEvent_UserInputsEvent(t *testing.T) {
|
||||
if !strings.Contains(body, `"event":"user_inputs"`) {
|
||||
t.Errorf("body missing event=user_inputs: %s", body)
|
||||
}
|
||||
if !strings.Contains(body, `"message_id":"msg-1"`) {
|
||||
t.Errorf("body missing message_id: %s", body)
|
||||
}
|
||||
if !strings.Contains(body, `"task_id":"task-1"`) {
|
||||
t.Errorf("body missing task_id: %s", body)
|
||||
}
|
||||
if !strings.Contains(body, `"session_id":"sess-1"`) {
|
||||
t.Errorf("body missing session_id: %s", body)
|
||||
}
|
||||
if strings.Contains(body, `"answer":"`) {
|
||||
t.Errorf("body should not wrap run events in data.answer: %s", body)
|
||||
}
|
||||
}
|
||||
|
||||
// TestWriteChatbotRunEvent_WorkflowFinishedEvent covers the second
|
||||
@@ -270,7 +281,7 @@ func TestWriteChatbotRunEvent_WorkflowFinishedEvent(t *testing.T) {
|
||||
rec := &recordingResponseWriter{header: http.Header{}}
|
||||
if err := WriteChatbotRunEvent(rec, canvas.RunEvent{
|
||||
Type: "workflow_finished",
|
||||
Data: `{"answer":"done"}`,
|
||||
Data: `{"outputs":"done"}`,
|
||||
SessionID: "sess-2",
|
||||
}); err != nil {
|
||||
t.Fatalf("WriteChatbotRunEvent: %v", err)
|
||||
@@ -279,6 +290,9 @@ func TestWriteChatbotRunEvent_WorkflowFinishedEvent(t *testing.T) {
|
||||
if !strings.Contains(body, `"event":"workflow_finished"`) {
|
||||
t.Errorf("body missing event=workflow_finished: %s", body)
|
||||
}
|
||||
if !strings.Contains(body, `"outputs":"done"`) {
|
||||
t.Errorf("body missing workflow output payload: %s", body)
|
||||
}
|
||||
}
|
||||
|
||||
// TestWriteChatbotRunEvent_MessageEventCarriesEvent ensures the
|
||||
@@ -290,7 +304,8 @@ func TestWriteChatbotRunEvent_MessageEventCarriesEvent(t *testing.T) {
|
||||
rec := &recordingResponseWriter{header: http.Header{}}
|
||||
if err := WriteChatbotRunEvent(rec, canvas.RunEvent{
|
||||
Type: "message",
|
||||
Data: `{"answer":"hi"}`,
|
||||
MessageID: "msg-3",
|
||||
Data: `{"content":"hi"}`,
|
||||
SessionID: "sess-3",
|
||||
}); err != nil {
|
||||
t.Fatalf("WriteChatbotRunEvent: %v", err)
|
||||
@@ -299,6 +314,12 @@ func TestWriteChatbotRunEvent_MessageEventCarriesEvent(t *testing.T) {
|
||||
if !strings.Contains(body, `"event":"message"`) {
|
||||
t.Errorf("message frame should carry event=message: %s", body)
|
||||
}
|
||||
if !strings.Contains(body, `"message_id":"msg-3"`) {
|
||||
t.Errorf("message frame should carry top-level message_id: %s", body)
|
||||
}
|
||||
if !strings.Contains(body, `"content":"hi"`) {
|
||||
t.Errorf("message frame should carry data.content: %s", body)
|
||||
}
|
||||
}
|
||||
|
||||
// recordingResponseWriter is a minimal http.ResponseWriter stub
|
||||
|
||||
Reference in New Issue
Block a user