fix[go]: skip LLM call for shared chatbot session handshake (#17095)

This commit is contained in:
euvre
2026-07-20 15:30:50 +08:00
committed by GitHub
parent b2e88c9933
commit 624b4b03f3
2 changed files with 98 additions and 2 deletions

View File

@@ -264,8 +264,10 @@ func WriteAgentbotFrame(w http.ResponseWriter, f ChatbotSSEFrame) error {
//
// The full LLM session-lifecycle implementation is added below. It
// is a v1 port: it yields a single frame per turn (the Go LLMBundle
// chat call is non-streaming), seeded with the dialog's prologue
// when the request creates a new session.
// chat call is non-streaming). A request without session_id only
// creates the prologue-seeded session and streams the prologue back;
// the LLM runs exclusively for follow-up turns that carry a
// session_id.
//
// Authorisation: dialog must exist, belong to the requester's tenant,
// and have status == common.StatusDialogValid.
@@ -336,6 +338,25 @@ func (s *BotService) ChatbotCompletion(
if err = s.api4ConversationDAO.Create(session); err != nil {
return nil, common.CodeServerError, err
}
// Mirror python async_iframe_completion
// (conversation_service.py:324-334): a request without a
// session_id is the share page's opening handshake — the
// front-end sends an empty question only to obtain a session.
// Persist the prologue-seeded session and stream the prologue
// back WITHOUT invoking the LLM; running the model here would
// fabricate a reply to a message the user never sent.
out := make(chan ChatbotSSEFrame, 2)
go func() {
defer close(out)
out <- ChatbotSSEFrame{
Data: prologue,
Reference: map[string]any{},
SessionID: session.ID,
}
out <- ChatbotSSEFrame{Done: true}
}()
return out, common.CodeSuccess, nil
}
// 3. Resolve the chat LLM via ModelProviderService. The python

View File

@@ -322,6 +322,81 @@ func TestWriteChatbotRunEvent_MessageEventCarriesEvent(t *testing.T) {
}
}
// TestBotService_ChatbotCompletion_NewSessionSkipsLLM locks in the
// share-page handshake behaviour: the front-end opens a shared chat
// with an empty question and no session_id only to obtain a session
// (web/src/pages/next-chats/hooks/use-send-shared-message.ts
// fetchSessionId). Mirrors python async_iframe_completion
// (conversation_service.py:324-334): the server must create the
// prologue-seeded session and stream the prologue back WITHOUT
// invoking the LLM. The previous Go implementation ran the model
// with an empty user turn, so merely opening a share link produced
// a fabricated "you sent a blank message" reply.
func TestBotService_ChatbotCompletion_NewSessionSkipsLLM(t *testing.T) {
db := setupServiceTestDB(t)
if err := db.AutoMigrate(&entity.Chat{}); err != nil {
t.Fatalf("migrate chat: %v", err)
}
pushServiceDB(t, db)
prologue := "你好我是你的AI助手。"
if err := db.Create(&entity.Chat{
ID: "dlg-1",
TenantID: "tenant-1",
Name: sptr("bot"),
LLMSetting: entity.JSONMap{},
PromptType: "simple",
PromptConfig: entity.JSONMap{"prologue": prologue},
KBIDs: entity.JSONSlice{},
Status: sptr(common.StatusDialogValid),
}).Error; err != nil {
t.Fatalf("seed dialog: %v", err)
}
// llmService is nil — any attempt to reach the LLM path would
// fail loudly, so a successful run proves the LLM was skipped.
svc := NewBotService(nil, nil)
frames, code, err := svc.ChatbotCompletion(context.Background(),
"tenant-1", "dlg-1", ChatbotCompletionRequest{Question: ""})
if err != nil {
t.Fatalf("ChatbotCompletion: %v", err)
}
if code != common.CodeSuccess {
t.Fatalf("want code %d, got %d", common.CodeSuccess, code)
}
var got []ChatbotSSEFrame
for f := range frames {
got = append(got, f)
}
if len(got) != 2 {
t.Fatalf("want 2 frames (prologue + done), got %d: %+v", len(got), got)
}
if got[0].Err != nil {
t.Errorf("frame 0 unexpected error: %v", got[0].Err)
}
if got[0].Data != prologue {
t.Errorf("frame 0 data = %q, want prologue %q", got[0].Data, prologue)
}
if got[0].SessionID == "" {
t.Errorf("frame 0 must carry the new session_id")
}
if !got[1].Done {
t.Errorf("frame 1 must be the done marker")
}
// The persisted session must hold exactly the prologue turn —
// no empty user message may be written.
row, derr := dao.NewAPI4ConversationDAO().GetBySessionID(got[0].SessionID, "dlg-1")
if derr != nil || row == nil {
t.Fatalf("session not persisted: row=%v err=%v", row, derr)
}
msgs := historyToMessages(row.Message)
if len(msgs) != 1 || msgs[0].Role != "assistant" || msgs[0].Content != prologue {
t.Errorf("persisted messages = %+v, want single prologue assistant turn", msgs)
}
}
// recordingResponseWriter is a minimal http.ResponseWriter stub
// for SSE frame tests. Tracks writes so the test can assert the
// emitted frame contents.