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:
2026-04-18 16:17:11 +08:00
parent 79c65c1da7
commit 4bbce5f083
13 changed files with 1062 additions and 234 deletions
@@ -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")