Browse Source

删除单个记录

feature/260608
TianYun 1 month ago
parent
commit
e2879d4393
  1. 72
      src/components/__tests__/GenerateAudioNode.test.tsx
  2. 59
      src/components/__tests__/GenerateImageNode.test.tsx
  3. 56
      src/components/__tests__/GenerateVideoNode.test.tsx
  4. 76
      src/components/nodes/GenerateAudioNode.tsx
  5. 56
      src/components/nodes/GenerateImageNode.tsx
  6. 57
      src/components/nodes/GenerateVideoNode.tsx
  7. 37
      src/hooks/__tests__/useCanvasWorkflowLoader.test.ts
  8. 22
      src/hooks/useCanvasWorkflowLoader.ts
  9. 8
      src/i18n/index.tsx
  10. 1
      src/store/execution/generateAudioExecutor.ts
  11. 1
      src/types/nodes.ts

72
src/components/__tests__/GenerateAudioNode.test.tsx

@ -1,5 +1,5 @@
import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
import { render, screen, fireEvent } from "@testing-library/react";
import { render, screen, fireEvent, waitFor } from "@testing-library/react";
import { GenerateAudioNode } from "@/components/nodes/GenerateAudioNode";
import { ReactFlowProvider } from "@xyflow/react";
import { GenerateAudioNodeData, ProviderSettings } from "@/types";
@ -151,6 +151,7 @@ describe("GenerateAudioNode", () => {
generationsPath: "/test/generations",
isRunning: false,
currentNodeIds: [],
runningNodeIds: new Set<string>(),
groups: {},
nodes: [],
recentModels: [],
@ -290,11 +291,11 @@ describe("GenerateAudioNode", () => {
</TestWrapper>
);
const clearButton = screen.getByTitle("Clear audio");
const clearButton = screen.getByTitle("清除音频");
expect(clearButton).toBeInTheDocument();
});
it("should call updateNodeData to clear audio when clear button is clicked", () => {
it("should call updateNodeData to clear audio when clear button is clicked", async () => {
render(
<TestWrapper>
<GenerateAudioNode {...createNodeProps({
@ -304,15 +305,66 @@ describe("GenerateAudioNode", () => {
</TestWrapper>
);
const clearButton = screen.getByTitle("Clear audio");
const clearButton = screen.getByTitle("清除音频");
fireEvent.click(clearButton);
expect(mockUpdateNodeData).toHaveBeenCalledWith("test-audio-1", {
outputAudio: null,
status: "idle",
error: null,
duration: null,
format: null,
await waitFor(() => {
expect(mockUpdateNodeData).toHaveBeenCalledWith("test-audio-1", expect.objectContaining({
outputAudio: null,
audioHistory: [],
selectedAudioHistoryIndex: 0,
status: "idle",
error: null,
duration: null,
format: null,
}));
});
});
it("should delete the current audio page and select the next audio", async () => {
mockFetch.mockResolvedValue({
ok: true,
json: () => Promise.resolve({ success: true, audio: "data:audio/mp3;base64,next" }),
});
const firstAudio = {
id: "audio1",
audio: "data:audio/mp3;base64,current",
timestamp: Date.now(),
prompt: "test1",
model: "elevenlabs",
};
const secondAudio = {
id: "audio2",
audio: "data:audio/mp3;base64,next",
timestamp: Date.now() + 1,
prompt: "test2",
model: "elevenlabs",
};
render(
<TestWrapper>
<GenerateAudioNode {...createNodeProps({
outputAudio: "data:audio/mp3;base64,current",
status: "complete",
audioHistory: [firstAudio, secondAudio],
selectedAudioHistoryIndex: 0,
})} />
</TestWrapper>
);
fireEvent.click(screen.getByTitle("清除音频"));
await waitFor(() => {
expect(mockUpdateNodeData).toHaveBeenCalledWith("test-audio-1", expect.objectContaining({
outputAudio: "data:audio/mp3;base64,next",
audioHistory: [secondAudio],
selectedAudioHistoryIndex: 0,
status: "idle",
error: null,
duration: null,
format: null,
}));
});
});
});

59
src/components/__tests__/GenerateImageNode.test.tsx

@ -159,6 +159,7 @@ describe("GenerateImageNode", () => {
saveDirectoryPath: null,
isRunning: false,
currentNodeIds: [],
runningNodeIds: new Set<string>(),
groups: {},
nodes: [],
recentModels: [],
@ -429,7 +430,7 @@ describe("GenerateImageNode", () => {
expect(screen.getByAltText("Generated preview")).toHaveAttribute("src", "data:image/png;base64,abc123");
});
it("should call updateNodeData to clear image when clear button is clicked", () => {
it("should call updateNodeData to clear image when clear button is clicked", async () => {
render(
<TestWrapper>
<GenerateImageNode {...createNodeProps({
@ -441,10 +442,57 @@ describe("GenerateImageNode", () => {
const clearButton = screen.getByTitle("Clear image");
fireEvent.click(clearButton);
expect(mockUpdateNodeData).toHaveBeenCalledWith("test-node-1", {
outputImage: null,
status: "idle",
error: null,
await waitFor(() => {
expect(mockUpdateNodeData).toHaveBeenCalledWith("test-node-1", expect.objectContaining({
outputImage: null,
imageHistory: [],
selectedHistoryIndex: 0,
status: "idle",
error: null,
}));
});
});
it("should delete the current image page and select the next image", async () => {
const firstImage = {
id: "img1",
image: "data:image/png;base64,current",
timestamp: Date.now(),
prompt: "test1",
aspectRatio: "1:1" as const,
model: "nano-banana",
};
const secondImage = {
id: "img2",
image: "data:image/png;base64,next",
previewImg: "data:image/png;base64,next-preview",
timestamp: Date.now() + 1,
prompt: "test2",
aspectRatio: "1:1" as const,
model: "nano-banana",
};
render(
<TestWrapper>
<GenerateImageNode {...createNodeProps({
outputImage: firstImage.image,
imageHistory: [firstImage, secondImage],
selectedHistoryIndex: 0,
})} />
</TestWrapper>
);
fireEvent.click(screen.getByTitle("Clear image"));
await waitFor(() => {
expect(mockUpdateNodeData).toHaveBeenCalledWith("test-node-1", expect.objectContaining({
outputImage: secondImage.image,
previewImg: secondImage.previewImg,
imageHistory: [secondImage],
selectedHistoryIndex: 0,
status: "idle",
error: null,
}));
});
});
});
@ -542,6 +590,7 @@ describe("GenerateImageNode", () => {
saveDirectoryPath: "browserfs://root/project",
isRunning: false,
currentNodeIds: [],
runningNodeIds: new Set<string>(),
groups: {},
nodes: [],
recentModels: [],

56
src/components/__tests__/GenerateVideoNode.test.tsx

@ -159,6 +159,7 @@ describe("GenerateVideoNode", () => {
generationsPath: "/test/generations",
isRunning: false,
currentNodeIds: [],
runningNodeIds: new Set<string>(),
groups: {},
nodes: [],
recentModels: [],
@ -503,7 +504,7 @@ describe("GenerateVideoNode", () => {
expect(container.querySelectorAll("video")).toHaveLength(2);
});
it("should call updateNodeData to clear video when clear button is clicked", () => {
it("should call updateNodeData to clear video when clear button is clicked", async () => {
render(
<TestWrapper>
<GenerateVideoNode {...createNodeProps({
@ -515,10 +516,53 @@ describe("GenerateVideoNode", () => {
const clearButton = screen.getByTitle("Clear video");
fireEvent.click(clearButton);
expect(mockUpdateNodeData).toHaveBeenCalledWith("test-node-1", {
outputVideo: null,
status: "idle",
error: null,
await waitFor(() => {
expect(mockUpdateNodeData).toHaveBeenCalledWith("test-node-1", expect.objectContaining({
outputVideo: null,
videoHistory: [],
selectedVideoHistoryIndex: 0,
status: "idle",
error: null,
}));
});
});
it("should delete the current video page and select the next video", async () => {
const firstVideo = {
id: "vid1",
video: "data:video/mp4;base64,current",
timestamp: Date.now(),
prompt: "test1",
model: "kling-video/v1",
};
const secondVideo = {
id: "vid2",
video: "data:video/mp4;base64,next",
timestamp: Date.now() + 1,
prompt: "test2",
model: "kling-video/v1",
};
render(
<TestWrapper>
<GenerateVideoNode {...createNodeProps({
outputVideo: firstVideo.video,
videoHistory: [firstVideo, secondVideo],
selectedVideoHistoryIndex: 0,
})} />
</TestWrapper>
);
fireEvent.click(screen.getByTitle("Clear video"));
await waitFor(() => {
expect(mockUpdateNodeData).toHaveBeenCalledWith("test-node-1", expect.objectContaining({
outputVideo: secondVideo.video,
videoHistory: [secondVideo],
selectedVideoHistoryIndex: 0,
status: "idle",
error: null,
}));
});
});
});
@ -569,6 +613,7 @@ describe("GenerateVideoNode", () => {
generationsPath: null,
isRunning: false,
currentNodeIds: [],
runningNodeIds: new Set<string>(),
groups: {},
nodes: [],
recentModels: [],
@ -609,6 +654,7 @@ describe("GenerateVideoNode", () => {
generationsPath: null,
isRunning: false,
currentNodeIds: [],
runningNodeIds: new Set<string>(),
groups: {},
nodes: [],
recentModels: [],

76
src/components/nodes/GenerateAudioNode.tsx

@ -143,15 +143,6 @@ export function GenerateAudioNodeView({
isLoadingWaveform,
});
const handleClearAudio = useCallback(() => {
if (audioRef.current) {
audioRef.current.pause();
audioRef.current = null;
}
setAudioBlob(null);
updateNodeData(id, { outputAudio: null, status: "idle", error: null, duration: null, format: null });
}, [id, updateNodeData, audioRef]);
const handleParametersChange = useCallback(
(parameters: Record<string, unknown>) => {
updateNodeData(id, buildGenerationParametersPatch(nodeConfig, parameters));
@ -260,6 +251,69 @@ export function GenerateAudioNodeView({
}
}, [generationsPath]);
const handleClearAudio = useCallback(async () => {
if (audioRef.current) {
audioRef.current.pause();
audioRef.current = null;
}
setAudioBlob(null);
const history = nodeData.audioHistory || [];
if (history.length === 0) {
updateNodeData(id, {
outputAudio: null,
outputAudioRef: undefined,
audioHistory: [],
selectedAudioHistoryIndex: 0,
status: "idle",
error: null,
duration: null,
format: null,
});
return;
}
const currentIndex = Math.min(Math.max(nodeData.selectedAudioHistoryIndex || 0, 0), history.length - 1);
const nextHistory = history.filter((_, index) => index !== currentIndex);
if (nextHistory.length === 0) {
updateNodeData(id, {
outputAudio: null,
outputAudioRef: undefined,
audioHistory: [],
selectedAudioHistoryIndex: 0,
status: "idle",
error: null,
duration: null,
format: null,
});
return;
}
const nextIndex = Math.min(currentIndex, nextHistory.length - 1);
const nextItem = nextHistory[nextIndex];
setIsLoadingCarouselAudio(true);
const nextAudio = nextItem.audio ?? await loadAudioById(nextItem.id);
setIsLoadingCarouselAudio(false);
updateNodeData(id, {
outputAudio: nextAudio,
outputAudioRef: undefined,
audioHistory: nextHistory,
selectedAudioHistoryIndex: nextIndex,
status: "idle",
error: nextAudio ? null : "Selected audio could not be loaded",
duration: null,
format: null,
});
}, [
audioRef,
id,
loadAudioById,
nodeData.audioHistory,
nodeData.selectedAudioHistoryIndex,
updateNodeData,
]);
// Carousel navigation handlers
const handleCarouselPrevious = useCallback(async () => {
const history = nodeData.audioHistory || [];
@ -270,7 +324,7 @@ export function GenerateAudioNodeView({
const audioItem = history[newIndex];
setIsLoadingCarouselAudio(true);
const audio = await loadAudioById(audioItem.id);
const audio = audioItem.audio ?? await loadAudioById(audioItem.id);
setIsLoadingCarouselAudio(false);
if (audio) {
@ -292,7 +346,7 @@ export function GenerateAudioNodeView({
const audioItem = history[newIndex];
setIsLoadingCarouselAudio(true);
const audio = await loadAudioById(audioItem.id);
const audio = audioItem.audio ?? await loadAudioById(audioItem.id);
setIsLoadingCarouselAudio(false);
if (audio) {

56
src/components/nodes/GenerateImageNode.tsx

@ -406,11 +406,7 @@ export function GenerateImageNodeView({ id, data, selected }: GenerateImageNodeV
[id, nodeConfig, updateNodeData]
);
const { setNodes } = useReactFlow();
const handleClearImage = useCallback(() => {
updateNodeData(id, { outputImage: null, previewImg: undefined, status: "idle", error: null });
}, [id, updateNodeData]);
const regenerateNode = useWorkflowStore((state) => state.regenerateNode);
const isRunning = useWorkflowStore((state) => state.runningNodeIds.has(id));
@ -457,6 +453,58 @@ export function GenerateImageNodeView({ id, data, selected }: GenerateImageNodeV
}
}, [generationsPath, saveDirectoryPath]);
const handleClearImage = useCallback(async () => {
const history = nodeData.imageHistory || [];
if (history.length === 0) {
updateNodeData(id, {
outputImage: null,
previewImg: undefined,
outputImageRef: undefined,
outputImageStorageStatus: undefined,
imageHistory: [],
selectedHistoryIndex: 0,
status: "idle",
error: null,
});
return;
}
const currentIndex = Math.min(Math.max(nodeData.selectedHistoryIndex || 0, 0), history.length - 1);
const nextHistory = history.filter((_, index) => index !== currentIndex);
if (nextHistory.length === 0) {
updateNodeData(id, {
outputImage: null,
previewImg: undefined,
outputImageRef: undefined,
outputImageStorageStatus: undefined,
imageHistory: [],
selectedHistoryIndex: 0,
status: "idle",
error: null,
});
return;
}
const nextIndex = Math.min(currentIndex, nextHistory.length - 1);
const nextItem = nextHistory[nextIndex];
setIsLoadingCarouselImage(true);
const nextImage = nextItem.image ?? await loadImageById(nextItem.id);
setIsLoadingCarouselImage(false);
updateNodeData(id, {
outputImage: nextImage,
previewImg: nextItem.previewImg,
outputImageRef: undefined,
outputImageStorageStatus: nextImage
? /^https?:\/\//i.test(nextImage) ? "remote-only" : "localized"
: undefined,
imageHistory: nextHistory,
selectedHistoryIndex: nextIndex,
status: "idle",
error: nextImage ? null : "Selected image could not be loaded",
});
}, [id, loadImageById, nodeData.imageHistory, nodeData.selectedHistoryIndex, updateNodeData]);
const handleCarouselPrevious = useCallback(async () => {
const history = nodeData.imageHistory || [];
if (history.length === 0 || isLoadingCarouselImage) return;

57
src/components/nodes/GenerateVideoNode.tsx

@ -366,10 +366,6 @@ export function GenerateVideoNodeView({ id, data, selected }: GenerateVideoNodeV
[id, modelOptions, nodeData, updateNodeData]
);
const handleClearVideo = useCallback(() => {
updateNodeData(id, { outputVideo: null, status: "idle", error: null });
}, [id, updateNodeData]);
const handleParametersChange = useCallback(
(parameters: Record<string, unknown>) => {
updateNodeData(id, buildGenerationParametersPatch(nodeConfig, parameters));
@ -441,6 +437,59 @@ export function GenerateVideoNodeView({ id, data, selected }: GenerateVideoNodeV
}
}, [generationsPath]);
const handleClearVideo = useCallback(async () => {
const history = nodeData.videoHistory || [];
if (history.length === 0) {
updateNodeData(id, {
outputVideo: null,
outputVideoRef: undefined,
outputVideoRemoteUrl: undefined,
outputVideoRemoteExpiresAt: undefined,
outputVideoStorageStatus: undefined,
videoHistory: [],
selectedVideoHistoryIndex: 0,
status: "idle",
error: null,
});
return;
}
const currentIndex = Math.min(Math.max(nodeData.selectedVideoHistoryIndex || 0, 0), history.length - 1);
const nextHistory = history.filter((_, index) => index !== currentIndex);
if (nextHistory.length === 0) {
updateNodeData(id, {
outputVideo: null,
outputVideoRef: undefined,
outputVideoRemoteUrl: undefined,
outputVideoRemoteExpiresAt: undefined,
outputVideoStorageStatus: undefined,
videoHistory: [],
selectedVideoHistoryIndex: 0,
status: "idle",
error: null,
});
return;
}
const nextIndex = Math.min(currentIndex, nextHistory.length - 1);
const nextItem = nextHistory[nextIndex];
setIsLoadingCarouselVideo(true);
const nextVideo = nextItem.video ?? await loadVideoById(nextItem.id);
setIsLoadingCarouselVideo(false);
updateNodeData(id, {
outputVideo: nextVideo,
outputVideoRef: undefined,
outputVideoRemoteUrl: nextVideo && /^https?:\/\//i.test(nextVideo) ? nextVideo : undefined,
outputVideoRemoteExpiresAt: undefined,
outputVideoStorageStatus: nextVideo && /^https?:\/\//i.test(nextVideo) ? "remote-only" : undefined,
videoHistory: nextHistory,
selectedVideoHistoryIndex: nextIndex,
status: "idle",
error: nextVideo ? null : "Selected video could not be loaded",
});
}, [id, loadVideoById, nodeData.selectedVideoHistoryIndex, nodeData.videoHistory, updateNodeData]);
// Carousel navigation handlers
const handleCarouselPrevious = useCallback(async () => {
const history = nodeData.videoHistory || [];

37
src/hooks/__tests__/useCanvasWorkflowLoader.test.ts

@ -9,6 +9,7 @@ const setCurrentCanvasWorkflow = vi.fn();
const clearCurrentCanvasWorkflow = vi.fn();
const setIsLoadingCanvasWorkflow = vi.fn();
const messageError = vi.fn();
const routerReplace = vi.fn();
vi.mock("antd", () => ({
App: {
@ -24,6 +25,12 @@ vi.mock("@/lib/canvasWorkflowApi", () => ({
getCanvasWorkflowDetail: vi.fn(),
}));
vi.mock("next/navigation", () => ({
useRouter: () => ({
replace: routerReplace,
}),
}));
vi.mock("@/store/workflowStore", () => ({
useWorkflowStore: (selector: (state: {
loadWorkflow: typeof loadWorkflow;
@ -85,5 +92,35 @@ describe("useCanvasWorkflowLoader", () => {
title: "Remote Flow",
description: "Remote description",
});
expect(routerReplace).not.toHaveBeenCalled();
});
it("redirects home when loading a workflow by id fails", async () => {
vi.mocked(getCanvasWorkflowDetail).mockRejectedValueOnce(new Error("Workflow not found"));
renderHook(() => useCanvasWorkflowLoader("345"));
await waitFor(() => {
expect(routerReplace).toHaveBeenCalledWith("/");
});
expect(getCanvasWorkflowDetail).toHaveBeenCalledWith(345);
expect(clearCurrentCanvasWorkflow).toHaveBeenCalled();
expect(clearWorkflow).toHaveBeenCalled();
expect(messageError).toHaveBeenCalledWith("Workflow not found");
expect(setCurrentCanvasWorkflow).not.toHaveBeenCalled();
});
it("redirects home when the workflow id is invalid", async () => {
renderHook(() => useCanvasWorkflowLoader("abc"));
await waitFor(() => {
expect(routerReplace).toHaveBeenCalledWith("/");
});
expect(getCanvasWorkflowDetail).not.toHaveBeenCalled();
expect(clearCurrentCanvasWorkflow).toHaveBeenCalled();
expect(clearWorkflow).toHaveBeenCalled();
expect(messageError).toHaveBeenCalledWith("工作流 ID 无效");
});
});

22
src/hooks/useCanvasWorkflowLoader.ts

@ -2,6 +2,8 @@
import { useEffect, useRef } from "react";
import { App } from "antd";
import { useRouter } from "next/navigation";
import { useI18n } from "@/i18n";
import { getCanvasWorkflowDetail } from "@/lib/canvasWorkflowApi";
import { useCanvasWorkflowStore } from "@/store/canvasWorkflowStore";
import { useWorkflowStore } from "@/store/workflowStore";
@ -9,9 +11,10 @@ import { parseCanvasWorkflowProperties } from "@/utils/canvasWorkflowProperties"
export function useCanvasWorkflowLoader(workflowId: string | null) {
const { message } = App.useApp();
const router = useRouter();
const { t } = useI18n();
const loadWorkflow = useWorkflowStore((state) => state.loadWorkflow);
const clearWorkflow = useWorkflowStore((state) => state.clearWorkflow);
const setWorkFlowName = useWorkflowStore((state) => state.setWorkflowName);
const setCurrentCanvasWorkflow = useCanvasWorkflowStore((state) => state.setCurrentCanvasWorkflow);
const clearCurrentCanvasWorkflow = useCanvasWorkflowStore((state) => state.clearCurrentCanvasWorkflow);
const setIsLoadingCanvasWorkflow = useCanvasWorkflowStore((state) => state.setIsLoadingCanvasWorkflow);
@ -24,9 +27,17 @@ export function useCanvasWorkflowLoader(workflowId: string | null) {
return;
}
const redirectHome = () => {
loadedWorkflowIdRef.current = null;
clearCurrentCanvasWorkflow();
clearWorkflow();
router.replace("/");
};
const numericWorkflowId = Number(workflowId);
if (!Number.isInteger(numericWorkflowId) || numericWorkflowId <= 0) {
void message.error("工作流 ID 无效");
void message.error(t("canvasWorkflow.invalidId"));
redirectHome();
return;
}
@ -55,9 +66,9 @@ export function useCanvasWorkflowLoader(workflowId: string | null) {
})
.catch((error) => {
if (isCancelled) return;
loadedWorkflowIdRef.current = null;
const errorMessage = error instanceof Error ? error.message : "加载工作流失败";
const errorMessage = error instanceof Error ? error.message : t("canvasWorkflow.loadFailed");
void message.error(errorMessage);
redirectHome();
})
.finally(() => {
if (!isCancelled) {
@ -72,10 +83,11 @@ export function useCanvasWorkflowLoader(workflowId: string | null) {
workflowId,
loadWorkflow,
clearWorkflow,
setWorkFlowName,
setCurrentCanvasWorkflow,
clearCurrentCanvasWorkflow,
setIsLoadingCanvasWorkflow,
message,
router,
t,
]);
}

8
src/i18n/index.tsx

@ -142,6 +142,8 @@ const en = {
"workflowSave.success": "Workflow saved",
"workflowSave.updateSuccess": "Workflow updated",
"workflowSave.failed": "Failed to save workflow",
"canvasWorkflow.invalidId": "Invalid workflow ID",
"canvasWorkflow.loadFailed": "Failed to load workflow",
"workflowSave.nameRequired": "Workflow name is required",
"auth.loginNow": "Login",
"auth.loggedInUser": "Logged-in user",
@ -1146,6 +1148,8 @@ const zhCN: Dictionary = {
"workflowSave.success": "工作流已保存",
"workflowSave.updateSuccess": "工作流已更新",
"workflowSave.failed": "保存工作流失败",
"canvasWorkflow.invalidId": "工作流 ID 无效",
"canvasWorkflow.loadFailed": "加载工作流失败",
"workflowSave.nameRequired": "工作流名称必填",
"quickstart.description": "一个面向生成式 AI 流水线的节点式工作流编辑器。连接节点即可构建生成与转换图片、视频、音频和 3D 资产的流程。",
"quickstart.docs": "文档",
@ -3202,6 +3206,8 @@ const ja: Dictionary = {
};
Object.assign(zhTW, {
"canvasWorkflow.invalidId": "工作流程 ID 無效",
"canvasWorkflow.loadFailed": "載入工作流程失敗",
"group.color": "顏色",
"group.layout": "佈局",
"group.ungroup": "解組",
@ -3226,6 +3232,8 @@ Object.assign(zhTW, {
});
Object.assign(ja, {
"canvasWorkflow.invalidId": "ワークフロー ID が無効です",
"canvasWorkflow.loadFailed": "ワークフローの読み込みに失敗しました",
"group.color": "色",
"group.layout": "レイアウト",
"group.ungroup": "グループ解除",

1
src/store/execution/generateAudioExecutor.ts

@ -108,6 +108,7 @@ export async function executeGenerateAudio(
// Add to node's audio history
const newHistoryItem = {
id: audioId,
audio: audioData,
timestamp,
prompt: text || "",
model: modelToUse.modelId || "",

1
src/types/nodes.ts

@ -350,6 +350,7 @@ export interface Generate3DNodeData extends BaseNodeData {
*/
export interface CarouselAudioItem {
id: string;
audio?: string; // Current-session fallback; persisted workflows may only keep id
timestamp: number;
prompt: string;
model: string; // Model ID for audio (not ModelType since external providers)

Loading…
Cancel
Save