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
@@ -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"`