Files
ttstd-infinite-canvas/web/src/lib/creation-text-replay.ts
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

87 lines
3.1 KiB
TypeScript
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.
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 };
}