Files
ttstd-infinite-canvas/backend/internal/service/text_replay.go
T
dlh 35dee568c9 feat(create): 创作对话文本模式接入 text-replay 持久化与回放
把后端既有但未接线的 text-replay 能力在前端打通:前端直连模型生成正文时,
把生成过程/结果持久化到后端任务,支持刷新重放与任务中心可见。

后端(最小配套,text-replay 本为前端自管设计):
- 新增 TaskStatusTextReplay 任务状态:不排入 worker 队列、不计费、不执行,
  仅作为正文增量(text-deltas)的存储容器;
- CreateTask 识别 input.replay=true 的请求,走 createTextReplayTask 轻量创建;
- 新增 POST /tasks/:id/text-replay-complete: 写入最终正文、置为 succeeded、
  触发 text-deltas 归并,使 queryTaskTextReplay 能读到 finalText。

前端:
- 新增 creation-text-replay.ts 发布器: start 建任务、publish 节流上报增量、
  finish 存最终全文,失败静默降级不阻塞生成;
- create 文本模式生成时接入发布器,正文流不经展示改动、额外持久化到后端。

测试: 新增 isTextReplayTaskRequest 纯函数测(本地)、text-replay 闭环仓测
(建任务后写增量 -> complete -> succeeded + finalText,CI/cgo 验证)。
build/vet/tsc 全绿。
2026-08-19 22:04:41 +08:00

166 lines
5.3 KiB
Go

package service
import (
"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"`
}
// 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}
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() {
cleanup := func() {
if _, err := s.CleanupTaskTextReplay(); err != nil {
log.Printf("text replay cleanup failed: %v", err)
}
}
cleanup()
go func() {
ticker := time.NewTicker(time.Hour)
defer ticker.Stop()
for range ticker.C {
cleanup()
}
}()
}