feat(kol): add SSE streaming for prompt assist
- Extract assist runtime (prompt building, response parsing, finalize) from the worker into kol_assist_runtime so the sync streaming path and the async task path share the same logic. - Add POST /kol/manage/assist/stream as a Server-Sent Events endpoint emitting start/delta/completed/error events, and wire it to a streamAssist helper on kolManageApi that reports ApiClientError with the server's error envelope. - Stream generated content live in KolPromptEditor and KolGenerateView, switching KolPromptEditArea to a boolean aiBusy flag and refining the generator page layout. - Add KolAssistStreamEvent to shared types. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -1,9 +1,12 @@
|
||||
package transport
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"io"
|
||||
"net/http"
|
||||
"strconv"
|
||||
"strings"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
|
||||
@@ -21,6 +24,17 @@ type KolManageHandler struct {
|
||||
assets *AssetHandler
|
||||
}
|
||||
|
||||
type kolAssistStreamEvent struct {
|
||||
Mode string `json:"mode,omitempty"`
|
||||
Status string `json:"status"`
|
||||
Delta string `json:"delta,omitempty"`
|
||||
Content string `json:"content,omitempty"`
|
||||
Schema *app.KolSchemaJSON `json:"schema,omitempty"`
|
||||
Error string `json:"error,omitempty"`
|
||||
Model string `json:"model,omitempty"`
|
||||
Done bool `json:"done"`
|
||||
}
|
||||
|
||||
func NewKolManageHandler(a *bootstrap.App, assets *AssetHandler) *KolManageHandler {
|
||||
profileSvc := app.NewKolProfileService(a.KolProfiles).WithObjectStorage(a.ObjectStorage)
|
||||
|
||||
@@ -460,6 +474,92 @@ func (h *KolManageHandler) AssistSubmit(c *gin.Context) {
|
||||
response.Success(c, app.KolAssistSubmitResponse{TaskID: taskID})
|
||||
}
|
||||
|
||||
func (h *KolManageHandler) AssistStream(c *gin.Context) {
|
||||
var req app.AssistRequest
|
||||
if err := c.ShouldBindJSON(&req); err != nil {
|
||||
response.Error(c, response.ErrBadRequest(40001, "invalid_params", err.Error()))
|
||||
return
|
||||
}
|
||||
|
||||
actor, ok := actorFromRequest(c)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
|
||||
c.Header("Content-Type", "text/event-stream")
|
||||
c.Header("Cache-Control", "no-cache")
|
||||
c.Header("Connection", "keep-alive")
|
||||
c.Header("X-Accel-Buffering", "no")
|
||||
|
||||
flusher, ok := c.Writer.(http.Flusher)
|
||||
if !ok {
|
||||
response.Error(c, response.ErrInternal(50011, "stream_unsupported", "response writer does not support streaming"))
|
||||
return
|
||||
}
|
||||
|
||||
if err := writeSSE(c, "start", kolAssistStreamEvent{
|
||||
Mode: req.Mode,
|
||||
Status: "generating",
|
||||
Done: false,
|
||||
}); err != nil {
|
||||
return
|
||||
}
|
||||
flusher.Flush()
|
||||
|
||||
var streamed strings.Builder
|
||||
var streamWriteErr error
|
||||
|
||||
result, model, err := h.assistSvc.Run(c.Request.Context(), actor, req, func(delta string) {
|
||||
if delta == "" || streamWriteErr != nil {
|
||||
return
|
||||
}
|
||||
|
||||
streamed.WriteString(delta)
|
||||
if writeErr := writeSSE(c, "delta", kolAssistStreamEvent{
|
||||
Mode: req.Mode,
|
||||
Status: "generating",
|
||||
Delta: delta,
|
||||
Content: streamed.String(),
|
||||
Done: false,
|
||||
}); writeErr != nil {
|
||||
streamWriteErr = writeErr
|
||||
return
|
||||
}
|
||||
flusher.Flush()
|
||||
})
|
||||
if streamWriteErr != nil {
|
||||
return
|
||||
}
|
||||
if err != nil {
|
||||
if errors.Is(c.Request.Context().Err(), context.Canceled) || errors.Is(c.Request.Context().Err(), context.DeadlineExceeded) {
|
||||
return
|
||||
}
|
||||
|
||||
appErr := response.Normalize(err)
|
||||
_ = writeSSE(c, "error", kolAssistStreamEvent{
|
||||
Mode: req.Mode,
|
||||
Status: "failed",
|
||||
Content: strings.TrimSpace(streamed.String()),
|
||||
Error: resolveKolAssistStreamErrorMessage(appErr),
|
||||
Done: true,
|
||||
})
|
||||
flusher.Flush()
|
||||
return
|
||||
}
|
||||
|
||||
if err := writeSSE(c, "completed", kolAssistStreamEvent{
|
||||
Mode: req.Mode,
|
||||
Status: "completed",
|
||||
Content: result.Content,
|
||||
Schema: &result.Schema,
|
||||
Model: model,
|
||||
Done: true,
|
||||
}); err != nil {
|
||||
return
|
||||
}
|
||||
flusher.Flush()
|
||||
}
|
||||
|
||||
func (h *KolManageHandler) AssistGet(c *gin.Context) {
|
||||
taskID := c.Param("id")
|
||||
if taskID == "" {
|
||||
@@ -504,3 +604,16 @@ func parseInt64Param(c *gin.Context, key, label string) (int64, bool) {
|
||||
}
|
||||
return id, true
|
||||
}
|
||||
|
||||
func resolveKolAssistStreamErrorMessage(appErr *response.AppError) string {
|
||||
if appErr == nil {
|
||||
return "AI 处理失败,请稍后重试"
|
||||
}
|
||||
if detail := strings.TrimSpace(appErr.Detail); detail != "" {
|
||||
return detail
|
||||
}
|
||||
if message := strings.TrimSpace(appErr.Message); message != "" {
|
||||
return message
|
||||
}
|
||||
return "AI 处理失败,请稍后重试"
|
||||
}
|
||||
|
||||
@@ -77,6 +77,7 @@ func RegisterRoutes(app *bootstrap.App) {
|
||||
kolManage.PUT("/prompts/:id/activate", kolManageHandler.ActivatePrompt)
|
||||
kolManage.PUT("/prompts/:id/archive", kolManageHandler.ArchivePrompt)
|
||||
kolManage.POST("/assist", kolManageHandler.AssistSubmit)
|
||||
kolManage.POST("/assist/stream", kolManageHandler.AssistStream)
|
||||
kolManage.GET("/assist/:id", kolManageHandler.AssistGet)
|
||||
|
||||
kolDash := protected.Group("/tenant/kol/dashboard")
|
||||
|
||||
Reference in New Issue
Block a user