feat(create): 创作对话 - 接入 text-replay 持久化与回放

This commit is contained in:
ddCat
2026-08-20 10:02:32 +08:00
13 changed files with 445 additions and 11 deletions

View File

@@ -172,6 +172,26 @@ func RegisterTaskRoutes(r *gin.RouterGroup, svc *service.Service) {
}
ok(c, task)
})
r.POST("/tasks/:id/text-replay-complete", func(c *gin.Context) {
user, err := currentUser(c, svc)
if err != nil {
failService(c, err)
return
}
var req struct {
Text string `json:"text"`
}
if err := c.ShouldBindJSON(&req); err != nil {
fail(c, http.StatusBadRequest, err)
return
}
task, err := svc.CompleteTextReplayTask(user.ID, c.Param("id"), req.Text)
if err != nil {
fail(c, http.StatusBadRequest, err)
return
}
ok(c, task)
})
r.GET("/tasks/:id/logs", func(c *gin.Context) {
user, err := currentUser(c, svc)
if err != nil {

View File

@@ -44,6 +44,10 @@ const (
TaskStatusSucceeded TaskStatus = "succeeded"
TaskStatusFailed TaskStatus = "failed"
TaskStatusCancelled TaskStatus = "cancelled"
// text_replay 任务是前端自管、不经过 worker 队列的文本生成存档容器:
// 前端直连模型流式生成,把增量 POST 到 /tasks/:id/text-deltas 存档,
// 完成后调用 /tasks/:id/text-replay-complete 归并为最终正文。
TaskStatusTextReplay TaskStatus = "text_replay"
SessionStatusActive SessionStatus = "active"
SessionStatusCompleted SessionStatus = "completed"

View File

@@ -124,6 +124,20 @@ func (r *Repository) CleanupTaskTextDeltas(now time.Time) (int64, error) {
return deleted, err
}
// CompleteTextReplayTask 把前端自管的 text_replay 任务原子收尾:写入最终正文并置为 succeeded。
// 条件更新保证只有仍处于 text_replay 状态的任务能被收尾,避免重复完成。
func (r *Repository) CompleteTextReplayTask(userID string, taskID string, resultJSON string, now time.Time) (bool, error) {
result := r.db.Model(&model.Task{}).
Where("id = ? AND user_id = ? AND status = ?", taskID, userID, model.TaskStatusTextReplay).
Updates(map[string]any{
"status": model.TaskStatusSucceeded, "stage": "已完成", "progress": 100,
"result_json": resultJSON, "text_draft": "",
"error": "", "completed_at": &now,
"lease_owner": "", "lease_expires_at": nil, "updated_at": now,
})
return result.RowsAffected == 1, result.Error
}
func (r *Repository) DeleteUserTaskTextDeltas(userID string) error {
return r.db.Delete(&model.TaskTextDelta{}, "user_id = ?", userID).Error
}

View File

@@ -0,0 +1,91 @@
package repository
import (
"testing"
"time"
"infinite-canvas/backend/internal/model"
"gorm.io/driver/sqlite"
"gorm.io/gorm"
)
// 验证 text-replay 闭环:text_replay 任务可写增量,完成后置为 succeeded 并写入最终正文。
func TestTextReplayLifecycle(t *testing.T) {
db, err := gorm.Open(sqlite.Open("file:text-replay-lifecycle?mode=memory&cache=shared"), &gorm.Config{})
if err != nil {
t.Fatal(err)
}
if err := db.AutoMigrate(&model.Task{}, &model.TaskTextDelta{}); err != nil {
t.Fatal(err)
}
task := model.Task{
ID: "replay-1", UserID: "user-1", Type: "text", Prompt: "写一段剧本",
Status: model.TaskStatusTextReplay, Stage: "文本持久化(前端自管)",
}
if err := db.Create(&task).Error; err != nil {
t.Fatal(err)
}
repo := New(db)
now := time.Now()
limits := TextReplayLimits{MaxTaskBytes: 1 << 20, MaxUserBytes: 1 << 20, MaxTaskEvents: 100}
// text_replay 状态应允许写入增量(未结束)
if _, err := repo.AppendTaskTextDelta("user-1", "replay-1", "第一段内容", now.Add(time.Hour), limits); err != nil {
t.Fatalf("text_replay 任务写入增量失败: %v", err)
}
if _, err := repo.AppendTaskTextDelta("user-1", "replay-1", "第二段内容", now.Add(time.Hour), limits); err != nil {
t.Fatalf("text_replay 任务写入增量失败: %v", err)
}
// 完成:写入最终正文并置为 succeeded
resultJSON := `{"mode":"text","text":"第一段内容第二段内容"}`
completed, err := repo.CompleteTextReplayTask("user-1", "replay-1", resultJSON, now)
if err != nil {
t.Fatal(err)
}
if !completed {
t.Fatal("expected complete to succeed")
}
stored, err := repo.Task("replay-1")
if err != nil {
t.Fatal(err)
}
if stored.Status != model.TaskStatusSucceeded {
t.Fatalf("expected status succeeded, got %s", stored.Status)
}
if stored.ResultJSON != resultJSON {
t.Fatalf("unexpected result_json: %s", stored.ResultJSON)
}
if stored.CompletedAt == nil {
t.Fatal("expected completed_at set")
}
// 已完成的 text_replay 任务不能再写入增量(进入 closed)
if _, err := repo.AppendTaskTextDelta("user-1", "replay-1", "追加", now.Add(time.Hour), limits); err == nil {
t.Fatal("expected ErrTextReplayClosed after completion")
}
// 重复完成应返回 completed=false(状态已不是 text_replay)
again, err := repo.CompleteTextReplayTask("user-1", "replay-1", resultJSON, now.Add(time.Minute))
if err != nil {
t.Fatal(err)
}
if again {
t.Fatal("expected second complete to be a no-op")
}
// 非 text_replay 任务不能完成
other := model.Task{ID: "queued-1", UserID: "user-1", Type: "text", Status: model.TaskStatusQueued}
if err := db.Create(&other).Error; err != nil {
t.Fatal(err)
}
done, err := repo.CompleteTextReplayTask("user-1", "queued-1", resultJSON, now)
if err != nil {
t.Fatal(err)
}
if done {
t.Fatal("queued task must not be completable as text_replay")
}
}

View File

@@ -347,6 +347,10 @@ func (s *Service) CreateTask(userID string, req CreateTaskRequest) (*model.Task,
}
normalizedInput = applyRoutedProviderSelection(normalizedInput, routed)
}
// 前端自管的文本持久化任务:直连模型生成、增量上报 text-deltas,不排入 worker 队列生成。
if isTextReplayTaskRequest(normalizedInput) {
return s.createTextReplayTask(userID, req, normalizedInput)
}
if err := s.requireCustomChannelsForTaskInput(normalizedInput); err != nil {
return nil, err
}
@@ -473,6 +477,41 @@ func normalizeTaskInput(input map[string]any) (map[string]any, error) {
return normalized, nil
}
// createTextReplayTask 创建前端自管的文本持久化任务:状态为 text_replay,
// 不排队执行、不计 active 队列、不产生计费,仅作为正文增量(text-deltas)的存储容器。
func (s *Service) createTextReplayTask(userID string, req CreateTaskRequest, normalizedInput map[string]any) (*model.Task, error) {
prompt := strings.TrimSpace(req.Prompt)
if prompt == "" {
prompt = strings.TrimSpace(fmt.Sprint(normalizedInput["prompt"]))
}
if prompt == "" {
return nil, errors.New("prompt is required")
}
taskType := strings.TrimSpace(req.Type)
if taskType == "" {
taskType = "text"
}
task := model.Task{
ID: newID(), UserID: userID, SessionID: req.SessionID, ProjectID: req.ProjectID,
Type: taskType, Status: model.TaskStatusTextReplay, Stage: "文本持久化(前端自管)", Progress: 5,
Prompt: prompt, Operation: req.Operation, Provider: req.Provider, Model: strings.TrimSpace(req.Model),
}
if err := s.protectTaskSecrets(normalizedInput); err != nil {
return nil, err
}
inputJSON, _ := json.Marshal(normalizedInput)
task.InputJSON = string(inputJSON)
policy, err := s.RuntimePolicy()
if err != nil {
return nil, err
}
if err := s.createTaskWithinStorageQuota(&task, nil, policy); err != nil {
return nil, err
}
_ = s.log(userID, task.ID, "info", "文本持久化任务已创建(前端自管)", "")
return taskForOutput(task), nil
}
func (s *Service) requireCustomChannelsForTaskInput(input map[string]any) error {
if !taskInputUsesCustomChannel(input) {
return nil

View File

@@ -29,6 +29,49 @@ type TextReplayResult struct {
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 {

View File

@@ -0,0 +1,25 @@
package service
import "testing"
func TestIsTextReplayTaskRequest(t *testing.T) {
cases := []struct {
name string
input map[string]any
want bool
}{
{"replay 布尔 true", map[string]any{"replay": true}, true},
{"replay 字符串 true", map[string]any{"replay": "true"}, true},
{"replay 字符串 True 大小写", map[string]any{"replay": "TRUE"}, true},
{"replay 布尔 false", map[string]any{"replay": false}, false},
{"无 replay 字段", map[string]any{"mode": "text"}, false},
{"replay 空字符串", map[string]any{"replay": ""}, false},
{"replay 数字", map[string]any{"replay": 1}, false},
{"replay nil", map[string]any{"replay": nil}, false},
}
for _, tc := range cases {
if got := isTextReplayTaskRequest(tc.input); got != tc.want {
t.Errorf("%s: isTextReplayTaskRequest(%v) = %v, want %v", tc.name, tc.input, got, tc.want)
}
}
}

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,13 +24,15 @@ 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";
import { consumeGenerationTaskMessage, generationTaskMaterializedUrls, materializeGenerationTaskAssets, projectGenerationTaskResult } from "@/services/project-asset-sync";
import { applyGenerationConsumerEffect } from "@/services/generation-consumer-dedupe";
import { beginGenerationConsumer, runGenerationConsumer } from "@/services/generation-consumer-lifecycle";
import { loadCreationConversations, pendingCreationMediaKey, pendingCreationTaskIds, removeCreationConversationSnapshot, saveCreationConversations, updateCreationConversationSnapshot } from "@/services/creation-conversation-store";
import { loadCreationConversations, pendingCreationTaskIds, pendingCreationTaskKey, removeCreationConversationSnapshot, saveCreationConversations, updateCreationConversationSnapshot } from "@/services/creation-conversation-store";
import { recoverCreationTextTask } from "@/services/creation-text-task-recovery";
import { modelDisplayName, modelOptionName, resolveModelChannel, selectableModelsByCapability, useConfigStore, useEffectiveConfig } from "@/stores/use-config-store";
import { useAssetStore, type Asset } from "@/stores/use-asset-store";
import { useUserStore } from "@/stores/use-user-store";
@@ -189,7 +191,7 @@ export default function CreatePage() {
const maxReferences = mode === "video" ? videoProfile.operations.includes("image_to_video") ? videoProfile.references.maxImages : 0 : mode === "image" ? imageProfile.references.maxImages : 6;
const mentionReferences = useMemo(() => buildCreationMentionReferences(addedSkills, attachments, draftReferences), [addedSkills, attachments, draftReferences]);
const isEmpty = !activeConversation?.messages.length;
const pendingMediaKey = useMemo(() => pendingCreationMediaKey(conversations), [conversations]);
const pendingTaskKey = useMemo(() => pendingCreationTaskKey(conversations), [conversations]);
const pendingTaskIds = useMemo(() => pendingCreationTaskIds(conversations), [conversations]);
const shots = useMemo(() => shotsFromMessages(activeConversation?.messages || []), [activeConversation]);
const visibleShotIndex = shots.length ? selectedShotIndex >= 0 && selectedShotIndex < shots.length ? selectedShotIndex : shots.length - 1 : -1;
@@ -255,7 +257,7 @@ export default function CreatePage() {
}, [conversations, hydrated]);
useEffect(() => {
if (!hydrated || !pendingMediaKey || !pendingTaskIds.length) return;
if (!hydrated || !pendingTaskKey || !pendingTaskIds.length) return;
let cancelled = false;
const observationController = new AbortController();
const applyTasks = async (tasks: GenerationTask[]) => {
@@ -291,7 +293,7 @@ export default function CreatePage() {
observationController.abort();
unsubscribe();
};
}, [hydrated, pendingMediaKey, toast]);
}, [hydrated, pendingTaskKey, toast]);
useEffect(() => {
let cancelled = false;
@@ -536,10 +538,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)));
@@ -1546,7 +1556,8 @@ function attachCreationTaskContexts(tasks: GenerationTask[], conversations: Crea
async function materializeCreationTaskResults(tasks: GenerationTask[], signal?: AbortSignal): Promise<PersistedCreationTask[]> {
return Promise.all(tasks.map(async (task): Promise<PersistedCreationTask> => {
if (task.status !== "succeeded" || !task.clientContext) return task;
// 文本正文保存在 resultJson,不进入媒体资源化链路。
if (task.status !== "succeeded" || !task.clientContext || task.type === "canvas_text") return task;
try {
const materialized = await runGenerationConsumer(signal, (managedSignal) => materializeGenerationTaskAssets(task, managedSignal));
const creationResultUrls = generationTaskMaterializedUrls(materialized);
@@ -1563,11 +1574,19 @@ function reconcileCreationTaskMessages(conversations: CreationConversation[], ta
let conversationChanged = false;
let completedAt = conversation.updatedAt;
const messages = conversation.messages.map((message) => {
if (message.role !== "assistant" || message.status !== "pending" || message.mode === "text") return message;
const taskIds = new Set(message.taskIds || []);
const matches = tasks
.filter((task) => taskIds.has(task.id) || (task.clientContext?.conversationId === conversation.id && task.clientContext.messageId === message.id))
.sort((left, right) => (left.clientContext?.batchIndex || 0) - (right.clientContext?.batchIndex || 0));
if (message.role === "assistant" && message.mode === "text") {
const recovery = recoverCreationTextTask(message, matches);
if (!recovery) return message;
completedAt = matches.reduce((latest, task) => conversationTimestamp(task.updatedAt) > conversationTimestamp(latest) ? task.updatedAt : latest, completedAt);
conversationChanged = true;
changed = true;
return { ...message, ...recovery };
}
if (message.role !== "assistant" || message.status !== "pending") return message;
const expectedTaskCount = Math.max(0, ...matches.map((task) => task.clientContext?.batchCount || 0));
if (!matches.length || (expectedTaskCount > 0 && matches.length < expectedTaskCount) || matches.some((task) => task.status === "queued" || task.status === "running")) return message;

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

View File

@@ -28,16 +28,21 @@ export function removeCreationConversationSnapshot<T extends { id: string }>(con
return next;
}
export function pendingCreationMediaKey(conversations: StoredCreationConversation[]) {
function isRecoverableCreationMessage(message: PendingCreationMessage) {
if (message.role !== "assistant" || !message.taskIds?.length) return false;
return message.mode === "text" ? message.status === "streaming" || message.status === "pending" : message.status === "pending";
}
export function pendingCreationTaskKey(conversations: StoredCreationConversation[]) {
return conversations
.flatMap((conversation) => conversation.messages.flatMap((message) => (message.role === "assistant" && message.status === "pending" && message.mode !== "text" ? [`${conversation.id}:${message.id}:${(message.taskIds || []).join(",")}`] : [])))
.flatMap((conversation) => conversation.messages.flatMap((message) => (isRecoverableCreationMessage(message) ? [`${conversation.id}:${message.id}:${(message.taskIds || []).join(",")}`] : [])))
.join("|");
}
export function pendingCreationTaskIds(conversations: StoredCreationConversation[]) {
const taskIds = conversations.flatMap((conversation) =>
conversation.messages.flatMap((message) => {
if (message.role !== "assistant" || message.status !== "pending" || message.mode === "text") return [];
if (!isRecoverableCreationMessage(message)) return [];
return message.taskIds || [];
}),
);

View File

@@ -0,0 +1,43 @@
import { parseBackendGenerationResult } from "@/services/api/generation-task";
import type { GenerationTask } from "@/services/api/task-center";
export type RecoverableCreationTextMessage = {
mode?: string;
status?: string;
content: string;
error?: string;
taskIds?: string[];
};
export type CreationTextTaskRecovery = {
status: "done" | "error" | "cancelled";
content: string;
error?: string;
taskIds: string[];
};
// 页面重载后只依赖后端任务终态恢复消息,不复用已经中断的浏览器请求。
export function recoverCreationTextTask(message: RecoverableCreationTextMessage, tasks: GenerationTask[]): CreationTextTaskRecovery | null {
if (message.mode !== "text" || (message.status !== "streaming" && message.status !== "pending")) return null;
const taskIds = new Set(message.taskIds || []);
const matches = tasks.filter((task) => taskIds.has(task.id));
if (!matches.length || matches.some((task) => task.status === "queued" || task.status === "running")) return null;
const nextTaskIds = Array.from(new Set([...(message.taskIds || []), ...matches.map((task) => task.id)]));
const succeeded = matches.find((task) => task.status === "succeeded");
if (succeeded) {
try {
const text = parseBackendGenerationResult(succeeded).text;
if (!text?.trim()) throw new Error("后端任务没有返回文本");
return { status: "done", content: text, error: undefined, taskIds: nextTaskIds };
} catch (error) {
return { status: "error", content: "生成失败", error: error instanceof Error ? error.message : "文本任务结果格式错误", taskIds: nextTaskIds };
}
}
if (matches.every((task) => task.status === "cancelled")) {
return { status: "cancelled", content: "已停止", error: undefined, taskIds: nextTaskIds };
}
const failed = matches.find((task) => task.status === "failed");
return { status: "error", content: "生成失败", error: failed?.error || "文本任务已失败", taskIds: nextTaskIds };
}

View File

@@ -8,7 +8,8 @@ import { switchUserStorageScope } from "../src/lib/user-session";
import { canvasCinematicContinuationEntryAdapters } from "../src/components/canvas/canvas-assistant-panel";
import { activeGenerationConsumerController, beginGenerationConsumer, runGenerationConsumer } from "../src/services/generation-consumer-lifecycle";
import { createCanvasGenerationLiveProjectAdapter, persistCanvasCinematicSessionContinuationEffect, persistCanvasGenerationEffect, registerCanvasGenerationLiveProject } from "../src/services/canvas-generation-consumer";
import { CREATION_CONVERSATIONS_KEY, loadCreationConversations, pendingCreationTaskIds, saveCreationConversations } from "../src/services/creation-conversation-store";
import { CREATION_CONVERSATIONS_KEY, loadCreationConversations, pendingCreationTaskIds, pendingCreationTaskKey, saveCreationConversations } from "../src/services/creation-conversation-store";
import { recoverCreationTextTask } from "../src/services/creation-text-task-recovery";
import { ASSET_STORE_KEY, flushAssetStorePersistence, useAssetStore, type Asset, type NewAsset } from "../src/stores/use-asset-store";
import { withGenerationAssetStorageLock } from "../src/services/generation-asset-repository";
import { CANVAS_STORE_KEY, flushCanvasStorePersistence, useCanvasStore, withCanvasStorePersistenceLock, withCanvasStorePersistenceSuppressed, type CanvasProject } from "../src/stores/canvas/use-canvas-store";
@@ -16,6 +17,46 @@ import { CanvasNodeType, type CanvasAssistantSession, type CanvasConnection, typ
import { deleteAssetWithRemoteSync, resetRemoteUserDataSync, syncRemoteUserData, withRemoteUserDataSyncPaused } from "../src/services/user-data-sync";
import { apiClient } from "../src/services/api/request";
test("creation recovery observes streaming text tasks after reload", () => {
const conversations = [{
id: "conversation-text-recovery",
messages: [
{ id: "text-streaming", role: "assistant" as const, mode: "text", status: "streaming", taskIds: ["task-text"] },
{ id: "text-done", role: "assistant" as const, mode: "text", status: "done", taskIds: ["task-text-done"] },
{ id: "image-pending", role: "assistant" as const, mode: "image", status: "pending", taskIds: ["task-image"] },
],
}];
expect(pendingCreationTaskIds(conversations)).toEqual(["task-text", "task-image"]);
expect(pendingCreationTaskKey(conversations)).toContain("conversation-text-recovery:text-streaming:task-text");
});
test("creation recovery restores completed text and terminal status", () => {
const message = { mode: "text", status: "streaming", content: "", taskIds: ["task-text"] };
const baseTask = {
id: "task-text",
type: "canvas_text",
prompt: "写一篇小说",
attempts: 1,
createdAt: "2026-08-19T22:47:51.000Z",
updatedAt: "2026-08-19T22:48:39.000Z",
};
expect(recoverCreationTextTask(message, [{ ...baseTask, status: "running" }])).toBeNull();
expect(recoverCreationTextTask(message, [{ ...baseTask, status: "succeeded", resultJson: JSON.stringify({ mode: "text", text: "恢复后的正文" }) }])).toEqual({
status: "done",
content: "恢复后的正文",
error: undefined,
taskIds: ["task-text"],
});
expect(recoverCreationTextTask(message, [{ ...baseTask, status: "failed", error: "渠道请求失败" }])).toEqual({
status: "error",
content: "生成失败",
error: "渠道请求失败",
taskIds: ["task-text"],
});
});
function generatedAsset(title: string): NewAsset {
return {
kind: "image",