把后端既有但未接线的 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 全绿。
166 lines
5.3 KiB
Go
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()
|
||
}
|
||
}()
|
||
}
|