fix(runtime): 运行恢复整合 - 合并 PR #253 本地任务恢复改动
This commit is contained in:
@@ -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);
|
||||
|
||||
Reference in New Issue
Block a user