Files
ttstd-infinite-canvas/backend/internal/app/text_replay.go

180 lines
5.7 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
package app
import (
"context"
"encoding/json"
"errors"
"log"
"strings"
"time"
"infinite-canvas/backend/internal/model"
"infinite-canvas/backend/internal/repository"
"gorm.io/gorm"
)
const (
textReplayMaxEventBytes = 64 << 10
textReplayMaxTaskBytes = 2 << 20
textReplayMaxUserBytes = 64 << 20
textReplayMaxTaskEvents int64 = 4096
textReplaySuccessRetention = 24 * time.Hour
textReplayDraftRetention = 7 * 24 * time.Hour
)
type TextReplayResult struct {
Deltas []model.TaskTextDelta `json:"deltas"`
TextDraft string `json:"textDraft,omitempty"`
FinalText string `json:"finalText,omitempty"`
Complete bool `json:"complete"`
Status model.TaskStatus `json:"status"`
Stage string `json:"stage,omitempty"`
Progress int `json:"progress"`
Error string `json:"error,omitempty"`
}
// isTextReplayTaskRequest 识别前端自管的文本持久化请求(input 带 replay=true)。
// 这类任务由前端直连模型流式生成,仅作正文的后端存档与回放,不经过 worker 队列生成。
func isTextReplayTaskRequest(input map[string]any) bool {
value, ok := input["replay"]
if !ok {
return false
}
switch v := value.(type) {
case bool:
return v
case string:
return strings.EqualFold(strings.TrimSpace(v), "true")
default:
return false
}
}
// CompleteTextReplayTask 在文本生成结束后把最终正文写入任务,收尾为 succeeded,
// 关闭 delta 写入窗口并触发 text-deltas 归并,使 queryTaskTextReplay 能读到 finalText。
func (s *Service) CompleteTextReplayTask(userID string, taskID string, text string) (*model.Task, error) {
if strings.TrimSpace(text) == "" {
return nil, BadAuthRequest("文本内容不能为空")
}
if len([]byte(text)) > textReplayMaxTaskBytes {
return nil, BadAuthRequest("文本内容过大")
}
if _, err := s.repo.TaskForUser(userID, taskID); err != nil {
return nil, err
}
resultJSON, _ := json.Marshal(map[string]any{"mode": "text", "text": text})
completed, err := s.repo.CompleteTextReplayTask(userID, taskID, string(resultJSON), time.Now())
if err != nil {
return nil, err
}
if !completed {
return nil, BadAuthRequest("该文本任务已结束或不属于你,无法完成")
}
if compactErr := s.finalizeTaskTextReplay(taskID, model.TaskStatusSucceeded); compactErr != nil {
_ = s.log(userID, taskID, "error", "文本回放窗口更新失败", compactErr.Error())
}
return s.repo.TaskForUser(userID, taskID)
}
func (s *Service) AppendTaskTextDelta(userID string, taskID string, content string) (*model.TaskTextDelta, error) {
task, err := s.repo.TaskForUser(userID, taskID)
if err != nil {
return nil, err
}
if capabilityFromTaskType(task.Type) != "text" {
return nil, BadAuthRequest("只有文本生成任务支持增量回放")
}
if strings.TrimSpace(content) == "" {
return nil, BadAuthRequest("文本增量不能为空")
}
if len([]byte(content)) > textReplayMaxEventBytes {
return nil, BadAuthRequest("单条文本增量不能超过 64KB")
}
item, err := s.repo.AppendTaskTextDelta(userID, taskID, content, time.Now().Add(textReplayDraftRetention), repository.TextReplayLimits{
MaxTaskBytes: textReplayMaxTaskBytes, MaxUserBytes: textReplayMaxUserBytes, MaxTaskEvents: textReplayMaxTaskEvents,
})
if errors.Is(err, repository.ErrTextReplayQuotaExceeded) {
return nil, BadAuthRequest("文本回放增量已达到配额,请等待任务归并后继续")
}
if errors.Is(err, repository.ErrTextReplayClosed) {
return nil, BadAuthRequest("已结束任务不能继续写入文本增量")
}
return item, err
}
func (s *Service) TaskTextReplay(userID string, taskID string, after int64) (*TextReplayResult, error) {
task, err := s.repo.TaskForUser(userID, taskID)
if err != nil {
return nil, err
}
deltas, err := s.repo.TaskTextDeltas(userID, taskID, after, 1000)
if err != nil {
return nil, err
}
result := &TextReplayResult{
Deltas: deltas, TextDraft: task.TextDraft,
Complete: task.Status == model.TaskStatusSucceeded || task.Status == model.TaskStatusFailed || task.Status == model.TaskStatusCancelled,
Status: task.Status, Stage: task.Stage, Progress: task.Progress, Error: task.Error,
}
if task.Status == model.TaskStatusSucceeded {
result.FinalText = taskResultText(task.ResultJSON)
}
return result, nil
}
func (s *Service) finalizeTaskTextReplay(taskID string, status model.TaskStatus) error {
keepDraft := status == model.TaskStatusFailed || status == model.TaskStatusCancelled
retention := textReplaySuccessRetention
if keepDraft {
retention = textReplayDraftRetention
}
err := s.repo.CompactTaskTextDeltas(taskID, time.Now().Add(retention), keepDraft)
if errors.Is(err, gorm.ErrRecordNotFound) {
return nil
}
return err
}
func (s *Service) CleanupTaskTextReplay() (int64, error) {
return s.repo.CleanupTaskTextDeltas(time.Now())
}
func (s *Service) AdminTextReplayStats(actor *model.User) (repository.TextReplayStats, error) {
if err := s.RequireAdmin(actor); err != nil {
return repository.TextReplayStats{}, err
}
return s.repo.TextReplayStats()
}
func taskResultText(raw string) string {
var result struct {
Text string `json:"text"`
}
if json.Unmarshal([]byte(raw), &result) != nil {
return ""
}
return result.Text
}
func (s *Service) startTextReplayCleanup(ctx context.Context) {
cleanup := func() {
if _, err := s.CleanupTaskTextReplay(); err != nil {
log.Printf("text replay cleanup failed: %v", err)
}
}
s.runWorkerLoop(func(ctx context.Context) {
cleanup()
ticker := time.NewTicker(time.Hour)
defer ticker.Stop()
for {
select {
case <-ctx.Done():
return
case <-ticker.C:
cleanup()
}
}
})
}