Files
ttstd-infinite-canvas/backend/internal/service/text_replay.go
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
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 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()
}
}()
}