新增 生成过程中可以中断;

新增 生成结果上传到OSS;
新增 历史记录使用上传后的图片;
This commit is contained in:
yuantao
2025-09-15 18:30:50 +08:00
parent e325d0fc8d
commit bda049fcd1
7 changed files with 443 additions and 149 deletions

8
.env.example Normal file
View File

@@ -0,0 +1,8 @@
# 访问令牌
VITE_ACCESS_TOKEN=your_access_token_here
# Gemini API密钥
VITE_GEMINI_API_KEY=your_gemini_api_key_here
# 远程资源路径
VITE_UPLOAD_ASSET_URL=''

View File

@@ -39,6 +39,18 @@ export const HistoryPanel: React.FC = () => {
const generations = currentProject?.generations || [];
const edits = currentProject?.edits || [];
// 获取上传后的图片链接
const getUploadedImageUrl = (generationOrEdit: any, index: number) => {
if (generationOrEdit.uploadResults && generationOrEdit.uploadResults[index]) {
const uploadResult = generationOrEdit.uploadResults[index];
if (uploadResult.success && uploadResult.url) {
// 添加参数以降低图片质量
return `${uploadResult.url}?x-oss-process=image/quality,q_50`;
}
}
return null;
};
// 获取当前图像尺寸
const [imageDimensions, setImageDimensions] = React.useState<{ width: number; height: number } | null>(null);
@@ -184,6 +196,13 @@ export const HistoryPanel: React.FC = () => {
>
{generation.outputAssetsBlobUrls && generation.outputAssetsBlobUrls.length > 0 ? (
(() => {
// 首先尝试使用上传后的图片链接
const uploadedUrl = getUploadedImageUrl(generation, 0);
if (uploadedUrl) {
return <img src={uploadedUrl} alt="生成的变体" className="w-full h-full object-cover" />;
}
// 如果没有上传链接则使用原来的Blob URL
const blobUrl = generation.outputAssetsBlobUrls[0];
const decodedUrl = decodedImages[blobUrl];
if (decodedUrl) {
@@ -239,6 +258,13 @@ export const HistoryPanel: React.FC = () => {
>
{edit.outputAssetsBlobUrls && edit.outputAssetsBlobUrls.length > 0 ? (
(() => {
// 首先尝试使用上传后的图片链接
const uploadedUrl = getUploadedImageUrl(edit, 0);
if (uploadedUrl) {
return <img src={uploadedUrl} alt="编辑的变体" className="w-full h-full object-cover" />;
}
// 如果没有上传链接则使用原来的Blob URL
const blobUrl = edit.outputAssetsBlobUrls[0];
const decodedUrl = decodedImages[blobUrl];
if (decodedUrl) {
@@ -319,6 +345,35 @@ export const HistoryPanel: React.FC = () => {
</div>
</div>
{/* 上传结果 */}
{gen.uploadResults && gen.uploadResults.length > 0 && (
<div>
<h5 className="text-xs font-medium text-gray-500 mb-2"></h5>
<div className="space-y-1">
{gen.uploadResults.map((result, index) => (
<div key={index} className="text-xs">
<div className="flex justify-between">
<span> {index + 1}:</span>
<span className={result.success ? 'text-green-600' : 'text-red-600'}>
{result.success ? '成功' : '失败'}
</span>
</div>
{result.success && result.url && (
<div className="text-blue-600 truncate">
{result.url.split('/').pop()}
</div>
)}
{result.error && (
<div className="text-red-600 truncate">
{result.error}
</div>
)}
</div>
))}
</div>
</div>
)}
{/* 参考图像信息 */}
{gen.sourceAssets.length > 0 && (
<div>
@@ -355,6 +410,35 @@ export const HistoryPanel: React.FC = () => {
)}
</div>
{/* 上传结果 */}
{selectedEdit.uploadResults && selectedEdit.uploadResults.length > 0 && (
<div>
<h5 className="text-xs font-medium text-gray-500 mb-2"></h5>
<div className="space-y-1">
{selectedEdit.uploadResults.map((result, index) => (
<div key={index} className="text-xs">
<div className="flex justify-between">
<span> {index + 1}:</span>
<span className={result.success ? 'text-green-600' : 'text-red-600'}>
{result.success ? '成功' : '失败'}
</span>
</div>
{result.success && result.url && (
<div className="text-blue-600 truncate">
{result.url.split('/').pop()}
</div>
)}
{result.error && (
<div className="text-red-600 truncate">
{result.error}
</div>
)}
</div>
))}
</div>
</div>
)}
{/* 原始生成参考 */}
{parentGen && (
<div>

View File

@@ -34,8 +34,8 @@ export const PromptComposer: React.FC = () => {
clearBrushStrokes,
} = useAppStore();
const { generate } = useImageGeneration();
const { edit } = useImageEditing();
const { generate, cancelGeneration } = useImageGeneration();
const { edit, cancelEdit } = useImageEditing();
const [showAdvanced, setShowAdvanced] = useState(false);
const [showClearConfirm, setShowClearConfirm] = useState(false);
const [showHintsModal, setShowHintsModal] = useState(false);
@@ -327,23 +327,26 @@ export const PromptComposer: React.FC = () => {
{/* 生成按钮 */}
<Button
onClick={handleGenerate}
disabled={isGenerating || !currentPrompt.trim()}
className="w-full h-14 text-base font-medium"
>
{isGenerating ? (
<>
{isGenerating ? (
<div className="flex gap-2">
<Button
onClick={() => selectedTool === 'generate' ? cancelGeneration() : cancelEdit()}
className="flex-1 h-14 text-base font-medium bg-red-500 hover:bg-red-600"
>
<div className="animate-spin rounded-full h-4 w-4 border-b-2 border-gray-900 mr-2" />
...
</>
) : (
<>
<Wand2 className="h-4 w-4 mr-2" />
{selectedTool === 'generate' ? '生成' : '应用编辑'}
</>
)}
</Button>
</Button>
</div>
) : (
<Button
onClick={handleGenerate}
disabled={!currentPrompt.trim()}
className="w-full h-14 text-base font-medium"
>
<Wand2 className="h-4 w-4 mr-2" />
{selectedTool === 'generate' ? '生成' : '应用编辑'}
</Button>
)}
{/* 高级控制 */}
<div>

View File

@@ -1,23 +1,37 @@
import { useMutation } from '@tanstack/react-query';
import { geminiService, GenerationRequest, EditRequest } from '../services/geminiService';
import { useAppStore } from '../store/useAppStore';
import { generateId } from '../utils/imageUtils';
import { Generation, Edit, Asset } from '../types';
import { useToast } from '../components/ToastContext';
import React from 'react'
import { useMutation } from '@tanstack/react-query'
import { geminiService, GenerationRequest, EditRequest } from '../services/geminiService'
import { useAppStore } from '../store/useAppStore'
import { generateId } from '../utils/imageUtils'
import { Generation, Edit, Asset } from '../types'
import { useToast } from '../components/ToastContext'
import { uploadImages } from '../services/uploadService'
export const useImageGeneration = () => {
const { addGeneration, setIsGenerating, setCanvasImage } = useAppStore();
const { addToast } = useToast();
const { addGeneration, setIsGenerating, setCanvasImage } = useAppStore()
const { addToast } = useToast()
// 创建中断标志引用
const isCancelledRef = React.useRef(false)
const generateMutation = useMutation({
mutationFn: async (request: GenerationRequest) => {
const images = await geminiService.generateImage(request);
return images;
// 重置中断标志
isCancelledRef.current = false
const images = await geminiService.generateImage(request)
// 检查是否已中断
if (isCancelledRef.current) {
throw new Error('生成已中断')
}
return images
},
onMutate: () => {
setIsGenerating(true);
setIsGenerating(true)
},
onSuccess: (images, request) => {
onSuccess: async (images, request) => {
if (images.length > 0) {
const outputAssets: Asset[] = images.map((base64, index) => ({
id: generateId(),
@@ -29,6 +43,34 @@ export const useImageGeneration = () => {
checksum: base64.slice(0, 32) // 简单校验和
}));
// 获取accessToken
const accessToken = import.meta.env.VITE_ACCESS_TOKEN || '';
let uploadResults: any[] | undefined;
// 上传生成的图像
if (accessToken) {
try {
const imageUrls = outputAssets.map(asset => asset.url);
uploadResults = await uploadImages(imageUrls, accessToken);
// 检查上传结果
const failedUploads = uploadResults.filter(r => !r.success);
if (failedUploads.length > 0) {
console.warn(`${failedUploads.length}张图像上传失败`);
addToast(`${failedUploads.length}张图像上传失败`, 'warning', 5000);
} else {
console.log(`${uploadResults.length}张图像全部上传成功`);
addToast('图像已成功上传', 'success', 3000);
}
} catch (error) {
console.error('上传图像时出错:', error);
addToast('图像上传失败', 'error', 5000);
uploadResults = undefined;
}
} else {
console.warn('未找到accessToken跳过上传');
}
const generation: Generation = {
id: generateId(),
prompt: request.prompt,
@@ -48,7 +90,8 @@ export const useImageGeneration = () => {
})) : [],
outputAssets,
modelVersion: 'gemini-2.5-flash-image-preview',
timestamp: Date.now()
timestamp: Date.now(),
uploadResults: uploadResults
};
addGeneration(generation);
@@ -56,136 +99,134 @@ export const useImageGeneration = () => {
}
setIsGenerating(false);
},
onError: (error) => {
console.error('生成失败:', error);
const errorMessage = error instanceof Error ? error.message : '未知错误';
const errorDetails = error instanceof Error ? error.stack : undefined;
addToast(`图像生成失败: ${errorMessage}`, 'error', 5000, errorDetails);
setIsGenerating(false);
}
});
onError: error => {
console.error('生成失败:', error)
const errorMessage = error instanceof Error ? error.message : '未知错误'
const errorDetails = error instanceof Error ? error.stack : undefined
addToast(`图像生成失败: ${errorMessage}`, 'error', 5000, errorDetails)
setIsGenerating(false)
},
})
const cancelGeneration = () => {
isCancelledRef.current = true
setIsGenerating(false)
addToast('生成已中断', 'info', 3000)
}
return {
generate: generateMutation.mutate,
isGenerating: generateMutation.isPending,
error: generateMutation.error
};
};
error: generateMutation.error,
cancelGeneration,
}
}
export const useImageEditing = () => {
const {
addEdit,
setIsGenerating,
setCanvasImage,
canvasImage,
editReferenceImages,
brushStrokes,
selectedGenerationId,
seed,
temperature,
uploadedImages
} = useAppStore();
const { addEdit, setIsGenerating, setCanvasImage, canvasImage, editReferenceImages, brushStrokes, selectedGenerationId, seed, temperature, uploadedImages } = useAppStore()
const { addToast } = useToast();
const { addToast } = useToast()
// 创建中断标志引用
const isCancelledRef = React.useRef(false)
const editMutation = useMutation({
mutationFn: async (instruction: string) => {
// 重置中断标志
isCancelledRef.current = false
// 如果可用,始终使用画布图像作为主要目标,否则使用第一张上传的图像
const sourceImage = canvasImage || uploadedImages[0];
if (!sourceImage) throw new Error('没有要编辑的图像');
const sourceImage = canvasImage || uploadedImages[0]
if (!sourceImage) throw new Error('没有要编辑的图像')
// 将画布图像转换为base64
const base64Image = sourceImage.includes('base64,')
? sourceImage.split('base64,')[1]
: sourceImage;
const base64Image = sourceImage.includes('base64,') ? sourceImage.split('base64,')[1] : sourceImage
// 获取用于样式指导的参考图像
let referenceImages = editReferenceImages
.filter(img => img.includes('base64,'))
.map(img => img.split('base64,')[1]);
let referenceImages = editReferenceImages.filter(img => img.includes('base64,')).map(img => img.split('base64,')[1])
let maskImage: string | undefined;
let maskedReferenceImage: string | undefined;
let maskImage: string | undefined
let maskedReferenceImage: string | undefined
// 如果存在画笔描边,则从描边创建遮罩
if (brushStrokes.length > 0) {
// 创建临时图像以获取实际尺寸
const tempImg = new Image();
tempImg.src = sourceImage;
await new Promise<void>((resolve) => {
tempImg.onload = () => resolve();
});
const tempImg = new Image()
tempImg.src = sourceImage
await new Promise<void>(resolve => {
tempImg.onload = () => resolve()
})
// 创建具有确切图像尺寸的遮罩画布
const canvas = document.createElement('canvas');
const ctx = canvas.getContext('2d')!;
canvas.width = tempImg.width;
canvas.height = tempImg.height;
const canvas = document.createElement('canvas')
const ctx = canvas.getContext('2d')!
canvas.width = tempImg.width
canvas.height = tempImg.height
// 用黑色填充(未遮罩区域)
ctx.fillStyle = 'black';
ctx.fillRect(0, 0, canvas.width, canvas.height);
ctx.fillStyle = 'black'
ctx.fillRect(0, 0, canvas.width, canvas.height)
// 绘制白色描边(遮罩区域)
ctx.strokeStyle = 'white';
ctx.lineCap = 'round';
ctx.lineJoin = 'round';
ctx.strokeStyle = 'white'
ctx.lineCap = 'round'
ctx.lineJoin = 'round'
brushStrokes.forEach(stroke => {
if (stroke.points.length >= 4) {
ctx.lineWidth = stroke.brushSize;
ctx.beginPath();
ctx.moveTo(stroke.points[0], stroke.points[1]);
ctx.lineWidth = stroke.brushSize
ctx.beginPath()
ctx.moveTo(stroke.points[0], stroke.points[1])
for (let i = 2; i < stroke.points.length; i += 2) {
ctx.lineTo(stroke.points[i], stroke.points[i + 1]);
ctx.lineTo(stroke.points[i], stroke.points[i + 1])
}
ctx.stroke();
ctx.stroke()
}
});
})
// 将遮罩转换为base64
const maskDataUrl = canvas.toDataURL('image/png');
maskImage = maskDataUrl.split('base64,')[1];
const maskDataUrl = canvas.toDataURL('image/png')
maskImage = maskDataUrl.split('base64,')[1]
// 创建遮罩参考图像(带遮罩叠加的原始图像)
const maskedCanvas = document.createElement('canvas');
const maskedCtx = maskedCanvas.getContext('2d')!;
maskedCanvas.width = tempImg.width;
maskedCanvas.height = tempImg.height;
const maskedCanvas = document.createElement('canvas')
const maskedCtx = maskedCanvas.getContext('2d')!
maskedCanvas.width = tempImg.width
maskedCanvas.height = tempImg.height
// 绘制原始图像
maskedCtx.drawImage(tempImg, 0, 0);
maskedCtx.drawImage(tempImg, 0, 0)
// 绘制带透明度的遮罩叠加
maskedCtx.globalCompositeOperation = 'source-over';
maskedCtx.globalAlpha = 0.4;
maskedCtx.fillStyle = '#A855F7';
maskedCtx.globalCompositeOperation = 'source-over'
maskedCtx.globalAlpha = 0.4
maskedCtx.fillStyle = '#A855F7'
brushStrokes.forEach(stroke => {
if (stroke.points.length >= 4) {
maskedCtx.lineWidth = stroke.brushSize;
maskedCtx.strokeStyle = '#A855F7';
maskedCtx.lineCap = 'round';
maskedCtx.lineJoin = 'round';
maskedCtx.beginPath();
maskedCtx.moveTo(stroke.points[0], stroke.points[1]);
maskedCtx.lineWidth = stroke.brushSize
maskedCtx.strokeStyle = '#A855F7'
maskedCtx.lineCap = 'round'
maskedCtx.lineJoin = 'round'
maskedCtx.beginPath()
maskedCtx.moveTo(stroke.points[0], stroke.points[1])
for (let i = 2; i < stroke.points.length; i += 2) {
maskedCtx.lineTo(stroke.points[i], stroke.points[i + 1]);
maskedCtx.lineTo(stroke.points[i], stroke.points[i + 1])
}
maskedCtx.stroke();
maskedCtx.stroke()
}
});
})
maskedCtx.globalAlpha = 1;
maskedCtx.globalCompositeOperation = 'source-over';
maskedCtx.globalAlpha = 1
maskedCtx.globalCompositeOperation = 'source-over'
const maskedDataUrl = maskedCanvas.toDataURL('image/png');
maskedReferenceImage = maskedDataUrl.split('base64,')[1];
const maskedDataUrl = maskedCanvas.toDataURL('image/png')
maskedReferenceImage = maskedDataUrl.split('base64,')[1]
// 将遮罩图像作为参考添加到模型中
referenceImages = [maskedReferenceImage, ...referenceImages];
referenceImages = [maskedReferenceImage, ...referenceImages]
}
const request: EditRequest = {
@@ -194,16 +235,22 @@ export const useImageEditing = () => {
referenceImages: referenceImages.length > 0 ? referenceImages : undefined,
maskImage,
temperature,
seed
};
seed,
}
const images = await geminiService.editImage(request);
return { images, maskedReferenceImage };
const images = await geminiService.editImage(request)
// 检查是否已中断
if (isCancelledRef.current) {
throw new Error('编辑已中断')
}
return { images, maskedReferenceImage }
},
onMutate: () => {
setIsGenerating(true);
setIsGenerating(true)
},
onSuccess: ({ images, maskedReferenceImage }, instruction) => {
onSuccess: async ({ images, maskedReferenceImage }, instruction) => {
if (images.length > 0) {
const outputAssets: Asset[] = images.map((base64, index) => ({
id: generateId(),
@@ -226,6 +273,34 @@ export const useImageEditing = () => {
checksum: maskedReferenceImage.slice(0, 32)
} : undefined;
// 获取accessToken
const accessToken = import.meta.env.VITE_ACCESS_TOKEN || '';
let uploadResults: any[] | undefined;
// 上传编辑后的图像
if (accessToken) {
try {
const imageUrls = outputAssets.map(asset => asset.url);
uploadResults = await uploadImages(imageUrls, accessToken);
// 检查上传结果
const failedUploads = uploadResults.filter(r => !r.success);
if (failedUploads.length > 0) {
console.warn(`${failedUploads.length}张编辑后的图像上传失败`);
addToast(`${failedUploads.length}张编辑后的图像上传失败`, 'warning', 5000);
} else {
console.log(`${uploadResults.length}张编辑后的图像全部上传成功`);
addToast('编辑后的图像已成功上传', 'success', 3000);
}
} catch (error) {
console.error('上传编辑后的图像时出错:', error);
addToast('编辑后的图像上传失败', 'error', 5000);
uploadResults = undefined;
}
} else {
console.warn('未找到accessToken跳过上传');
}
const edit: Edit = {
id: generateId(),
parentGenerationId: selectedGenerationId || '',
@@ -233,7 +308,8 @@ export const useImageEditing = () => {
maskReferenceAsset,
instruction,
outputAssets,
timestamp: Date.now()
timestamp: Date.now(),
uploadResults: uploadResults
};
addEdit(edit);
@@ -246,18 +322,25 @@ export const useImageEditing = () => {
}
setIsGenerating(false);
},
onError: (error) => {
console.error('编辑失败:', error);
const errorMessage = error instanceof Error ? error.message : '未知错误';
const errorDetails = error instanceof Error ? error.stack : undefined;
addToast(`图像编辑失败: ${errorMessage}`, 'error', 5000, errorDetails);
setIsGenerating(false);
}
});
onError: error => {
console.error('编辑失败:', error)
const errorMessage = error instanceof Error ? error.message : '未知错误'
const errorDetails = error instanceof Error ? error.stack : undefined
addToast(`图像编辑失败: ${errorMessage}`, 'error', 5000, errorDetails)
setIsGenerating(false)
},
})
const cancelEdit = () => {
isCancelledRef.current = true
setIsGenerating(false)
addToast('编辑已中断', 'info', 3000)
}
return {
edit: editMutation.mutate,
isEditing: editMutation.isPending,
error: editMutation.error
};
};
error: editMutation.error,
cancelEdit,
}
}

View File

@@ -0,0 +1,103 @@
// src/services/uploadService.ts
import { UploadResult } from '../types'
// 上传接口URL
const UPLOAD_URL = 'https://api.pandorastudio.cn/auth/OSSupload'
/**
* 将base64图像数据上传到指定接口
* @param base64Data - base64编码的图像数据
* @param accessToken - 访问令牌
* @returns 上传结果
*/
export const uploadImage = async (base64Data: string, accessToken: string): Promise<{ success: boolean; url?: string; error?: string }> => {
try {
// 将base64数据转换为Blob
const byteString = atob(base64Data.split(',')[1])
const mimeString = base64Data.split(',')[0].split(':')[1].split(';')[0]
const ab = new ArrayBuffer(byteString.length)
const ia = new Uint8Array(ab)
for (let i = 0; i < byteString.length; i++) {
ia[i] = byteString.charCodeAt(i)
}
const blob = new Blob([ab], { type: mimeString })
// 创建FormData对象
const formData = new FormData()
formData.append('file', blob, 'generated-image.png')
// 发送POST请求
const response = await fetch(UPLOAD_URL, {
method: 'POST',
headers: { accessToken },
body: formData,
})
if (!response.ok) {
const errorText = await response.text()
throw new Error(`上传失败: ${response.status} ${response.statusText} - ${errorText}`)
}
const result = await response.json()
// 根据返回格式处理结果: {"code": 200,"msg": "上传成功","data": "9ecbaa0a0.jpg"}
if (result.code === 200) {
// 使用环境变量中的VITE_UPLOAD_ASSET_URL作为前缀
const uploadAssetUrl = import.meta.env.VITE_UPLOAD_ASSET_URL || ''
const fullUrl = uploadAssetUrl ? `${uploadAssetUrl}/${result.data}` : result.data
return { success: true, url: fullUrl, error: undefined }
} else {
throw new Error(`上传失败: ${result.msg}`)
}
} catch (error) {
console.error('上传图像时出错:', error)
return { success: false, url: undefined, error: error instanceof Error ? error.message : String(error) }
}
}
/**
* 上传多个图像
* @param base64Images - base64编码的图像数组
* @param accessToken - 访问令牌
* @returns 上传结果数组
*/
export const uploadImages = async (base64Images: string[], accessToken: string): Promise<UploadResult[]> => {
try {
const results: UploadResult[] = []
for (let i = 0; i < base64Images.length; i++) {
const base64Data = base64Images[i]
try {
const uploadResult = await uploadImage(base64Data, accessToken)
const result: UploadResult = {
success: uploadResult.success,
url: uploadResult.url,
error: uploadResult.error,
timestamp: Date.now(),
}
results.push(result)
console.log(`${i + 1}张图像上传${uploadResult.success ? '成功' : '失败'}:`, uploadResult)
} catch (error) {
const result: UploadResult = {
success: false,
error: error instanceof Error ? error.message : String(error),
timestamp: Date.now(),
}
results.push(result)
console.error(`${i + 1}张图像上传失败:`, error)
}
}
// 检查是否有任何上传失败
const failedUploads = results.filter(r => !r.success)
if (failedUploads.length > 0) {
console.warn(`${failedUploads.length}张图像上传失败`)
} else {
console.log(`所有${results.length}张图像上传成功`)
}
return results
} catch (error) {
console.error('批量上传图像时出错:', error)
throw error
}
}

View File

@@ -1,6 +1,6 @@
import { create } from 'zustand';
import { devtools, persist } from 'zustand/middleware';
import { Project, Generation, Edit, SegmentationMask, BrushStroke } from '../types';
import { Project, Generation, Edit, SegmentationMask, BrushStroke, UploadResult } from '../types';
import { generateId } from '../utils/imageUtils';
// 定义不包含图像数据的轻量级项目结构
@@ -25,6 +25,7 @@ interface LightweightProject {
outputAssetsBlobUrls: string[];
modelVersion: string;
timestamp: number;
uploadResults?: UploadResult[];
}>;
edits: Array<{
id: string;
@@ -36,6 +37,7 @@ interface LightweightProject {
// 存储输出资产的Blob URL
outputAssetsBlobUrls: string[];
timestamp: number;
uploadResults?: UploadResult[];
}>;
createdAt: number;
updatedAt: number;
@@ -281,7 +283,8 @@ export const useAppStore = create<AppState>()(
sourceAssets,
outputAssetsBlobUrls,
modelVersion: generation.modelVersion,
timestamp: generation.timestamp
timestamp: generation.timestamp,
uploadResults: generation.uploadResults
};
const updatedProject = state.currentProject ? {
@@ -368,7 +371,8 @@ export const useAppStore = create<AppState>()(
maskReferenceAssetBlobUrl,
instruction: edit.instruction,
outputAssetsBlobUrls,
timestamp: edit.timestamp
timestamp: edit.timestamp,
uploadResults: edit.uploadResults
};
if (!state.currentProject) return {};

View File

@@ -8,6 +8,13 @@ export interface Asset {
checksum: string;
}
export interface UploadResult {
success: boolean;
url?: string;
error?: string;
timestamp: number;
}
export interface Generation {
id: string;
prompt: string;
@@ -20,6 +27,7 @@ export interface Generation {
modelVersion: string;
timestamp: number;
costEstimate?: number;
uploadResults?: UploadResult[];
}
export interface Edit {
@@ -30,6 +38,7 @@ export interface Edit {
instruction: string;
outputAssets: Asset[];
timestamp: number;
uploadResults?: UploadResult[];
}
export interface Project {