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
@@ -6,7 +6,6 @@ import (
"errors"
"fmt"
"os"
"strings"
"time"
amqp "github.com/rabbitmq/amqp091-go"
@@ -18,8 +17,6 @@ import (
"github.com/geo-platform/tenant-api/internal/tenant/repository"
)
const kolAssistSystemPrompt = "You are a prompt designer for marketing content. Respond ONLY with JSON: {content: string, schema: {variables: [...]}}"
var errKolAssistConsumerClosed = errors.New("kol assist consumer channel closed")
type KolAssistWorker struct {
@@ -191,24 +188,21 @@ func (w *KolAssistWorker) processTask(ctx context.Context, job tenantapp.KolAssi
return fmt.Errorf("decode kol assist request: %w", err)
}
userPrompt, err := buildKolAssistUserPrompt(req)
generateReq, err := tenantapp.BuildKolAssistGenerateRequest(req)
if err != nil {
return err
}
result, err := w.llm.Generate(ctx, llm.GenerateRequest{
Prompt: fmt.Sprintf("SYSTEM:\n%s\n\nUSER:\n%s", kolAssistSystemPrompt, userPrompt),
ResponseFormat: &llm.ResponseFormat{
Type: llm.ResponseFormatTypeJSONObject,
Name: "kol_assist_response",
Description: "JSON object with content:string and schema:{variables:[]}",
},
}, nil)
result, err := w.llm.Generate(ctx, generateReq, nil)
if err != nil {
return fmt.Errorf("generate kol assist content: %w", err)
}
parsed, err := parseKolAssistResponse(result.Content)
parsed, err := tenantapp.ParseKolAssistResponse(result.Content)
if err != nil {
return err
}
parsed, err = tenantapp.FinalizeKolAssistResult(req, parsed)
if err != nil {
return err
}
@@ -223,44 +217,3 @@ func (w *KolAssistWorker) processTask(ctx context.Context, job tenantapp.KolAssi
}
return nil
}
func buildKolAssistUserPrompt(req tenantapp.AssistRequest) (string, error) {
switch strings.ToLower(strings.TrimSpace(req.Mode)) {
case "generate":
return req.Description, nil
case "optimize":
schemaJSON := "null"
if req.Schema != nil {
payload, err := json.Marshal(req.Schema)
if err != nil {
return "", fmt.Errorf("encode schema: %w", err)
}
schemaJSON = string(payload)
}
return fmt.Sprintf(
"Optimize the following prompt. Keep variable placeholders intact. Current schema: %s\n\nPROMPT:\n%s",
schemaJSON,
req.CurrentContent,
), nil
default:
return "", fmt.Errorf("unsupported assist mode %q", req.Mode)
}
}
func parseKolAssistResponse(content string) (*tenantapp.KolAssistResult, error) {
var result tenantapp.KolAssistResult
if err := json.Unmarshal([]byte(content), &result); err != nil {
return nil, fmt.Errorf("decode kol assist result: %w", err)
}
result.Content = strings.TrimSpace(result.Content)
if result.Content == "" {
return nil, fmt.Errorf("kol assist result content is empty")
}
if result.Schema.Variables == nil {
result.Schema.Variables = []tenantapp.KolVariableDefinition{}
}
if err := tenantapp.ValidateSchema(result.Schema); err != nil {
return nil, fmt.Errorf("invalid kol assist schema: %w", err)
}
return &result, nil
}