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

@@ -0,0 +1,86 @@
import { appendTaskTextDelta, completeTextReplayTask, createGenerationTask } from "@/services/api/task-center";
import type { AiConfig } from "@/stores/use-config-store";
/**
* text-replay 前端发布器:为前端直连模型生成的文本,在后端维护一个
* text_replay 任务作持久化存档 + 回放。
*
* - start():创建后端 text_replay 任务(不排队、不计费),拿到 taskId;
* - publish(fullText):按阈值节流地把新增片段通过 appendTaskTextDelta 上报
* (生成中途刷新页面也能读到已生成部分);
* - finish(finalText):把最终正文写入任务并置为 succeeded,queryTaskTextReplay
* 即可读到 finalText。
*
* 全部失败静默降级:文本直连生成是主流程,后端持久化只是增强,失败不阻塞生成。
*/
export function createTextReplayPublisher(config: AiConfig, prompt: string) {
let taskId: string | null = null;
let lastLen = 0;
let buffered = "";
let flushTimer: ReturnType<typeof setTimeout> | null = null;
let closed = false;
// 增量累积到该阈值才落一次库,避免逐 token 高频写。
const FLUSH_THRESHOLD = 2048;
const flush = (force = false) => {
if (flushTimer) {
clearTimeout(flushTimer);
flushTimer = null;
}
if (closed || !taskId) return;
if (buffered.length >= FLUSH_THRESHOLD || force) {
const chunk = buffered;
buffered = "";
if (chunk.trim()) void appendTaskTextDelta(taskId, chunk).catch(() => {});
} else if (buffered) {
flushTimer = setTimeout(() => flush(true), 1000);
}
};
const start = async () => {
try {
const task = await createGenerationTask({
type: "text",
prompt: prompt.trim() || "文本创作",
model: config.model,
input: {
mode: "text",
replay: true,
prompt: prompt.trim() || "文本创作",
config: {
model: config.model,
baseUrl: config.baseUrl,
apiKey: config.apiKey,
apiFormat: config.apiFormat,
},
},
});
taskId = task?.id || null;
} catch {
taskId = null; // 降级:无持久化,不影响主流程
}
};
const publish = (fullText: string) => {
if (!taskId) return;
if (fullText.length <= lastLen) return;
buffered += fullText.slice(lastLen);
lastLen = fullText.length;
flush();
};
const finish = (finalText: string) => {
closed = true;
if (flushTimer) {
clearTimeout(flushTimer);
flushTimer = null;
}
if (!taskId) return;
void appendTaskTextDelta(taskId, buffered).catch(() => {});
if (finalText && finalText.trim()) {
void completeTextReplayTask(taskId, finalText).catch(() => {});
}
};
return { start, publish, finish };
}

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)));

View File

@@ -391,6 +391,10 @@ export function appendTaskTextDelta(id: string, content: string) {
return request<TaskTextDelta>(api.post(`/tasks/${encodeURIComponent(id)}/text-deltas`, { content }));
}
export function completeTextReplayTask(id: string, text: string) {
return request<GenerationTask>(api.post(`/tasks/${encodeURIComponent(id)}/text-replay-complete`, { text }));
}
export function queryTaskTextReplay(id: string, after = 0) {
return request<TaskTextReplay>(api.get(`/tasks/${encodeURIComponent(id)}/text-deltas`, { params: { after } }));
}