feat: add EditorGridSizePicker and EditorSelectionFrame components; implement FreeCreateView for article management; introduce ArticleSelectionOptimizeService for optimizing article selections
This commit is contained in:
@@ -48,7 +48,7 @@ llm:
|
||||
provider: ark
|
||||
base_url: https://ark.cn-beijing.volces.com/api/v3
|
||||
api_key: "7fb6c66b-129c-4935-9617-709236c4205a"
|
||||
model: doubao-seed-2-0-lite-260215
|
||||
model: doubao-seed-2-0-mini-260215 #doubao-seed-2-0-lite-260215
|
||||
knowledge_url_model: doubao-seed-2-0-mini-260215
|
||||
timeout: 2m
|
||||
max_output_tokens: 16000
|
||||
|
||||
@@ -0,0 +1,228 @@
|
||||
package app
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"strings"
|
||||
"time"
|
||||
"unicode/utf8"
|
||||
|
||||
"github.com/jackc/pgx/v5"
|
||||
"github.com/jackc/pgx/v5/pgxpool"
|
||||
|
||||
"github.com/geo-platform/tenant-api/internal/shared/auth"
|
||||
"github.com/geo-platform/tenant-api/internal/shared/llm"
|
||||
"github.com/geo-platform/tenant-api/internal/shared/response"
|
||||
)
|
||||
|
||||
const (
|
||||
maxOptimizeInstructionChars = 800
|
||||
maxOptimizeMarkdownContextLen = 6000
|
||||
minOptimizeSelectedTextRunes = 2
|
||||
optimizeSelectionTimeout = 90 * time.Second
|
||||
)
|
||||
|
||||
type ArticleSelectionOptimizeRequest struct {
|
||||
Title string `json:"title"`
|
||||
MarkdownContent string `json:"markdown_content"`
|
||||
SelectedText string `json:"selected_text"`
|
||||
Instruction string `json:"instruction"`
|
||||
}
|
||||
|
||||
type ArticleSelectionOptimizeResult struct {
|
||||
Content string `json:"content"`
|
||||
Model string `json:"model"`
|
||||
}
|
||||
|
||||
type ArticleSelectionOptimizeService struct {
|
||||
pool *pgxpool.Pool
|
||||
llm llm.Client
|
||||
defaultMaxOutTokens int64
|
||||
}
|
||||
|
||||
func NewArticleSelectionOptimizeService(
|
||||
pool *pgxpool.Pool,
|
||||
llmClient llm.Client,
|
||||
defaultMaxOutTokens int64,
|
||||
) *ArticleSelectionOptimizeService {
|
||||
return &ArticleSelectionOptimizeService{
|
||||
pool: pool,
|
||||
llm: llmClient,
|
||||
defaultMaxOutTokens: defaultMaxOutTokens,
|
||||
}
|
||||
}
|
||||
|
||||
func (s *ArticleSelectionOptimizeService) ValidateRequest(
|
||||
ctx context.Context,
|
||||
articleID int64,
|
||||
req ArticleSelectionOptimizeRequest,
|
||||
) error {
|
||||
if err := s.validateLLM(); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := validateOptimizeSelectionPayload(req); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
actor := auth.MustActor(ctx)
|
||||
return s.ensureArticleEditable(ctx, articleID, actor.TenantID)
|
||||
}
|
||||
|
||||
func (s *ArticleSelectionOptimizeService) OptimizeSelection(
|
||||
ctx context.Context,
|
||||
req ArticleSelectionOptimizeRequest,
|
||||
onDelta func(string),
|
||||
) (*ArticleSelectionOptimizeResult, error) {
|
||||
result, err := s.llm.Generate(
|
||||
ctx,
|
||||
llm.GenerateRequest{
|
||||
Prompt: buildOptimizeSelectionPrompt(req),
|
||||
Timeout: optimizeSelectionTimeout,
|
||||
MaxOutputTokens: s.resolveMaxOutputTokens(req.SelectedText),
|
||||
},
|
||||
onDelta,
|
||||
)
|
||||
if err != nil {
|
||||
return nil, response.ErrInternal(50019, "article_selection_optimize_failed", err.Error())
|
||||
}
|
||||
|
||||
content := strings.TrimSpace(result.Content)
|
||||
if content == "" {
|
||||
return nil, response.ErrInternal(50019, "article_selection_optimize_failed", "optimized content is empty")
|
||||
}
|
||||
|
||||
return &ArticleSelectionOptimizeResult{
|
||||
Content: content,
|
||||
Model: result.Model,
|
||||
}, nil
|
||||
}
|
||||
|
||||
func (s *ArticleSelectionOptimizeService) validateLLM() error {
|
||||
if err := s.llm.Validate(); err != nil {
|
||||
return response.ErrServiceUnavailable(50304, "llm_unavailable", "llm provider is not configured")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *ArticleSelectionOptimizeService) ensureArticleEditable(
|
||||
ctx context.Context,
|
||||
articleID int64,
|
||||
tenantID int64,
|
||||
) error {
|
||||
var generateStatus string
|
||||
err := s.pool.QueryRow(
|
||||
ctx,
|
||||
`SELECT generate_status
|
||||
FROM articles
|
||||
WHERE id = $1 AND tenant_id = $2 AND deleted_at IS NULL`,
|
||||
articleID,
|
||||
tenantID,
|
||||
).Scan(&generateStatus)
|
||||
if err != nil {
|
||||
if err == pgx.ErrNoRows {
|
||||
return response.ErrNotFound(40411, "article_not_found", "article not found")
|
||||
}
|
||||
return response.ErrInternal(50020, "article_lookup_failed", "failed to query article state")
|
||||
}
|
||||
|
||||
if generateStatus != "completed" {
|
||||
return response.ErrConflict(40913, "article_not_optimizable", "only completed articles can be optimized")
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *ArticleSelectionOptimizeService) resolveMaxOutputTokens(selectedText string) int64 {
|
||||
limit := s.defaultMaxOutTokens
|
||||
if limit <= 0 {
|
||||
limit = 1200
|
||||
}
|
||||
|
||||
estimated := int64(utf8.RuneCountInString(strings.TrimSpace(selectedText))*4 + 240)
|
||||
if estimated < 256 {
|
||||
estimated = 256
|
||||
}
|
||||
if estimated > limit {
|
||||
return limit
|
||||
}
|
||||
return estimated
|
||||
}
|
||||
|
||||
func validateOptimizeSelectionPayload(req ArticleSelectionOptimizeRequest) error {
|
||||
selectedText := strings.TrimSpace(req.SelectedText)
|
||||
if utf8.RuneCountInString(selectedText) < minOptimizeSelectedTextRunes {
|
||||
return response.ErrBadRequest(40019, "selected_text_required", "selected_text is required")
|
||||
}
|
||||
|
||||
instruction := strings.TrimSpace(req.Instruction)
|
||||
if instruction == "" {
|
||||
return response.ErrBadRequest(40020, "optimize_instruction_required", "instruction is required")
|
||||
}
|
||||
|
||||
if len([]rune(instruction)) > maxOptimizeInstructionChars {
|
||||
return response.ErrBadRequest(
|
||||
40021,
|
||||
"optimize_instruction_too_long",
|
||||
fmt.Sprintf("instruction cannot exceed %d characters", maxOptimizeInstructionChars),
|
||||
)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func buildOptimizeSelectionPrompt(req ArticleSelectionOptimizeRequest) string {
|
||||
title := sanitizeOptimizePromptSection(req.Title, 300)
|
||||
markdownContent := sanitizeOptimizePromptSection(req.MarkdownContent, maxOptimizeMarkdownContextLen)
|
||||
selectedText := sanitizeOptimizePromptSection(req.SelectedText, 2000)
|
||||
instruction := sanitizeOptimizePromptSection(req.Instruction, maxOptimizeInstructionChars)
|
||||
|
||||
var builder strings.Builder
|
||||
builder.WriteString("你是一名资深中文内容编辑。请根据用户要求,优化一段文章选中文本。\n")
|
||||
builder.WriteString("请严格遵守以下规则:\n")
|
||||
builder.WriteString("1. 只输出优化后的正文内容,不要输出解释、标题、引号、前后缀说明或 Markdown 代码块。\n")
|
||||
builder.WriteString("2. 保持原文事实、结论、人物、时间、数字和专有名词准确,不得编造信息。\n")
|
||||
builder.WriteString("3. 优先遵循用户给出的优化要求,同时让表达与整篇文章风格保持自然一致。\n")
|
||||
builder.WriteString("4. 如果原文是一段话,默认输出一段话;除非用户明确要求改成其他结构。\n")
|
||||
builder.WriteString("5. 不要复述“以下是优化结果”等说明性文字。\n")
|
||||
|
||||
if title != "" {
|
||||
builder.WriteString("\n文章标题:\n")
|
||||
builder.WriteString(title)
|
||||
builder.WriteString("\n")
|
||||
}
|
||||
|
||||
if markdownContent != "" {
|
||||
builder.WriteString("\n文章草稿(用于参考语气和上下文,可能不是最终定稿):\n")
|
||||
builder.WriteString(markdownContent)
|
||||
builder.WriteString("\n")
|
||||
}
|
||||
|
||||
builder.WriteString("\n待优化原文:\n")
|
||||
builder.WriteString(selectedText)
|
||||
builder.WriteString("\n")
|
||||
|
||||
builder.WriteString("\n用户的优化要求:\n")
|
||||
builder.WriteString(instruction)
|
||||
builder.WriteString("\n")
|
||||
|
||||
builder.WriteString("\n现在请直接输出优化后的文本。")
|
||||
return builder.String()
|
||||
}
|
||||
|
||||
func sanitizeOptimizePromptSection(value string, maxRunes int) string {
|
||||
normalized := strings.TrimSpace(value)
|
||||
if normalized == "" {
|
||||
return ""
|
||||
}
|
||||
|
||||
normalized = strings.ReplaceAll(normalized, "\u0000", "")
|
||||
normalized = strings.ReplaceAll(normalized, "\r\n", "\n")
|
||||
normalized = strings.ReplaceAll(normalized, "\r", "\n")
|
||||
|
||||
runes := []rune(normalized)
|
||||
if maxRunes > 0 && len(runes) > maxRunes {
|
||||
return strings.TrimSpace(string(runes[:maxRunes])) + "\n...[内容已截断]"
|
||||
}
|
||||
|
||||
return normalized
|
||||
}
|
||||
@@ -314,6 +314,54 @@ func (s *ArticleService) Detail(ctx context.Context, id int64) (*ArticleDetailRe
|
||||
return &d, nil
|
||||
}
|
||||
|
||||
type CreateArticleRequest struct {
|
||||
Title string `json:"title"`
|
||||
}
|
||||
|
||||
func (s *ArticleService) Create(ctx context.Context, req CreateArticleRequest) (*ArticleDetailResponse, error) {
|
||||
actor := auth.MustActor(ctx)
|
||||
|
||||
title := strings.TrimSpace(req.Title)
|
||||
|
||||
tx, err := s.pool.Begin(ctx)
|
||||
if err != nil {
|
||||
return nil, response.ErrInternal(50014, "create_failed", "failed to begin article creation transaction")
|
||||
}
|
||||
defer tx.Rollback(ctx)
|
||||
|
||||
var articleID int64
|
||||
if err := tx.QueryRow(ctx, `
|
||||
INSERT INTO articles (tenant_id, source_type, template_id, generate_status, publish_status)
|
||||
VALUES ($1, 'free_create', NULL, 'completed', 'unpublished')
|
||||
RETURNING id
|
||||
`, actor.TenantID).Scan(&articleID); err != nil {
|
||||
return nil, response.ErrInternal(50014, "create_failed", "failed to create article")
|
||||
}
|
||||
|
||||
sourceLabel := "自由创作"
|
||||
var versionID int64
|
||||
if err := tx.QueryRow(ctx, `
|
||||
INSERT INTO article_versions (article_id, version_no, title, html_content, markdown_content, word_count, source_label)
|
||||
VALUES ($1, 1, $2, NULL, '', 0, $3)
|
||||
RETURNING id
|
||||
`, articleID, title, sourceLabel).Scan(&versionID); err != nil {
|
||||
return nil, response.ErrInternal(50014, "create_failed", "failed to create article version")
|
||||
}
|
||||
|
||||
if _, err := tx.Exec(ctx, `
|
||||
UPDATE articles SET current_version_id = $1, updated_at = NOW()
|
||||
WHERE id = $2 AND tenant_id = $3
|
||||
`, versionID, articleID, actor.TenantID); err != nil {
|
||||
return nil, response.ErrInternal(50014, "create_failed", "failed to link article version")
|
||||
}
|
||||
|
||||
if err := tx.Commit(ctx); err != nil {
|
||||
return nil, response.ErrInternal(50014, "create_failed", "failed to commit article creation")
|
||||
}
|
||||
|
||||
return s.Detail(ctx, articleID)
|
||||
}
|
||||
|
||||
type UpdateArticleRequest struct {
|
||||
Title string `json:"title"`
|
||||
MarkdownContent string `json:"markdown_content"`
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
|
||||
@@ -50,8 +50,10 @@ func RegisterRoutes(app *bootstrap.App) {
|
||||
articles := protected.Group("/tenant/articles")
|
||||
artHandler := NewArticleHandler(app)
|
||||
articles.GET("", artHandler.List)
|
||||
articles.POST("", artHandler.Create)
|
||||
articles.POST("/generate-from-rule", artHandler.GenerateFromRule)
|
||||
articles.POST("/:id/images", artHandler.UploadImage)
|
||||
articles.POST("/:id/selection-optimize/stream", artHandler.OptimizeSelectionStream)
|
||||
articles.GET("/:id", artHandler.Detail)
|
||||
articles.PUT("/:id", artHandler.Update)
|
||||
articles.GET("/:id/stream", artHandler.Stream)
|
||||
|
||||
Reference in New Issue
Block a user