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 全绿。
This commit is contained in:
dlh
2026-08-19 21:45:42 +08:00
parent 298b5ebf1f
commit 35dee568c9
10 changed files with 336 additions and 1 deletions

View File

@@ -24,6 +24,7 @@ import { isGenerationTaskCancelled, logicalModelIDForConfig, runBackendGeneratio
import { requestImageQuestion, type AiTextContentPart } from "@/services/api/image";
import { listAddedSkills, type Skill } from "@/services/api/skills";
import { cancelGenerationTask, subscribeGenerationTasks, type GenerationTask } from "@/services/api/task-center";
import { createTextReplayPublisher } from "@/lib/creation-text-replay";
import { isLocalDreaminaTaskId, isLocalDreaminaWaitStopped, localDreaminaCancellationCopy, localDreaminaCancellationMessage, localDreaminaDetachOutcome } from "@/services/local-dreamina-task-projection";
import { getMediaBlob, uploadMediaFile } from "@/services/file-storage";
import { uploadImage } from "@/services/image-storage";
@@ -536,10 +537,18 @@ export default function CreatePage() {
? await buildTextMessageContent(item)
: item.content,
})));
await requestImageQuestion(requestConfig, history, (text) => updateOriginAssistant((item) => ({ ...item, content: text })), {
const replayPublisher = createTextReplayPublisher(requestConfig, text);
void replayPublisher.start();
let finalText = "";
await requestImageQuestion(requestConfig, history, (full) => {
finalText = full;
updateOriginAssistant((item) => ({ ...item, content: full }));
replayPublisher.publish(full);
}, {
signal: requestLifecycle.signal,
onReasoning: (reasoning) => updateOriginAssistant((item) => ({ ...item, reasoning })),
});
replayPublisher.finish(finalText);
}
} else if (mode === "image") {
const taskCount = Math.max(1, Math.min(imageProfile.maxOutputs, Math.floor(Number(count) || 1)));