feat: add EditorGridSizePicker and EditorSelectionFrame components; implement FreeCreateView for article management; introduce ArticleSelectionOptimizeService for optimizing article selections

This commit is contained in:
2026-04-06 22:18:55 +08:00
parent 08ace0a9e0
commit 1e844704e4
20 changed files with 3033 additions and 740 deletions
@@ -1,10 +1,13 @@
package transport
import (
"context"
"encoding/json"
"errors"
"io"
"net/http"
"strconv"
"strings"
"time"
"github.com/gin-gonic/gin"
@@ -17,6 +20,7 @@ import (
type ArticleHandler struct {
svc *app.ArticleService
selectionOptimize *app.ArticleSelectionOptimizeService
promptGenerateSvc *app.PromptRuleGenerationService
streams *stream.GenerationHub
streamEnabled bool
@@ -39,6 +43,11 @@ func NewArticleHandler(a *bootstrap.App) *ArticleHandler {
return &ArticleHandler{
svc: app.NewArticleService(a.DB, a.AuditLogs, a.ObjectStorage),
selectionOptimize: app.NewArticleSelectionOptimizeService(
a.DB,
a.LLM,
a.Config.LLM.MaxOutputTokens,
),
promptGenerateSvc: app.NewPromptRuleGenerationService(
a.DB,
a.LLM,
@@ -124,6 +133,22 @@ func (h *ArticleHandler) GenerateFromRule(c *gin.Context) {
response.SuccessWithStatus(c, http.StatusCreated, data)
}
func (h *ArticleHandler) Create(c *gin.Context) {
var req app.CreateArticleRequest
if err := c.ShouldBindJSON(&req); err != nil {
// Allow empty body
req = app.CreateArticleRequest{}
}
data, err := h.svc.Create(c.Request.Context(), req)
if err != nil {
response.Error(c, err)
return
}
response.SuccessWithStatus(c, http.StatusCreated, data)
}
func (h *ArticleHandler) Detail(c *gin.Context) {
id, err := strconv.ParseInt(c.Param("id"), 10, 64)
if err != nil {
@@ -210,6 +235,111 @@ func (h *ArticleHandler) UploadImage(c *gin.Context) {
response.SuccessWithStatus(c, http.StatusCreated, data)
}
type articleSelectionOptimizeStreamEvent struct {
ArticleID int64 `json:"article_id"`
Status string `json:"status,omitempty"`
Delta string `json:"delta,omitempty"`
Content string `json:"content,omitempty"`
Error string `json:"error,omitempty"`
Model string `json:"model,omitempty"`
Done bool `json:"done"`
}
func (h *ArticleHandler) OptimizeSelectionStream(c *gin.Context) {
id, err := strconv.ParseInt(c.Param("id"), 10, 64)
if err != nil {
response.Error(c, response.ErrBadRequest(40001, "invalid_id", "article id must be a number"))
return
}
var req app.ArticleSelectionOptimizeRequest
if err := c.ShouldBindJSON(&req); err != nil {
response.Error(c, response.ErrBadRequest(40013, "invalid_payload", err.Error()))
return
}
if err := h.selectionOptimize.ValidateRequest(c.Request.Context(), id, req); err != nil {
response.Error(c, err)
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", articleSelectionOptimizeStreamEvent{
ArticleID: id,
Status: "generating",
Done: false,
}); err != nil {
return
}
flusher.Flush()
streamCtx, cancel := context.WithCancel(c.Request.Context())
defer cancel()
var streamed strings.Builder
var streamWriteErr error
result, err := h.selectionOptimize.OptimizeSelection(streamCtx, req, func(delta string) {
if delta == "" || streamWriteErr != nil {
return
}
streamed.WriteString(delta)
if writeErr := writeSSE(c, "delta", articleSelectionOptimizeStreamEvent{
ArticleID: id,
Status: "generating",
Delta: delta,
Content: streamed.String(),
Done: false,
}); writeErr != nil {
streamWriteErr = writeErr
cancel()
return
}
flusher.Flush()
})
if streamWriteErr != nil {
return
}
if err != nil {
if errors.Is(streamCtx.Err(), context.Canceled) {
return
}
appErr := response.Normalize(err)
_ = writeSSE(c, "error", articleSelectionOptimizeStreamEvent{
ArticleID: id,
Status: "failed",
Content: strings.TrimSpace(streamed.String()),
Error: resolveOptimizeStreamErrorMessage(appErr),
Done: true,
})
flusher.Flush()
return
}
if err := writeSSE(c, "completed", articleSelectionOptimizeStreamEvent{
ArticleID: id,
Status: "completed",
Content: result.Content,
Model: result.Model,
Done: true,
}); err != nil {
return
}
flusher.Flush()
}
func (h *ArticleHandler) Stream(c *gin.Context) {
if !h.streamEnabled {
response.Error(c, response.ErrNotFound(40412, "generation_stream_disabled", "generation stream is disabled"))
@@ -324,3 +454,16 @@ func writeSSE(c *gin.Context, event string, payload interface{}) error {
}
return nil
}
func resolveOptimizeStreamErrorMessage(appErr *response.AppError) string {
if appErr == nil {
return "optimize selection failed"
}
if detail := strings.TrimSpace(appErr.Detail); detail != "" {
return detail
}
if message := strings.TrimSpace(appErr.Message); message != "" {
return message
}
return "optimize selection failed"
}