fix(runtime): 运行恢复整合 - 合并 PR #253 本地任务恢复改动
This commit is contained in:
@@ -294,7 +294,7 @@ func (r *Repository) ClaimNextTask(owner string, leaseDuration time.Duration) (*
|
||||
now := time.Now()
|
||||
leaseExpiresAt := now.Add(leaseDuration)
|
||||
err := r.db.Transaction(func(tx *gorm.DB) error {
|
||||
query := tx.Where("status = ? OR (status = ? AND (lease_expires_at IS NULL OR lease_expires_at <= ?))", model.TaskStatusQueued, model.TaskStatusRunning, now).
|
||||
query := tx.Where("(status = ? OR (status = ? AND (lease_expires_at IS NULL OR lease_expires_at <= ?))) AND (next_poll_at IS NULL OR next_poll_at <= ?)", model.TaskStatusQueued, model.TaskStatusRunning, now, now).
|
||||
Order("created_at asc").Limit(1)
|
||||
if r.Dialect() == "postgres" {
|
||||
query = query.Clauses(clause.Locking{Strength: "UPDATE", Options: "SKIP LOCKED"})
|
||||
@@ -309,7 +309,7 @@ func (r *Repository) ClaimNextTask(owner string, leaseDuration time.Duration) (*
|
||||
}
|
||||
claim := tx.Model(&model.Task{}).Where("id = ?", task.ID)
|
||||
if r.Dialect() != "postgres" {
|
||||
claim = claim.Where("status = ? OR (status = ? AND (lease_expires_at IS NULL OR lease_expires_at <= ?))", model.TaskStatusQueued, model.TaskStatusRunning, now)
|
||||
claim = claim.Where("(status = ? OR (status = ? AND (lease_expires_at IS NULL OR lease_expires_at <= ?))) AND (next_poll_at IS NULL OR next_poll_at <= ?)", model.TaskStatusQueued, model.TaskStatusRunning, now, now)
|
||||
}
|
||||
updated := claim.
|
||||
Updates(map[string]any{
|
||||
@@ -320,6 +320,7 @@ func (r *Repository) ClaimNextTask(owner string, leaseDuration time.Duration) (*
|
||||
"started_at": gorm.Expr("COALESCE(started_at, ?)", now),
|
||||
"lease_owner": owner,
|
||||
"lease_expires_at": leaseExpiresAt,
|
||||
"next_poll_at": nil,
|
||||
"updated_at": now,
|
||||
})
|
||||
if updated.Error != nil {
|
||||
@@ -358,6 +359,23 @@ func (r *Repository) UpdateTaskProviderState(id string, providerRequestID string
|
||||
return r.db.Model(&model.Task{}).Where("id = ?", id).Updates(updates).Error
|
||||
}
|
||||
|
||||
func (r *Repository) DeferRunningTaskForProviderPoll(id string, owner string, stage string, delay time.Duration) error {
|
||||
now := time.Now()
|
||||
result := r.db.Model(&model.Task{}).
|
||||
Where("id = ? AND status = ? AND lease_owner = ?", id, model.TaskStatusRunning, owner).
|
||||
Updates(map[string]any{
|
||||
"stage": stage, "error": "", "completed_at": nil, "next_poll_at": now.Add(delay),
|
||||
"lease_owner": "", "lease_expires_at": nil, "updated_at": now,
|
||||
})
|
||||
if result.Error != nil {
|
||||
return result.Error
|
||||
}
|
||||
if result.RowsAffected != 1 {
|
||||
return ErrTaskStateConflict
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// 人工恢复仅锁定失败任务;旧 worker 的租约可覆盖,但未过期的人工恢复租约不能并发抢占。
|
||||
func (r *Repository) ClaimFailedTaskProviderRecovery(id string, userID string, owner string, leaseDuration time.Duration) error {
|
||||
now := time.Now()
|
||||
|
||||
51
backend/internal/repository/task_provider_poll_test.go
Normal file
51
backend/internal/repository/task_provider_poll_test.go
Normal file
@@ -0,0 +1,51 @@
|
||||
package repository
|
||||
|
||||
import (
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"infinite-canvas/backend/internal/model"
|
||||
|
||||
"gorm.io/driver/sqlite"
|
||||
"gorm.io/gorm"
|
||||
)
|
||||
|
||||
func TestDeferredProviderPollKeepsOriginalTaskIdentityWithoutImmediateReclaim(t *testing.T) {
|
||||
db, err := gorm.Open(sqlite.Open("file:provider-poll-defer?mode=memory&cache=shared"), &gorm.Config{})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := db.AutoMigrate(&model.Task{}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
task := model.Task{
|
||||
ID: "task-1", UserID: "user-1", Type: "canvas_video", Status: model.TaskStatusRunning,
|
||||
LeaseOwner: "worker-1", LeaseExpiresAt: ptrTime(time.Now().Add(time.Minute)), ProviderRequestID: "provider-task-1",
|
||||
}
|
||||
if err := db.Create(&task).Error; err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
repo := New(db)
|
||||
if err := repo.DeferRunningTaskForProviderPoll(task.ID, task.LeaseOwner, "后台仍在生成", time.Minute); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
claimed, err := repo.ClaimNextTask("worker-2", 45*time.Second)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if claimed != nil {
|
||||
t.Fatalf("task reclaimed before next poll: %#v", claimed)
|
||||
}
|
||||
if err := db.Model(&model.Task{}).Where("id = ?", task.ID).Update("next_poll_at", time.Now().Add(-time.Second)).Error; err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
claimed, err = repo.ClaimNextTask("worker-2", 45*time.Second)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if claimed == nil || claimed.ID != task.ID || claimed.ProviderRequestID != task.ProviderRequestID {
|
||||
t.Fatalf("reclaimed task = %#v", claimed)
|
||||
}
|
||||
}
|
||||
|
||||
func ptrTime(value time.Time) *time.Time { return &value }
|
||||
@@ -1854,7 +1854,10 @@ func runNewAPIChannel2VideoTask(ctx context.Context, input canvasGenerationInput
|
||||
return nil, err
|
||||
}
|
||||
}
|
||||
return nil, fmt.Errorf("NewAPI Video Generations 视频生成超时(任务 %s)", id)
|
||||
if err := ctx.Err(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return nil, context.DeadlineExceeded
|
||||
}
|
||||
|
||||
// 单次查询只读取既有上游任务,不创建新任务;自动轮询和人工恢复共用这条安全边界。
|
||||
|
||||
@@ -4,6 +4,7 @@ import (
|
||||
"bytes"
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"io"
|
||||
"mime/multipart"
|
||||
"net/http"
|
||||
@@ -12,6 +13,9 @@ import (
|
||||
"strconv"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"infinite-canvas/backend/internal/model"
|
||||
)
|
||||
|
||||
const testReferenceImageDataURL = "data:image/png;base64,aGVsbG8="
|
||||
@@ -1306,6 +1310,59 @@ func TestRunNewAPIChannel2VideoTaskDownloadsTemporaryResult(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestRunNewAPIChannel2VideoTaskResumesOriginalProviderTaskWithoutAnotherPost(t *testing.T) {
|
||||
t.Setenv("CANVAS_ALLOW_PRIVATE_UPSTREAMS", "true")
|
||||
paths := make([]string, 0, 2)
|
||||
var server *httptest.Server
|
||||
server = httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
paths = append(paths, r.Method+" "+r.URL.Path)
|
||||
switch r.Method + " " + r.URL.Path {
|
||||
case "GET /v1/video/generations/existing-provider-task":
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
_, _ = w.Write([]byte(`{"code":"success","data":{"task_id":"existing-provider-task","status":"SUCCESS","result_url":"` + server.URL + `/video.mp4"}}`))
|
||||
case "GET /video.mp4":
|
||||
w.Header().Set("Content-Type", "video/mp4")
|
||||
_, _ = w.Write([]byte("video"))
|
||||
default:
|
||||
http.NotFound(w, r)
|
||||
}
|
||||
}))
|
||||
defer server.Close()
|
||||
|
||||
input := canvasGenerationInput{
|
||||
Mode: "video",
|
||||
Config: providerConfig{BaseURL: server.URL, APIKey: "test-key", Model: "video-model", InterfaceType: string(model.ChannelInterfaceNewAPIChannel2)},
|
||||
}
|
||||
inputJSON, err := json.Marshal(input)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
ctx := withProviderAnalytics(context.Background(), nil, model.Task{
|
||||
ID: "task-1", Type: "canvas_video", ProviderRequestID: "existing-provider-task", InputJSON: string(inputJSON),
|
||||
})
|
||||
result, err := runNewAPIChannel2VideoTask(ctx, input)
|
||||
if err != nil {
|
||||
t.Fatalf("runNewAPIChannel2VideoTask() error = %v", err)
|
||||
}
|
||||
if result["video"] == nil {
|
||||
t.Fatalf("result = %#v", result)
|
||||
}
|
||||
want := "GET /v1/video/generations/existing-provider-task,GET /video.mp4"
|
||||
if got := strings.Join(paths, ","); got != want {
|
||||
t.Fatalf("paths = %q, want %q", got, want)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRunNewAPIChannel2VideoTaskReturnsTypedDeadlineWhenPollingWindowEnds(t *testing.T) {
|
||||
ctx, cancel := context.WithDeadline(context.Background(), time.Now().Add(-time.Second))
|
||||
defer cancel()
|
||||
ctx = withProviderAnalytics(ctx, nil, model.Task{ID: "task-1", Type: "canvas_video", ProviderRequestID: "existing-provider-task"})
|
||||
_, err := runNewAPIChannel2VideoTask(ctx, canvasGenerationInput{})
|
||||
if !errors.Is(err, context.DeadlineExceeded) {
|
||||
t.Fatalf("runNewAPIChannel2VideoTask() error = %v, want context deadline exceeded", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRunGeminiVeoVideoTaskUsesLongRunningOperation(t *testing.T) {
|
||||
t.Setenv("CANVAS_ALLOW_PRIVATE_UPSTREAMS", "true")
|
||||
paths := make([]string, 0, 3)
|
||||
|
||||
@@ -1,7 +1,10 @@
|
||||
package service
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"infinite-canvas/backend/internal/model"
|
||||
"infinite-canvas/backend/internal/repository"
|
||||
@@ -10,6 +13,76 @@ import (
|
||||
"gorm.io/gorm"
|
||||
)
|
||||
|
||||
func TestVideoTaskTimeoutHasFiveMinuteSafetyFloor(t *testing.T) {
|
||||
policy := defaultRuntimePolicy().Task
|
||||
policy.VideoTimeoutMinutes = 1
|
||||
policy.ImageTimeoutMinutes = 1
|
||||
if got := taskExecutionTimeoutWithPolicy("canvas_video", policy); got != 5*time.Minute {
|
||||
t.Fatalf("video timeout = %s, want 5m", got)
|
||||
}
|
||||
if got := taskExecutionTimeoutWithPolicy("canvas_image", policy); got != time.Minute {
|
||||
t.Fatalf("image timeout = %s, want 1m", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestOnlyResumableNewAPIChannel2VideoDeadlinesStayRunning(t *testing.T) {
|
||||
svc := &Service{}
|
||||
input, err := json.Marshal(canvasGenerationInput{Mode: "video", Config: providerConfig{BaseURL: "https://example.com", InterfaceType: string(model.ChannelInterfaceNewAPIChannel2)}})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
base := model.Task{ID: "task-1", Type: "canvas_video", ProviderRequestID: "provider-task-1"}
|
||||
if !svc.shouldDeferVideoProviderTask(base, string(input), context.DeadlineExceeded) {
|
||||
t.Fatal("resumable NewAPI Channel 2 deadline should remain running")
|
||||
}
|
||||
if svc.shouldDeferVideoProviderTask(model.Task{ID: "task-2", Type: "canvas_video"}, string(input), context.DeadlineExceeded) {
|
||||
t.Fatal("missing provider task id must not be deferred")
|
||||
}
|
||||
if svc.shouldDeferVideoProviderTask(base, string(input), context.Canceled) {
|
||||
t.Fatal("explicit cancellation must not be deferred")
|
||||
}
|
||||
other, err := json.Marshal(canvasGenerationInput{Mode: "video", Config: providerConfig{BaseURL: "https://example.com", InterfaceType: string(model.ChannelInterfaceNewAPIVideo)}})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if svc.shouldDeferVideoProviderTask(base, string(other), context.DeadlineExceeded) {
|
||||
t.Fatal("providers without a verified query-only resume contract must fail closed")
|
||||
}
|
||||
}
|
||||
|
||||
func TestResumableVideoDeadlineUsesResolvedSystemChannelProtocol(t *testing.T) {
|
||||
db, err := gorm.Open(sqlite.Open("file:"+newID()+"?mode=memory&cache=shared"), &gorm.Config{})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := db.AutoMigrate(&model.ModelChannel{}, &model.ChannelModel{}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
channel := model.ModelChannel{
|
||||
ID: "channel-video", UserID: "admin", Scope: model.ChannelScopeSystem, Enabled: true, Name: "Video",
|
||||
BaseURL: "https://example.com", APIKey: "test-key", APIFormat: "openai", ModelsJSON: `["video-model"]`,
|
||||
}
|
||||
channelModel := model.ChannelModel{
|
||||
ID: "model-video", ChannelID: channel.ID, ModelKey: "video-model", Capability: "video",
|
||||
Protocol: model.ChannelInterfaceNewAPIChannel2, Enabled: true, PriceConfigured: true,
|
||||
}
|
||||
if err := db.Create(&channel).Error; err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := db.Create(&channelModel).Error; err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
input, err := json.Marshal(canvasGenerationInput{Mode: "video", Config: providerConfig{ChannelID: channel.ID, Model: "video-model"}})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
svc := New(repository.New(db), t.TempDir())
|
||||
task := model.Task{ID: "task-system", Type: "canvas_video", ProviderRequestID: "provider-task-1"}
|
||||
if !svc.shouldDeferVideoProviderTask(task, string(input), context.DeadlineExceeded) {
|
||||
t.Fatal("system-channel task must use the resolved model protocol when deciding query-only recovery")
|
||||
}
|
||||
}
|
||||
|
||||
func TestRuntimePolicyDefaultsAndSelfUseModeValidate(t *testing.T) {
|
||||
if err := validateRuntimePolicy(defaultRuntimePolicy()); err != nil {
|
||||
t.Fatalf("default runtime policy error = %v", err)
|
||||
|
||||
@@ -990,6 +990,14 @@ func (s *Service) processClaimedTask(task *model.Task) error {
|
||||
return nil
|
||||
}
|
||||
if errors.Is(err, context.DeadlineExceeded) {
|
||||
decryptedInput, decryptErr := s.decryptTaskInputJSON(task.InputJSON)
|
||||
if decryptErr == nil && s.shouldDeferVideoProviderTask(*task, decryptedInput, err) {
|
||||
if deferErr := s.repo.DeferRunningTaskForProviderPoll(task.ID, task.LeaseOwner, "后台仍在生成", 15*time.Second); deferErr != nil {
|
||||
return deferErr
|
||||
}
|
||||
_ = s.log(task.UserID, task.ID, "info", "前台等待结束,上游视频仍在生成,将继续回查原任务", task.PollStage)
|
||||
return nil
|
||||
}
|
||||
err = errors.New(taskTimeoutMessage(task.Type))
|
||||
}
|
||||
task.Status = model.TaskStatusFailed
|
||||
@@ -1079,7 +1087,7 @@ func taskExecutionTimeoutWithPolicy(taskType string, policy RuntimeTaskPolicy) t
|
||||
case taskType == "agent_storyboard" || taskType == "agent_storyboard_rows":
|
||||
return time.Duration(policy.StoryboardTimeoutMinutes) * time.Minute
|
||||
case strings.HasPrefix(taskType, "canvas_video") || strings.HasPrefix(taskType, "video_"):
|
||||
return time.Duration(policy.VideoTimeoutMinutes) * time.Minute
|
||||
return max(time.Duration(policy.VideoTimeoutMinutes)*time.Minute, 5*time.Minute)
|
||||
case strings.HasPrefix(taskType, "canvas_image"):
|
||||
return time.Duration(policy.ImageTimeoutMinutes) * time.Minute
|
||||
case strings.HasPrefix(taskType, "canvas_audio"):
|
||||
@@ -1091,6 +1099,18 @@ func taskExecutionTimeoutWithPolicy(taskType string, policy RuntimeTaskPolicy) t
|
||||
}
|
||||
}
|
||||
|
||||
func (s *Service) shouldDeferVideoProviderTask(task model.Task, decryptedInput string, err error) bool {
|
||||
if !errors.Is(err, context.DeadlineExceeded) || strings.TrimSpace(task.ProviderRequestID) == "" || (!strings.HasPrefix(task.Type, "canvas_video") && !strings.HasPrefix(task.Type, "video_")) {
|
||||
return false
|
||||
}
|
||||
var input canvasGenerationInput
|
||||
if json.Unmarshal([]byte(decryptedInput), &input) != nil {
|
||||
return false
|
||||
}
|
||||
resolved, resolveErr := s.resolveProviderConfig(input.Config)
|
||||
return resolveErr == nil && resolved.InterfaceType == string(model.ChannelInterfaceNewAPIChannel2)
|
||||
}
|
||||
|
||||
func taskTimeoutMessage(taskType string) string {
|
||||
if strings.HasPrefix(taskType, "canvas_video") || strings.HasPrefix(taskType, "video_") {
|
||||
return "视频生成等待超时,请稍后到任务中心查看或重试。"
|
||||
|
||||
@@ -10,7 +10,18 @@ import { createClientId } from "@/lib/client-id";
|
||||
import { getLocalRuntimeSessionClient, useLocalRuntimeStore } from "@/stores/use-local-runtime-store";
|
||||
import { useThemeStore } from "@/stores/use-theme-store";
|
||||
import { useUserStore } from "@/stores/use-user-store";
|
||||
import { useCanvasAgentStore, type AgentAttachment, type AgentChatItem, type AgentEventLog, type AgentPanelTab, type AgentPendingToolCall, type AgentThreadSummary } from "@/stores/canvas/use-canvas-agent-store";
|
||||
import {
|
||||
canvasAgentConnectionStatusText,
|
||||
canvasAgentConnectionStartingPatch,
|
||||
canvasAgentTransientDisconnectPatch,
|
||||
useCanvasAgentStore,
|
||||
type AgentAttachment,
|
||||
type AgentChatItem,
|
||||
type AgentEventLog,
|
||||
type AgentPanelTab,
|
||||
type AgentPendingToolCall,
|
||||
type AgentThreadSummary,
|
||||
} from "@/stores/canvas/use-canvas-agent-store";
|
||||
import { previewCanvasAgentOps, summarizeCanvasAgentOps, type CanvasAgentOp, type CanvasAgentSnapshot } from "@/lib/canvas/canvas-agent-ops";
|
||||
import { isProjectAgentReadTool, isProjectAgentToolName, runProjectAgentTool } from "@/services/api/project-agent-tools";
|
||||
import { AgentChatComposer, AgentChatMessage, AgentPanelTabs, AgentPendingToolCard, AgentWorkingMessage, type CanvasAgentChatAttachment } from "./canvas-agent-chat-ui";
|
||||
@@ -236,7 +247,7 @@ export const CanvasLocalAgentPanel = memo(function CanvasLocalAgentPanel({
|
||||
}
|
||||
errorLoggedRef.current = true;
|
||||
connectedRef.current = false;
|
||||
clearAgentSession({ activity: wasConnected ? "正在重连" : "连接失败", connected: false, connectError: text });
|
||||
setAgentState(canvasAgentTransientDisconnectPatch(wasConnected ? "正在重连" : "连接失败", text));
|
||||
await waitForCanvasRuntimeReconnect(controller.signal);
|
||||
}
|
||||
}
|
||||
@@ -402,28 +413,15 @@ export const CanvasLocalAgentPanel = memo(function CanvasLocalAgentPanel({
|
||||
if (connected) syncState(clientIdRef.current, restored);
|
||||
};
|
||||
|
||||
const toggleAgentConnection = async () => {
|
||||
const toggleAgentConnection = () => {
|
||||
if (enabled) {
|
||||
connectionControllerRef.current?.abort();
|
||||
clearAgentSession({ enabled: false, connected: false, activity: "离线", connectError: "" });
|
||||
pendingToolRef.current = null;
|
||||
setAgentState({ enabled: false, connected: false, activity: "离线", connectError: "", waiting: false, sending: false, pendingTool: null });
|
||||
return;
|
||||
}
|
||||
const controller = new AbortController();
|
||||
connectionControllerRef.current = controller;
|
||||
setAgentState({ connected: false, activity: "连接中", connectError: "", activeTab: "setup" });
|
||||
try {
|
||||
await prepareCanvasRuntimeConnection(useLocalRuntimeStore, controller.signal);
|
||||
if (controller.signal.aborted) return;
|
||||
errorLoggedRef.current = false;
|
||||
setAgentState({ enabled: true, connected: false, activity: "连接中", connectError: "", activeTab: "setup" });
|
||||
} catch (error) {
|
||||
if (controller.signal.aborted) return;
|
||||
const text = error instanceof Error ? error.message : "本机 Runtime 连接失败";
|
||||
setAgentState({ connectError: text });
|
||||
if (!headless) message.warning(text);
|
||||
} finally {
|
||||
if (connectionControllerRef.current === controller) connectionControllerRef.current = null;
|
||||
}
|
||||
errorLoggedRef.current = false;
|
||||
setAgentState(canvasAgentConnectionStartingPatch());
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
@@ -432,21 +430,6 @@ export const CanvasLocalAgentPanel = memo(function CanvasLocalAgentPanel({
|
||||
void toggleAgentConnection();
|
||||
}, [autoConnect, connected, enabled]);
|
||||
|
||||
function clearAgentSession(patch: Parameters<typeof setAgentState>[0] = {}) {
|
||||
setAgentState({
|
||||
messages: [],
|
||||
threads: [],
|
||||
activeThreadId: "",
|
||||
workspacePath: "",
|
||||
loadingThreads: false,
|
||||
waiting: false,
|
||||
sending: false,
|
||||
pendingTool: null,
|
||||
...patch,
|
||||
});
|
||||
pendingToolRef.current = null;
|
||||
}
|
||||
|
||||
const startNewThread = async () => {
|
||||
const projectId = snapshotRef.current.projectId;
|
||||
if (!connected || !projectId) return;
|
||||
@@ -786,8 +769,8 @@ function AgentConnectView({
|
||||
connectError: string;
|
||||
onToggleEnabled: () => void;
|
||||
}) {
|
||||
const statusText = connectError ? "连接失败" : connected ? activity : enabled ? "连接中" : "未连接";
|
||||
const statusColor = connectError ? "#dc2626" : connected ? "#16a34a" : enabled ? "#d97706" : theme.node.muted;
|
||||
const statusText = canvasAgentConnectionStatusText({ enabled, connected, activity, connectError });
|
||||
const statusColor = statusText === "连接失败" ? "#dc2626" : connected ? "#16a34a" : enabled ? "#d97706" : theme.node.muted;
|
||||
return (
|
||||
<div className="thin-scrollbar min-h-0 flex-1 overflow-y-auto p-4">
|
||||
<div className="space-y-4">
|
||||
|
||||
@@ -7,6 +7,7 @@ import { isLocalDreaminaBackgroundTask, localDreaminaTaskId, projectLocalDreamin
|
||||
import { modelCapabilityConfigFor } from "@/lib/model-capabilities";
|
||||
import { grokImagePromptLimitError } from "@/lib/grok-image-prompt-limit";
|
||||
import { resolveModelRequestConfig, type AiConfig } from "@/stores/use-config-store";
|
||||
import { useLocalDreaminaModelStore } from "@/stores/use-local-dreamina-model-store";
|
||||
import type { ReferenceImage } from "@/types/image";
|
||||
import type { ReferenceAudio, ReferenceVideo } from "@/types/media";
|
||||
|
||||
@@ -46,6 +47,7 @@ export type GenerationTaskDependencies = {
|
||||
runLocal: (input: LocalDreaminaGenerationInput, signal?: AbortSignal, onTaskUpdate?: (task: LocalDreaminaGenerationTask) => void) => ReturnType<typeof runLocalDreaminaGenerationTask>;
|
||||
createId: () => string;
|
||||
now: () => string;
|
||||
ensureLocalDreaminaReady?: (signal?: AbortSignal) => Promise<unknown>;
|
||||
};
|
||||
|
||||
const defaultDependencies: GenerationTaskDependencies = {
|
||||
@@ -54,6 +56,7 @@ const defaultDependencies: GenerationTaskDependencies = {
|
||||
runLocal: (input, signal, onTaskUpdate) => runLocalDreaminaGenerationTask(input, { onTaskUpdate }, signal),
|
||||
createId: () => crypto.randomUUID(),
|
||||
now: () => new Date().toISOString(),
|
||||
ensureLocalDreaminaReady: (signal) => useLocalDreaminaModelStore.getState().ensureReady(signal),
|
||||
};
|
||||
|
||||
type PreparedGenerationReferences = {
|
||||
@@ -88,6 +91,8 @@ export async function runBackendGenerationTask(
|
||||
throwIfAborted(signal);
|
||||
assertClientPromptLimit(mode, prompt, config, metadata);
|
||||
if (isLocalDreaminaModel(config.model)) {
|
||||
await dependencies.ensureLocalDreaminaReady?.(signal);
|
||||
throwIfAborted(signal);
|
||||
return await runLocalDreaminaGeneration(
|
||||
{ projectId, mode, prompt, config, referenceImages, referenceVideos, referenceAudios, mask, signal, metadata, onTaskUpdate, localIdempotencyKey, localResumeOnly, clientOperationId, retryOf, attemptGroupId },
|
||||
dependencies,
|
||||
@@ -104,6 +109,8 @@ export async function runBackendGenerationTaskBatch(options: BackendGenerationTa
|
||||
assertClientPromptLimit(options.mode, options.prompt, options.config, options.metadata);
|
||||
if (options.retryContextsByBatchIndex && options.retryContextsByBatchIndex.length !== count) throw new Error("生成重试批次任务数量不匹配");
|
||||
if (isLocalDreaminaModel(options.config.model)) {
|
||||
await dependencies.ensureLocalDreaminaReady?.(options.signal);
|
||||
throwIfAborted(options.signal);
|
||||
return Promise.allSettled(
|
||||
Array.from({ length: count }, (_, batchIndex) => {
|
||||
const retryContext = options.retryContextsByBatchIndex?.[batchIndex];
|
||||
@@ -191,7 +198,8 @@ async function runLocalDreaminaGeneration(options: BackendGenerationTaskOptions,
|
||||
},
|
||||
);
|
||||
const completedAt = dependencies.now();
|
||||
options.onTaskUpdate?.({ ...task, status: "succeeded", progress: 100, stage: "local_cli_succeeded", resultJson: JSON.stringify(result), completedAt, updatedAt: completedAt });
|
||||
latestPublicTask = { ...latestPublicTask, status: "succeeded", progress: 100, stage: "local_cli_succeeded", resultJson: JSON.stringify(result), completedAt, updatedAt: completedAt };
|
||||
options.onTaskUpdate?.(latestPublicTask);
|
||||
return result;
|
||||
} catch (error) {
|
||||
const completedAt = dependencies.now();
|
||||
@@ -200,7 +208,7 @@ async function runLocalDreaminaGeneration(options: BackendGenerationTaskOptions,
|
||||
const localErrorCode = error instanceof LocalDreaminaGenerationClientError ? error.code : undefined;
|
||||
if (!(cancelled && isLocalDreaminaBackgroundTask(latestPublicTask))) {
|
||||
options.onTaskUpdate?.({
|
||||
...task,
|
||||
...latestPublicTask,
|
||||
status: cancelled ? "cancelled" : "failed",
|
||||
stage: cancelled ? "local_cli_cancelled" : "local_cli_failed",
|
||||
completedAt,
|
||||
|
||||
@@ -35,10 +35,50 @@ type CanvasAgentStore = {
|
||||
clearEventLogs: () => void;
|
||||
};
|
||||
|
||||
const CANVAS_AGENT_ENABLED_STORAGE_KEY = "canvas-agent-enabled";
|
||||
|
||||
type CanvasAgentPreferenceStorage = {
|
||||
getItem(key: string): string | null;
|
||||
setItem(key: string, value: string): void;
|
||||
};
|
||||
|
||||
export function readCanvasAgentEnabledPreference(storage: Pick<CanvasAgentPreferenceStorage, "getItem"> | undefined = browserPreferenceStorage()) {
|
||||
try {
|
||||
return storage?.getItem(CANVAS_AGENT_ENABLED_STORAGE_KEY) === "true";
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
export function writeCanvasAgentEnabledPreference(enabled: boolean, storage: Pick<CanvasAgentPreferenceStorage, "setItem"> | undefined = browserPreferenceStorage()) {
|
||||
try {
|
||||
storage?.setItem(CANVAS_AGENT_ENABLED_STORAGE_KEY, String(enabled));
|
||||
} catch {
|
||||
// 浏览器隐私模式可能拒绝本地偏好写入;连接本身仍可继续。
|
||||
}
|
||||
}
|
||||
|
||||
export function canvasAgentConnectionStartingPatch() {
|
||||
return { enabled: true, connected: false, activity: "连接中", connectError: "", activeTab: "setup" as const };
|
||||
}
|
||||
|
||||
export function canvasAgentTransientDisconnectPatch(activity: string, connectError: string) {
|
||||
return { enabled: true, connected: false, activity, connectError, waiting: false, sending: false };
|
||||
}
|
||||
|
||||
export function canvasAgentConnectionStatusText({ enabled, connected, activity, connectError }: Pick<CanvasAgentStore, "enabled" | "connected" | "activity" | "connectError">) {
|
||||
if (enabled && !connected && activity === "正在重连") return activity;
|
||||
return connectError ? "连接失败" : connected ? activity : enabled ? "连接中" : "未连接";
|
||||
}
|
||||
|
||||
function browserPreferenceStorage(): CanvasAgentPreferenceStorage | undefined {
|
||||
return typeof window === "undefined" ? undefined : window.localStorage;
|
||||
}
|
||||
|
||||
export const useCanvasAgentStore = create<CanvasAgentStore>((set) => ({
|
||||
width: typeof window === "undefined" ? 440 : Number(localStorage.getItem("canvas-agent-panel-width")) || 440,
|
||||
connected: false,
|
||||
enabled: false,
|
||||
enabled: readCanvasAgentEnabledPreference(),
|
||||
prompt: "",
|
||||
attachments: [],
|
||||
sending: false,
|
||||
@@ -54,7 +94,10 @@ export const useCanvasAgentStore = create<CanvasAgentStore>((set) => ({
|
||||
activity: "就绪",
|
||||
connectError: "",
|
||||
pendingTool: null,
|
||||
setAgentState: (patch) => set(patch),
|
||||
setAgentState: (patch) => {
|
||||
if (typeof patch.enabled === "boolean") writeCanvasAgentEnabledPreference(patch.enabled);
|
||||
set(patch);
|
||||
},
|
||||
addMessage: (item) => set((state) => ({ messages: [...state.messages.slice(-120), item] })),
|
||||
addEventLog: (item) => set((state) => ({ eventLogs: [...state.eventLogs.slice(-160), item] })),
|
||||
clearEventLogs: () => set({ eventLogs: [] }),
|
||||
|
||||
@@ -4,31 +4,103 @@ import { create } from "zustand";
|
||||
import { getDreaminaModelCatalogSnapshotWithSessionRecovery, type DreaminaLocalModel } from "@/services/local-dreamina-model-catalog";
|
||||
import { getLocalRuntimeSessionClient, useLocalRuntimeStore } from "@/stores/use-local-runtime-store";
|
||||
|
||||
type State = { state: "idle" | "loading" | "ready" | "error"; models: DreaminaLocalModel[]; cacheScope?: string; sync(signal?: AbortSignal): Promise<void> };
|
||||
type CatalogSnapshot = Awaited<ReturnType<typeof getDreaminaModelCatalogSnapshotWithSessionRecovery>>;
|
||||
type RuntimeCatalogState = { connection: string; modules: Array<{ id: string; scopes: string[] }> };
|
||||
type Dependencies = {
|
||||
getRuntimeState(): RuntimeCatalogState;
|
||||
getClient(): Parameters<typeof getDreaminaModelCatalogSnapshotWithSessionRecovery>[0];
|
||||
loadSnapshot(client: Parameters<typeof getDreaminaModelCatalogSnapshotWithSessionRecovery>[0], signal: AbortSignal): Promise<CatalogSnapshot>;
|
||||
requestTimeoutMs?: number;
|
||||
};
|
||||
type State = {
|
||||
state: "idle" | "loading" | "ready" | "error";
|
||||
models: DreaminaLocalModel[];
|
||||
cacheScope?: string;
|
||||
sync(signal?: AbortSignal): Promise<void>;
|
||||
ensureReady(signal?: AbortSignal): Promise<DreaminaLocalModel[]>;
|
||||
};
|
||||
|
||||
export const dreaminaModelCacheScopeKey = ({ accountBinding, sessionEpoch }: { accountBinding: string; sessionEpoch: string | number }) => `${accountBinding}:${sessionEpoch}`;
|
||||
|
||||
export const useLocalDreaminaModelStore = create<State>((set, get) => ({
|
||||
state: "idle",
|
||||
models: [],
|
||||
cacheScope: undefined,
|
||||
async sync(signal) {
|
||||
const runtime = useLocalRuntimeStore.getState();
|
||||
const available = runtime.connection === "connected" && runtime.modules.some((module) => module.id === "dreamina" && module.scopes.includes("dreamina:models"));
|
||||
if (!available) {
|
||||
set({ state: "idle", models: [], cacheScope: undefined });
|
||||
return;
|
||||
}
|
||||
const client = getLocalRuntimeSessionClient();
|
||||
try {
|
||||
const snapshot = await getDreaminaModelCatalogSnapshotWithSessionRecovery(client, signal);
|
||||
const cacheScope = dreaminaModelCacheScopeKey(snapshot);
|
||||
set({ state: "ready", models: snapshot.models, cacheScope });
|
||||
} catch {
|
||||
if (!signal?.aborted) set({ state: "error", models: [], cacheScope: undefined });
|
||||
}
|
||||
},
|
||||
}));
|
||||
const DEFAULT_CATALOG_TIMEOUT_MS = 8_000;
|
||||
|
||||
export function createLocalDreaminaModelStore(dependencies: Dependencies) {
|
||||
let requestRevision = 0;
|
||||
let activeRequest: { revision: number; controller: AbortController; promise: Promise<DreaminaLocalModel[]> } | null = null;
|
||||
return create<State>((set, get) => {
|
||||
const load = (force: boolean, signal?: AbortSignal) => {
|
||||
const runtime = dependencies.getRuntimeState();
|
||||
const available = runtime.connection === "connected" && runtime.modules.some((module) => module.id === "dreamina" && module.scopes.includes("dreamina:models"));
|
||||
if (!available) {
|
||||
requestRevision++;
|
||||
activeRequest?.controller.abort();
|
||||
activeRequest = null;
|
||||
set({ state: "idle", models: [], cacheScope: undefined });
|
||||
return Promise.reject(new Error("即梦本机模型目录尚未就绪"));
|
||||
}
|
||||
const current = get();
|
||||
if (!force && current.state === "ready" && current.models.length) return Promise.resolve(current.models);
|
||||
if (force && activeRequest) {
|
||||
requestRevision++;
|
||||
activeRequest.controller.abort();
|
||||
activeRequest = null;
|
||||
}
|
||||
if (!activeRequest) {
|
||||
set({ state: "loading" });
|
||||
const revision = ++requestRevision;
|
||||
const controller = new AbortController();
|
||||
const timer = setTimeout(() => controller.abort(), Math.max(1, dependencies.requestTimeoutMs ?? DEFAULT_CATALOG_TIMEOUT_MS));
|
||||
const promise = dependencies
|
||||
.loadSnapshot(dependencies.getClient(), controller.signal)
|
||||
.then((snapshot) => {
|
||||
if (revision === requestRevision) set({ state: "ready", models: snapshot.models, cacheScope: dreaminaModelCacheScopeKey(snapshot) });
|
||||
return snapshot.models;
|
||||
})
|
||||
.catch((error) => {
|
||||
if (revision === requestRevision) set({ state: "error", models: [], cacheScope: undefined });
|
||||
throw error;
|
||||
})
|
||||
.finally(() => {
|
||||
clearTimeout(timer);
|
||||
if (activeRequest?.revision === revision) activeRequest = null;
|
||||
});
|
||||
activeRequest = { revision, controller, promise };
|
||||
}
|
||||
return waitForCatalog(activeRequest.promise, signal);
|
||||
};
|
||||
return {
|
||||
state: "idle",
|
||||
models: [],
|
||||
cacheScope: undefined,
|
||||
async sync(signal) {
|
||||
try {
|
||||
await load(true, signal);
|
||||
} catch {
|
||||
// 页面引导读取失败只投影 store 状态;生成入口的 ensureReady 会明确失败。
|
||||
}
|
||||
},
|
||||
ensureReady(signal) {
|
||||
return load(false, signal);
|
||||
},
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
function waitForCatalog<T>(promise: Promise<T>, signal?: AbortSignal): Promise<T> {
|
||||
if (!signal) return promise;
|
||||
if (signal.aborted) return Promise.reject(new DOMException("Aborted", "AbortError"));
|
||||
return new Promise<T>((resolve, reject) => {
|
||||
const abort = () => reject(new DOMException("Aborted", "AbortError"));
|
||||
signal.addEventListener("abort", abort, { once: true });
|
||||
promise.then(resolve, reject).finally(() => signal.removeEventListener("abort", abort));
|
||||
});
|
||||
}
|
||||
|
||||
export const useLocalDreaminaModelStore = createLocalDreaminaModelStore({
|
||||
getRuntimeState: () => useLocalRuntimeStore.getState(),
|
||||
getClient: getLocalRuntimeSessionClient,
|
||||
loadSnapshot: (client, signal) => getDreaminaModelCatalogSnapshotWithSessionRecovery(client, signal),
|
||||
});
|
||||
|
||||
export function useLocalDreaminaModelBootstrap() {
|
||||
const connection = useLocalRuntimeStore((state) => state.connection);
|
||||
|
||||
@@ -98,6 +98,73 @@ test("Canvas Agent UI store contains no Runtime endpoint, bearer, or session fie
|
||||
expect("session" in state).toBe(false);
|
||||
});
|
||||
|
||||
test("Canvas Agent reconnect intent persists while transient disconnects preserve the active conversation", async () => {
|
||||
const module = await import("../src/stores/canvas/use-canvas-agent-store").catch(() => ({}));
|
||||
const connectionStartingPatch = (
|
||||
module as {
|
||||
canvasAgentConnectionStartingPatch?: () => Record<string, unknown>;
|
||||
}
|
||||
).canvasAgentConnectionStartingPatch;
|
||||
const transientDisconnectPatch = (
|
||||
module as {
|
||||
canvasAgentTransientDisconnectPatch?: (activity: string, connectError: string) => Record<string, unknown>;
|
||||
}
|
||||
).canvasAgentTransientDisconnectPatch;
|
||||
const writeEnabled = (
|
||||
module as {
|
||||
writeCanvasAgentEnabledPreference?: (enabled: boolean, storage: { setItem(key: string, value: string): void }) => void;
|
||||
}
|
||||
).writeCanvasAgentEnabledPreference;
|
||||
const readEnabled = (
|
||||
module as {
|
||||
readCanvasAgentEnabledPreference?: (storage: { getItem(key: string): string | null }) => boolean;
|
||||
}
|
||||
).readCanvasAgentEnabledPreference;
|
||||
|
||||
expect(typeof connectionStartingPatch).toBe("function");
|
||||
expect(typeof transientDisconnectPatch).toBe("function");
|
||||
expect(typeof writeEnabled).toBe("function");
|
||||
expect(typeof readEnabled).toBe("function");
|
||||
if (!connectionStartingPatch || !transientDisconnectPatch || !writeEnabled || !readEnabled) return;
|
||||
|
||||
const durable = {
|
||||
messages: [{ id: "message-1", role: "assistant", text: "kept" }],
|
||||
threads: [{ id: "thread-1", preview: "kept" }],
|
||||
activeThreadId: "thread-1",
|
||||
workspacePath: "D:/workspace",
|
||||
pendingTool: { requestId: "tool-1", name: "canvas_get_state" },
|
||||
};
|
||||
const starting = { ...durable, ...connectionStartingPatch() };
|
||||
expect(starting).toMatchObject({ enabled: true, connected: false, activity: "连接中", ...durable });
|
||||
|
||||
const reconnecting = { ...starting, ...transientDisconnectPatch("正在重连", "连接已断开") };
|
||||
expect(reconnecting).toMatchObject({ enabled: true, connected: false, activity: "正在重连", connectError: "连接已断开", ...durable });
|
||||
|
||||
const values = new Map<string, string>();
|
||||
const storage = {
|
||||
getItem: (key: string) => values.get(key) ?? null,
|
||||
setItem: (key: string, value: string) => values.set(key, value),
|
||||
};
|
||||
writeEnabled(true, storage);
|
||||
expect(readEnabled(storage)).toBe(true);
|
||||
writeEnabled(false, storage);
|
||||
expect(readEnabled(storage)).toBe(false);
|
||||
});
|
||||
|
||||
test("Canvas Agent status shows reconnecting instead of a stale connection failure", async () => {
|
||||
const module = await import("../src/stores/canvas/use-canvas-agent-store").catch(() => ({}));
|
||||
const statusText = (
|
||||
module as {
|
||||
canvasAgentConnectionStatusText?: (state: { enabled: boolean; connected: boolean; activity: string; connectError: string }) => string;
|
||||
}
|
||||
).canvasAgentConnectionStatusText;
|
||||
expect(typeof statusText).toBe("function");
|
||||
if (!statusText) return;
|
||||
|
||||
expect(statusText({ enabled: true, connected: false, activity: "正在重连", connectError: "连接已断开" })).toBe("正在重连");
|
||||
expect(statusText({ enabled: true, connected: false, activity: "连接失败", connectError: "首次连接失败" })).toBe("连接失败");
|
||||
});
|
||||
|
||||
test("Canvas connection reuses the shared Runtime store and requires the canvas module", async () => {
|
||||
const module = await import("../src/lib/canvas/local-runtime-connection");
|
||||
const prepare = (
|
||||
|
||||
@@ -335,6 +335,140 @@ test("shared Create and Canvas task projection exposes queued, submitted, genera
|
||||
expect(distinctUpdates[4]?.resultJson).toContain('"mode":"video"');
|
||||
});
|
||||
|
||||
test("the first local Dreamina submission waits for catalog readiness before invoking the paid Runtime path", async () => {
|
||||
const order: string[] = [];
|
||||
let release!: () => void;
|
||||
const readyGate = new Promise<void>((resolve) => {
|
||||
release = resolve;
|
||||
});
|
||||
const pending = runBackendGenerationTask(
|
||||
{
|
||||
mode: "video",
|
||||
prompt: "first visit",
|
||||
config: { ...defaultConfig, model: "local:dreamina-cli:seedance2.0mini", videoSeconds: "4", vquality: "720" },
|
||||
},
|
||||
{
|
||||
createTask: async () => {
|
||||
throw new Error("must not create backend task");
|
||||
},
|
||||
waitTask: async () => {
|
||||
throw new Error("must not wait backend task");
|
||||
},
|
||||
ensureLocalDreaminaReady: async () => {
|
||||
order.push("catalog-start");
|
||||
await readyGate;
|
||||
order.push("catalog-ready");
|
||||
},
|
||||
runLocal: async () => {
|
||||
order.push("paid-runtime");
|
||||
return { mode: "video", video: { dataUrl: "data:video/mp4;base64,AAAA", mimeType: "video/mp4", bytes: 3 } };
|
||||
},
|
||||
createId: () => "dreamina-first-ready-0001",
|
||||
now: () => "2026-08-18T00:00:00.000Z",
|
||||
},
|
||||
);
|
||||
|
||||
await Promise.resolve();
|
||||
expect(order).toEqual(["catalog-start"]);
|
||||
release();
|
||||
await pending;
|
||||
expect(order).toEqual(["catalog-start", "catalog-ready", "paid-runtime"]);
|
||||
});
|
||||
|
||||
test("the first local Dreamina batch shares one catalog readiness gate before any paid Runtime call", async () => {
|
||||
const order: string[] = [];
|
||||
let release!: () => void;
|
||||
const readyGate = new Promise<void>((resolve) => {
|
||||
release = resolve;
|
||||
});
|
||||
const pending = runBackendGenerationTaskBatch(
|
||||
{
|
||||
mode: "image",
|
||||
prompt: "first batch",
|
||||
config: { ...defaultConfig, model: "local:dreamina-cli:5.0Pro", count: "1" },
|
||||
count: 3,
|
||||
},
|
||||
{
|
||||
createTask: async () => {
|
||||
throw new Error("must not create backend task");
|
||||
},
|
||||
waitTask: async () => {
|
||||
throw new Error("must not wait backend task");
|
||||
},
|
||||
ensureLocalDreaminaReady: async () => {
|
||||
order.push("catalog-start");
|
||||
await readyGate;
|
||||
order.push("catalog-ready");
|
||||
},
|
||||
runLocal: async () => {
|
||||
order.push("paid-runtime");
|
||||
return { mode: "image", images: [{ dataUrl: "data:image/png;base64,AAAA", mimeType: "image/png", bytes: 3 }] };
|
||||
},
|
||||
createId: () => `dreamina-first-batch-${order.length}`,
|
||||
now: () => "2026-08-18T00:00:00.000Z",
|
||||
},
|
||||
);
|
||||
|
||||
await Promise.resolve();
|
||||
expect(order).toEqual(["catalog-start"]);
|
||||
release();
|
||||
const settled = await pending;
|
||||
expect(settled.every((entry) => entry.status === "fulfilled")).toBe(true);
|
||||
expect(order).toEqual(["catalog-start", "catalog-ready", "paid-runtime", "paid-runtime", "paid-runtime"]);
|
||||
});
|
||||
|
||||
test("local Dreamina terminal projection keeps the latest receipt, observation, and durable outputs", async () => {
|
||||
const updates: GenerationTask[] = [];
|
||||
const timestamp = "2026-08-18T00:00:00.000Z";
|
||||
await runBackendGenerationTask(
|
||||
{
|
||||
mode: "video",
|
||||
prompt: "late result",
|
||||
config: { ...defaultConfig, model: "local:dreamina-cli:seedance2.0mini", videoSeconds: "4", vquality: "720" },
|
||||
onTaskUpdate: (task) => updates.push(task),
|
||||
},
|
||||
{
|
||||
createTask: async () => {
|
||||
throw new Error("must not create backend task");
|
||||
},
|
||||
waitTask: async () => {
|
||||
throw new Error("must not wait backend task");
|
||||
},
|
||||
runLocal: async (input, _signal, onTaskUpdate) => {
|
||||
onTaskUpdate?.({
|
||||
id: input.idempotencyKey!,
|
||||
provider: "dreamina-cli",
|
||||
mode: "video",
|
||||
operation: "text2video",
|
||||
model: "seedance2.0mini",
|
||||
status: "succeeded",
|
||||
stage: "succeeded",
|
||||
progress: 100,
|
||||
receiptRecorded: true,
|
||||
officialStatus: "completed",
|
||||
providerObservation: { source: "query_result", status: "completed", observedAt: timestamp },
|
||||
outputs: [{ outputIndex: 0, mediaType: "video", materializedAssetId: "asset-late-0001" }],
|
||||
result: { mode: "video", video: { dataUrl: "data:video/mp4;base64,AAAA", mimeType: "video/mp4", bytes: 3 } },
|
||||
createdAt: timestamp,
|
||||
updatedAt: timestamp,
|
||||
});
|
||||
return { mode: "video", video: { dataUrl: "data:video/mp4;base64,AAAA", mimeType: "video/mp4", bytes: 3 } };
|
||||
},
|
||||
createId: () => "dreamina-latest-context-0001",
|
||||
now: () => timestamp,
|
||||
},
|
||||
);
|
||||
|
||||
expect(updates.at(-1)).toMatchObject({
|
||||
status: "succeeded",
|
||||
stage: "local_cli_succeeded",
|
||||
receiptRecorded: true,
|
||||
officialStatus: "completed",
|
||||
outputs: [{ outputIndex: 0, mediaType: "video", materializedAssetId: "asset-late-0001" }],
|
||||
});
|
||||
expect(updates.at(-1)?.resultJson).toContain('"mode":"video"');
|
||||
});
|
||||
|
||||
test("an explicitly cancelled Dreamina task stays cancelled instead of being projected as failed", async () => {
|
||||
const updates: Array<{ status: string; stage?: string }> = [];
|
||||
const timestamp = "2026-08-12T00:00:00.000Z";
|
||||
|
||||
@@ -213,6 +213,125 @@ test("effective config projects an asynchronously arriving Dreamina catalog with
|
||||
expect(defaultConfig.channels.some((channel) => channel.id === "local:dreamina-cli")).toBe(false);
|
||||
});
|
||||
|
||||
test("Dreamina catalog readiness is loading immediately and shares one in-flight bootstrap", async () => {
|
||||
const module = await import("../src/stores/use-local-dreamina-model-store").catch(() => ({}));
|
||||
const createStore = (
|
||||
module as {
|
||||
createLocalDreaminaModelStore?: (dependencies: Record<string, unknown>) => {
|
||||
getState(): {
|
||||
state: string;
|
||||
models: Array<{ id: string }>;
|
||||
ensureReady(signal?: AbortSignal): Promise<Array<{ id: string }>>;
|
||||
};
|
||||
};
|
||||
}
|
||||
).createLocalDreaminaModelStore;
|
||||
expect(typeof createStore).toBe("function");
|
||||
if (!createStore) return;
|
||||
|
||||
let requests = 0;
|
||||
let release!: () => void;
|
||||
const gate = new Promise<void>((resolve) => {
|
||||
release = resolve;
|
||||
});
|
||||
const store = createStore({
|
||||
getRuntimeState: () => ({ connection: "connected", modules: [{ id: "dreamina", scopes: ["dreamina:models"] }] }),
|
||||
getClient: () => ({ request: async () => catalogResponse() }),
|
||||
loadSnapshot: async () => {
|
||||
requests += 1;
|
||||
await gate;
|
||||
return {
|
||||
accountBinding: "a".repeat(64),
|
||||
sessionEpoch: 3,
|
||||
models: [
|
||||
{
|
||||
provider: "dreamina-cli",
|
||||
id: "seedance2.0mini",
|
||||
displayName: "seedance2.0mini",
|
||||
modality: "video",
|
||||
operations: ["text-to-video"],
|
||||
adapterSupported: true,
|
||||
accountEntitlement: "unknown",
|
||||
currentlyObservedAvailable: "unknown",
|
||||
settings: { aliases: [], aspects: ["16:9"], maxReferenceImages: 9, minDuration: 4, maxDuration: 15, tiers: ["720p"] },
|
||||
source: "runtime-execution-contract",
|
||||
},
|
||||
],
|
||||
};
|
||||
},
|
||||
});
|
||||
|
||||
const first = store.getState().ensureReady();
|
||||
const second = store.getState().ensureReady();
|
||||
expect(store.getState().state).toBe("loading");
|
||||
expect(requests).toBe(1);
|
||||
release();
|
||||
const [firstModels, secondModels] = await Promise.all([first, second]);
|
||||
expect(firstModels.map((item) => item.id)).toEqual(["seedance2.0mini"]);
|
||||
expect(secondModels).toEqual(firstModels);
|
||||
expect(store.getState()).toMatchObject({ state: "ready", cacheScope: `${"a".repeat(64)}:3` });
|
||||
expect(requests).toBe(1);
|
||||
});
|
||||
|
||||
test("Dreamina catalog timeout releases a hung shared request so generation can recover without reload", async () => {
|
||||
const { createLocalDreaminaModelStore } = await import("../src/stores/use-local-dreamina-model-store");
|
||||
let requests = 0;
|
||||
const store = createLocalDreaminaModelStore({
|
||||
getRuntimeState: () => ({ connection: "connected", modules: [{ id: "dreamina", scopes: ["dreamina:models"] }] }),
|
||||
getClient: () => ({ request: async () => catalogResponse() }),
|
||||
requestTimeoutMs: 10,
|
||||
loadSnapshot: async (_client, signal) => {
|
||||
requests += 1;
|
||||
if (requests > 1) return catalogSnapshot("seedance-recovered", 4);
|
||||
if (!signal) return await new Promise<ReturnType<typeof catalogSnapshot>>(() => undefined);
|
||||
return await new Promise<ReturnType<typeof catalogSnapshot>>((_resolve, reject) => {
|
||||
signal.addEventListener("abort", () => reject(new DOMException("Aborted", "AbortError")), { once: true });
|
||||
});
|
||||
},
|
||||
});
|
||||
|
||||
const caller = new AbortController();
|
||||
const first = store.getState().ensureReady(caller.signal);
|
||||
caller.abort();
|
||||
await expect(first).rejects.toMatchObject({ name: "AbortError" });
|
||||
await Bun.sleep(20);
|
||||
|
||||
const recovered = await Promise.race([store.getState().ensureReady(), Bun.sleep(40).then(() => "stuck" as const)]);
|
||||
expect(recovered).not.toBe("stuck");
|
||||
expect(recovered).toMatchObject([{ id: "seedance-recovered" }]);
|
||||
expect(store.getState()).toMatchObject({ state: "ready", cacheScope: `${"a".repeat(64)}:4` });
|
||||
expect(requests).toBe(2);
|
||||
});
|
||||
|
||||
test("Dreamina catalog forced resync ignores a late result from the previous Runtime scope", async () => {
|
||||
const { createLocalDreaminaModelStore } = await import("../src/stores/use-local-dreamina-model-store");
|
||||
let requests = 0;
|
||||
let releaseOld!: (snapshot: ReturnType<typeof catalogSnapshot>) => void;
|
||||
const oldRequest = new Promise<ReturnType<typeof catalogSnapshot>>((resolve) => {
|
||||
releaseOld = resolve;
|
||||
});
|
||||
const store = createLocalDreaminaModelStore({
|
||||
getRuntimeState: () => ({ connection: "connected", modules: [{ id: "dreamina", scopes: ["dreamina:models"] }] }),
|
||||
getClient: () => ({ request: async () => catalogResponse() }),
|
||||
requestTimeoutMs: 100,
|
||||
loadSnapshot: async () => {
|
||||
requests += 1;
|
||||
return requests === 1 ? oldRequest : catalogSnapshot("seedance-current", 5);
|
||||
},
|
||||
});
|
||||
|
||||
const oldSync = store.getState().sync();
|
||||
const currentSync = store.getState().sync();
|
||||
const currentResult = await Promise.race([currentSync.then(() => "ready" as const), Bun.sleep(40).then(() => "stuck" as const)]);
|
||||
expect(currentResult).toBe("ready");
|
||||
expect(requests).toBe(2);
|
||||
expect(store.getState()).toMatchObject({ state: "ready", cacheScope: `${"a".repeat(64)}:5`, models: [{ id: "seedance-current" }] });
|
||||
|
||||
releaseOld(catalogSnapshot("seedance-stale", 3));
|
||||
await oldSync;
|
||||
expect(store.getState()).toMatchObject({ state: "ready", cacheScope: `${"a".repeat(64)}:5`, models: [{ id: "seedance-current" }] });
|
||||
});
|
||||
|
||||
test("effective config removes custom channels when administrators disable them", async () => {
|
||||
const { createModelChannel, effectiveConfigForCustomChannels, normalizeConfigSnapshot } = await import("../src/stores/use-config-store");
|
||||
const config = normalizeConfigSnapshot({
|
||||
@@ -264,3 +383,24 @@ function catalogResponse() {
|
||||
{ status: 200, headers: { "content-type": "application/json" } },
|
||||
);
|
||||
}
|
||||
|
||||
function catalogSnapshot(id: string, sessionEpoch: number) {
|
||||
return {
|
||||
accountBinding: "a".repeat(64),
|
||||
sessionEpoch,
|
||||
models: [
|
||||
{
|
||||
provider: "dreamina-cli" as const,
|
||||
id,
|
||||
displayName: id,
|
||||
modality: "video" as const,
|
||||
operations: ["text-to-video" as const],
|
||||
adapterSupported: true,
|
||||
accountEntitlement: "unknown" as const,
|
||||
currentlyObservedAvailable: "unknown" as const,
|
||||
settings: { aliases: [], aspects: ["16:9"], maxReferenceImages: 9, minDuration: 4, maxDuration: 15, tiers: ["720p"] },
|
||||
source: "runtime-execution-contract" as const,
|
||||
},
|
||||
],
|
||||
};
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user