阶段性提交

This commit is contained in:
2025-09-21 14:43:59 +08:00
parent af2058f752
commit 690a530031
20 changed files with 1577 additions and 781 deletions

View File

@@ -1,26 +0,0 @@
import type { Config } from 'jest';
const config: Config = {
preset: 'ts-jest',
testEnvironment: 'jsdom',
setupFilesAfterEnv: ['<rootDir>/src/__tests__/setup.ts'],
moduleNameMapper: {
'\.(css|less|scss|sass)$': 'identity-obj-proxy',
'^@/(.*)$': '<rootDir>/src/$1'
},
testMatch: [
'<rootDir>/src/__tests__/**/*.{ts,tsx}',
'<rootDir>/src/**/*.{spec,test}.{ts,tsx}'
],
transform: {
'^.+\.(ts|tsx)$': 'ts-jest'
},
collectCoverageFrom: [
'src/**/*.{ts,tsx}',
'!src/**/*.d.ts',
'!src/main.tsx',
'!src/vite-env.d.ts'
]
};
export default config;

9
package-lock.json generated
View File

@@ -4456,9 +4456,9 @@
}
},
"node_modules/caniuse-lite": {
"version": "1.0.30001667",
"resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001667.tgz",
"integrity": "sha512-7LTwJjcRkzKFmtqGsibMeuXmvFDfZq/nzIjnmgCGzKKRVzjD72selLDK1oPF/Oxzmt4fNcPvTDvGqSDG4tCALw==",
"version": "1.0.30001743",
"resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001743.tgz",
"integrity": "sha512-e6Ojr7RV14Un7dz6ASD0aZDmQPT/A+eZU+nuTNfjqmRrmkmQlnTNWH0SKmqagx9PeW87UVqapSurtAXifmtdmw==",
"dev": true,
"funding": [
{
@@ -4473,7 +4473,8 @@
"type": "github",
"url": "https://github.com/sponsors/ai"
}
]
],
"license": "CC-BY-4.0"
},
"node_modules/canvas": {
"version": "3.2.0",

View File

@@ -1,4 +1,4 @@
import React, { useEffect, useState } from 'react';
import React, { useEffect, useState, useReducer } from 'react';
import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
import { cn } from './utils/cn';
import { Header } from './components/Header';
@@ -65,15 +65,6 @@ function AppContent() {
return () => clearInterval(interval);
}, []);
// 定期清理未使用的Blob URL
useEffect(() => {
const interval = setInterval(() => {
useAppStore.getState().scheduleBlobCleanup();
}, 60000); // 每分钟清理一次
return () => clearInterval(interval);
}, []);
// 控制预览窗口的显示和隐藏动画
useEffect(() => {

View File

@@ -4,7 +4,6 @@ import { Button } from './ui/Button';
import { History, Download, Image as ImageIcon, Trash2 } from 'lucide-react';
import { cn } from '../utils/cn';
import { ImagePreviewModal } from './ImagePreviewModal';
import * as indexedDBService from '../services/indexedDBService';
import { useIndexedDBListener } from '../hooks/useIndexedDBListener';
import { DayPicker } from 'react-day-picker';
import zhCN from 'react-day-picker/dist/locale/zh-CN';
@@ -13,9 +12,8 @@ export const HistoryPanel: React.FC<{
setHoveredImage: (image: {url: string, title: string, width?: number, height?: number} | null) => void,
setPreviewPosition?: (position: {x: number, y: number} | null) => void
}> = ({ setHoveredImage, setPreviewPosition }) => {
const {
const {
currentProject,
canvasImage,
selectedGenerationId,
selectedEditId,
selectGeneration,
@@ -23,7 +21,6 @@ export const HistoryPanel: React.FC<{
showHistory,
setShowHistory,
setCanvasImage,
selectedTool,
removeGeneration,
removeEdit
} = useAppStore();
@@ -68,18 +65,28 @@ export const HistoryPanel: React.FC<{
const [startDate, setStartDate] = useState<string>(() => {
// 初始化时默认显示今天的记录
const today = new Date();
today.setHours(0, 0, 0, 0);
return today.toISOString().split('T')[0];
});
const [endDate, setEndDate] = useState<string>(() => {
// 初始化时默认显示今天的记录
const today = new Date();
today.setHours(0, 0, 0, 0);
return today.toISOString().split('T')[0];
});
const [searchTerm, setSearchTerm] = useState<string>('');
const [showDatePicker, setShowDatePicker] = useState(false); // 控制日期选择器的显示
const [dateRange, setDateRange] = useState<{ from: Date | undefined; to: Date | undefined }>({
from: new Date(new Date().setHours(0, 0, 0, 0)),
to: new Date(new Date().setHours(0, 0, 0, 0))
from: (() => {
const today = new Date();
today.setHours(0, 0, 0, 0);
return today;
})(),
to: (() => {
const today = new Date();
today.setHours(0, 0, 0, 0);
return today;
})()
});
// 分页状态
@@ -87,17 +94,18 @@ export const HistoryPanel: React.FC<{
const itemsPerPage = 20; // 减少每页显示的项目数
// 获取当前图像尺寸
const [imageDimensions, setImageDimensions] = React.useState<{ width: number; height: number } | null>(null);
// const [imageDimensions, setImageDimensions] = React.useState<{ width: number; height: number } | null>(null);
// 当currentProject为空时使用dbGenerations和dbEdits作为备选
const displayGenerations = currentProject ? currentProject.generations : dbGenerations;
const displayEdits = currentProject ? currentProject.edits : dbEdits;
// 筛选记录的函数
const filterRecords = useCallback((records: any[], isGeneration: boolean) => {
const filterRecords = useCallback((records: Array<{timestamp: number, prompt?: string, instruction?: string}>, isGeneration: boolean) => {
return records.filter(record => {
// 日期筛选 - 检查记录日期是否在筛选范围内
const recordDate = new Date(record.timestamp);
recordDate.setHours(0, 0, 0, 0); // 设置为当天的开始
const recordDateStr = recordDate.toISOString().split('T')[0]; // 获取日期部分 YYYY-MM-DD
// 检查是否在日期范围内
@@ -108,10 +116,10 @@ export const HistoryPanel: React.FC<{
if (searchTerm) {
if (isGeneration) {
// 生成记录按提示词搜索
return record.prompt.toLowerCase().includes(searchTerm.toLowerCase());
return record.prompt?.toLowerCase().includes(searchTerm.toLowerCase()) || false;
} else {
// 编辑记录按指令搜索
return record.instruction.toLowerCase().includes(searchTerm.toLowerCase());
return record.instruction?.toLowerCase().includes(searchTerm.toLowerCase()) || false;
}
}
@@ -123,17 +131,17 @@ export const HistoryPanel: React.FC<{
const filteredGenerations = useMemo(() => filterRecords(displayGenerations, true), [displayGenerations, filterRecords]);
const filteredEdits = useMemo(() => filterRecords(displayEdits, false), [displayEdits, filterRecords]);
React.useEffect(() => {
if (canvasImage) {
const img = new Image();
img.onload = () => {
setImageDimensions({ width: img.width, height: img.height });
};
img.src = canvasImage;
} else {
setImageDimensions(null);
}
}, [canvasImage]);
// React.useEffect(() => {
// if (canvasImage) {
// const img = new Image();
// img.onload = () => {
// setImageDimensions({ width: img.width, height: img.height });
// };
// img.src = canvasImage;
// } else {
// setImageDimensions(null);
// }
// }, [canvasImage]);
// 当项目变化时解码Blob图像
useEffect(() => {
@@ -196,8 +204,8 @@ export const HistoryPanel: React.FC<{
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_30`; // 降低质量到30%
// 返回原始链接,不添加任何参数
return uploadResult.url;
}
}
return null;
@@ -469,6 +477,13 @@ export const HistoryPanel: React.FC<{
selected={dateRange}
onSelect={(range) => {
if (range) {
// 确保日期时间设置为当天的开始
if (range.from) {
range.from.setHours(0, 0, 0, 0);
}
if (range.to) {
range.to.setHours(0, 0, 0, 0);
}
setDateRange(range);
// 更新字符串格式的日期用于筛选
if (range.from) {
@@ -511,7 +526,8 @@ export const HistoryPanel: React.FC<{
className="text-xs p-1.5 rounded-l-none h-7 border-gray-200 text-gray-600 hover:bg-gray-100 card"
onClick={() => {
// 重置为显示今天的记录
const today = new Date(new Date().setHours(0, 0, 0, 0));
const today = new Date();
today.setHours(0, 0, 0, 0);
const todayStr = today.toISOString().split('T')[0];
setStartDate(todayStr);
setEndDate(todayStr);
@@ -581,7 +597,7 @@ export const HistoryPanel: React.FC<{
sortedGenerations.some(gen => gen.id === record.id)
);
return paginatedGenerations.map((generation, index) => {
return paginatedGenerations.map((generation: {id: string, sourceAssets?: Array<{url: string}>, outputAssets?: Array<{url: string}>}) => {
// 计算全局索引用于显示编号
const globalIndex = allRecords.findIndex(record => record.id === generation.id);
@@ -596,23 +612,57 @@ export const HistoryPanel: React.FC<{
)}
onClick={() => {
selectGeneration(generation.id);
// 设置画布图像为第一个输出资产
if (generation.outputAssets && generation.outputAssets.length > 0) {
// 设置画布图像为参考图像,如果没有参考图像则使用第一个输出资产
let imageUrl = null;
// 首先尝试获取参考图像
if (generation.sourceAssets && generation.sourceAssets.length > 0) {
// 参考图像在uploadResults中从索引outputAssets.length开始
const outputAssetsCount = generation.outputAssets?.length || 0;
const uploadResultIndex = outputAssetsCount; // 第一个参考图像
const uploadedUrl = getUploadedImageUrl(generation, uploadResultIndex);
if (uploadedUrl) {
imageUrl = uploadedUrl;
} else if (generation.sourceAssets[0].url) {
imageUrl = generation.sourceAssets[0].url;
}
}
// 如果没有参考图像,则使用生成结果图像
if (!imageUrl && generation.outputAssets && generation.outputAssets.length > 0) {
const asset = generation.outputAssets[0];
if (asset.url) {
setCanvasImage(asset.url);
const uploadedUrl = getUploadedImageUrl(generation, 0);
imageUrl = uploadedUrl || asset.url;
}
}
if (imageUrl) {
setCanvasImage(imageUrl);
}
}}
onMouseEnter={(e) => {
// 设置当前悬停的记录
setHoveredRecord({type: 'generation', id: generation.id});
// 优先使用上传后的远程链接,如果没有则使用原始链接
let imageUrl = getUploadedImageUrl(generation, 0);
if (!imageUrl && generation.outputAssets && generation.outputAssets.length > 0) {
imageUrl = generation.outputAssets[0].url;
}
// 优先显示参考图像,如果没有参考图像则显示生成结果图像
let imageUrl = null;
// 首先尝试获取参考图像
if (generation.sourceAssets && generation.sourceAssets.length > 0) {
// 参考图像在uploadResults中从索引outputAssets.length开始
const outputAssetsCount = generation.outputAssets?.length || 0;
const uploadResultIndex = outputAssetsCount; // 第一个参考图像
imageUrl = getUploadedImageUrl(generation, uploadResultIndex) ||
(generation.sourceAssets[0].url ? generation.sourceAssets[0].url : null);
}
// 如果没有参考图像,则使用生成结果图像
if (!imageUrl) {
imageUrl = getUploadedImageUrl(generation, 0) ||
(generation.outputAssets && generation.outputAssets.length > 0 && generation.outputAssets[0].url ? generation.outputAssets[0].url : null);
}
if (imageUrl) {
// 创建图像对象以获取尺寸
const img = new Image();
@@ -719,9 +769,23 @@ export const HistoryPanel: React.FC<{
}}
>
{(() => {
// 优先使用上传后的远程链接
const imageUrl = getUploadedImageUrl(generation, 0) ||
(generation.outputAssets && generation.outputAssets.length > 0 && generation.outputAssets[0].url ? generation.outputAssets[0].url : null);
// 优先显示参考图像,如果没有参考图像则显示生成结果图像
let imageUrl = null;
// 首先尝试获取参考图像
if (generation.sourceAssets && generation.sourceAssets.length > 0) {
// 参考图像在uploadResults中从索引outputAssets.length开始
const outputAssetsCount = generation.outputAssets?.length || 0;
const uploadResultIndex = outputAssetsCount; // 第一个参考图像
imageUrl = getUploadedImageUrl(generation, uploadResultIndex) ||
(generation.sourceAssets[0].url ? generation.sourceAssets[0].url : null);
}
// 如果没有参考图像,则使用生成结果图像
if (!imageUrl) {
imageUrl = getUploadedImageUrl(generation, 0) ||
(generation.outputAssets && generation.outputAssets.length > 0 && generation.outputAssets[0].url ? generation.outputAssets[0].url : null);
}
if (imageUrl) {
return <img src={imageUrl} alt="生成的变体" className="w-full h-full object-cover" />;
@@ -748,12 +812,34 @@ export const HistoryPanel: React.FC<{
className="h-8 w-8 bg-white/90 hover:bg-white text-gray-700 rounded-full shadow-md"
onClick={(e) => {
e.stopPropagation();
// 下载图像
const imageUrl = getUploadedImageUrl(generation, 0) ||
(generation.outputAssets && generation.outputAssets.length > 0 && generation.outputAssets[0].url ? generation.outputAssets[0].url : null);
// 下载图像 - 优先下载参考图像,如果没有则下载生成结果图像
let imageUrl = null;
// 首先尝试获取参考图像
if (generation.sourceAssets && generation.sourceAssets.length > 0) {
// 参考图像在uploadResults中从索引outputAssets.length开始
const outputAssetsCount = generation.outputAssets?.length || 0;
const uploadResultIndex = outputAssetsCount; // 第一个参考图像
imageUrl = getUploadedImageUrl(generation, uploadResultIndex) ||
(generation.sourceAssets[0].url ? generation.sourceAssets[0].url : null);
}
// 如果没有参考图像,则使用生成结果图像
if (!imageUrl) {
imageUrl = getUploadedImageUrl(generation, 0) ||
(generation.outputAssets && generation.outputAssets.length > 0 && generation.outputAssets[0].url ? generation.outputAssets[0].url : null);
}
if (imageUrl) {
// 使用Promise来处理异步操作
fetch(imageUrl)
// 使用fetch获取图像数据并创建Blob URL以确保正确下载
// 添加更多缓存控制头以绕过CDN缓存
fetch(imageUrl, {
headers: {
'Cache-Control': 'no-cache, no-store, must-revalidate',
'Pragma': 'no-cache',
'Expires': '0'
}
})
.then(response => response.blob())
.then(blob => {
const url = URL.createObjectURL(blob);
@@ -767,6 +853,14 @@ export const HistoryPanel: React.FC<{
})
.catch(error => {
console.error('下载图像失败:', error);
// 如果fetch失败回退到直接使用a标签
const link = document.createElement('a');
link.href = imageUrl;
link.download = `nano-banana-${Date.now()}.png`;
link.target = '_blank';
document.body.appendChild(link);
link.click();
document.body.removeChild(link);
});
}
}}
@@ -809,7 +903,7 @@ export const HistoryPanel: React.FC<{
sortedEdits.some(edit => edit.id === record.id)
);
return paginatedEdits.map((edit, index) => {
return paginatedEdits.map((edit: {id: string, sourceAssets?: Array<{url: string}>, outputAssets?: Array<{url: string}>}) => {
// 计算全局索引用于显示编号
const globalIndex = allRecords.findIndex(record => record.id === edit.id);
@@ -825,22 +919,55 @@ export const HistoryPanel: React.FC<{
onClick={() => {
selectEdit(edit.id);
selectGeneration(null);
// 设置画布图像为第一个输出资产
if (edit.outputAssets && edit.outputAssets.length > 0) {
// 设置画布图像为参考图像,如果没有参考图像则使用第一个输出资产
let imageUrl = null;
// 首先尝试获取参考图像
if (edit.sourceAssets && edit.sourceAssets.length > 0) {
// 参考图像在uploadResults中从索引outputAssets.length开始
const outputAssetsCount = edit.outputAssets?.length || 0;
const uploadResultIndex = outputAssetsCount; // 第一个参考图像
const uploadedUrl = getUploadedImageUrl(edit, uploadResultIndex);
if (uploadedUrl) {
imageUrl = uploadedUrl;
} else if (edit.sourceAssets[0].url) {
imageUrl = edit.sourceAssets[0].url;
}
}
// 如果没有参考图像,则使用编辑结果图像
if (!imageUrl && edit.outputAssets && edit.outputAssets.length > 0) {
const asset = edit.outputAssets[0];
if (asset.url) {
setCanvasImage(asset.url);
const uploadedUrl = getUploadedImageUrl(edit, 0);
imageUrl = uploadedUrl || asset.url;
}
}
if (imageUrl) {
setCanvasImage(imageUrl);
}
}}
onMouseEnter={(e) => {
// 设置当前悬停的记录
setHoveredRecord({type: 'edit', id: edit.id});
// 优先使用上传后的远程链接,如果没有则使用原始链接
let imageUrl = getUploadedImageUrl(edit, 0);
if (!imageUrl && edit.outputAssets && edit.outputAssets.length > 0) {
imageUrl = edit.outputAssets[0].url;
// 优先显示参考图像,如果没有参考图像则显示编辑结果图像
let imageUrl = null;
// 首先尝试获取参考图像
if (edit.sourceAssets && edit.sourceAssets.length > 0) {
// 参考图像在uploadResults中从索引outputAssets.length开始
const outputAssetsCount = edit.outputAssets?.length || 0;
const uploadResultIndex = outputAssetsCount; // 第一个参考图像
imageUrl = getUploadedImageUrl(edit, uploadResultIndex) ||
(edit.sourceAssets[0].url ? edit.sourceAssets[0].url : null);
}
// 如果没有参考图像,则使用编辑结果图像
if (!imageUrl) {
imageUrl = getUploadedImageUrl(edit, 0) ||
(edit.outputAssets && edit.outputAssets.length > 0 && edit.outputAssets[0].url ? edit.outputAssets[0].url : null);
}
if (imageUrl) {
@@ -889,9 +1016,23 @@ export const HistoryPanel: React.FC<{
}}
>
{(() => {
// 优先使用上传后的远程链接
const imageUrl = getUploadedImageUrl(edit, 0) ||
(edit.outputAssets && edit.outputAssets.length > 0 && edit.outputAssets[0].url ? edit.outputAssets[0].url : null);
// 优先显示参考图像,如果没有参考图像则显示编辑结果图像
let imageUrl = null;
// 首先尝试获取参考图像
if (edit.sourceAssets && edit.sourceAssets.length > 0) {
// 参考图像在uploadResults中从索引outputAssets.length开始
const outputAssetsCount = edit.outputAssets?.length || 0;
const uploadResultIndex = outputAssetsCount; // 第一个参考图像
imageUrl = getUploadedImageUrl(edit, uploadResultIndex) ||
(edit.sourceAssets[0].url ? edit.sourceAssets[0].url : null);
}
// 如果没有参考图像,则使用编辑结果图像
if (!imageUrl) {
imageUrl = getUploadedImageUrl(edit, 0) ||
(edit.outputAssets && edit.outputAssets.length > 0 && edit.outputAssets[0].url ? edit.outputAssets[0].url : null);
}
if (imageUrl) {
return <img src={imageUrl} alt="编辑的变体" className="w-full h-full object-cover" />;
@@ -918,12 +1059,34 @@ export const HistoryPanel: React.FC<{
className="h-8 w-8 bg-white/90 hover:bg-white text-gray-700 rounded-full shadow-md"
onClick={(e) => {
e.stopPropagation();
// 下载图像
const imageUrl = getUploadedImageUrl(edit, 0) ||
(edit.outputAssets && edit.outputAssets.length > 0 && edit.outputAssets[0].url ? edit.outputAssets[0].url : null);
// 下载图像 - 优先下载参考图像,如果没有则下载编辑结果图像
let imageUrl = null;
// 首先尝试获取参考图像
if (edit.sourceAssets && edit.sourceAssets.length > 0) {
// 参考图像在uploadResults中从索引outputAssets.length开始
const outputAssetsCount = edit.outputAssets?.length || 0;
const uploadResultIndex = outputAssetsCount; // 第一个参考图像
imageUrl = getUploadedImageUrl(edit, uploadResultIndex) ||
(edit.sourceAssets[0].url ? edit.sourceAssets[0].url : null);
}
// 如果没有参考图像,则使用编辑结果图像
if (!imageUrl) {
imageUrl = getUploadedImageUrl(edit, 0) ||
(edit.outputAssets && edit.outputAssets.length > 0 && edit.outputAssets[0].url ? edit.outputAssets[0].url : null);
}
if (imageUrl) {
// 使用Promise来处理异步操作
fetch(imageUrl)
// 使用fetch获取图像数据并创建Blob URL以确保正确下载
// 添加更多缓存控制头以绕过CDN缓存
fetch(imageUrl, {
headers: {
'Cache-Control': 'no-cache, no-store, must-revalidate',
'Pragma': 'no-cache',
'Expires': '0'
}
})
.then(response => response.blob())
.then(blob => {
const url = URL.createObjectURL(blob);
@@ -937,6 +1100,14 @@ export const HistoryPanel: React.FC<{
})
.catch(error => {
console.error('下载图像失败:', error);
// 如果fetch失败回退到直接使用a标签
const link = document.createElement('a');
link.href = imageUrl;
link.download = `nano-banana-${Date.now()}.png`;
link.target = '_blank';
document.body.appendChild(link);
link.click();
document.body.removeChild(link);
});
}
}}
@@ -1080,7 +1251,7 @@ export const HistoryPanel: React.FC<{
{gen.outputAssets.length}
</div>
<div className="flex flex-wrap gap-2">
{gen.outputAssets.slice(0, 4).map((asset: any, index: number) => {
{gen.outputAssets.slice(0, 4).map((asset: {id: string, url?: string, blobUrl?: string, width: number, height: number}, index: number) => {
// 获取上传后的远程链接(如果存在)
const uploadedUrl = gen.uploadResults && gen.uploadResults[index] && gen.uploadResults[index].success
? `${gen.uploadResults[index].url}?x-oss-process=image/quality,q_30`
@@ -1169,20 +1340,14 @@ export const HistoryPanel: React.FC<{
{gen.sourceAssets.length}
</div>
<div className="flex flex-wrap gap-2">
{gen.sourceAssets.slice(0, 4).map((asset: any, index: number) => {
// 获取上传后的远程链接如果存在)
// 参考图像在uploadResults中从索引outputAssets.length开始
// 但由于gen可能是轻量级记录我们需要从dbGenerations中获取完整的记录
const fullGen = dbGenerations.find(item => item.id === gen.id) || gen;
const outputAssetsCount = fullGen.outputAssets?.length || 0;
// 确保索引在有效范围内
const uploadResultIndex = outputAssetsCount + index;
const uploadedUrl = fullGen.uploadResults && fullGen.uploadResults[uploadResultIndex] && fullGen.uploadResults[uploadResultIndex].success
? `${fullGen.uploadResults[uploadResultIndex].url}?x-oss-process=image/quality,q_30`
{gen.sourceAssets.slice(0, 4).map((asset: {id: string, url?: string, blobUrl?: string, width: number, height: number}, index: number) => {
// 优先使用上传后的远程链接如果没有则使用asset中的URL
// 参考图像在uploadResults中从索引1开始图像2字段
const uploadResultIndex = 1 + index;
const uploadedUrl = gen.uploadResults && gen.uploadResults[uploadResultIndex] && gen.uploadResults[uploadResultIndex].success
? gen.uploadResults[uploadResultIndex].url
: null;
// 如果没有上传的URL则使用asset中的URL
const displayUrl = uploadedUrl || asset.url || asset.blobUrl;
// 如果URL是blob:开头但已失效,尝试重新创建
@@ -1320,10 +1485,10 @@ export const HistoryPanel: React.FC<{
{selectedEdit.outputAssets.length}
</div>
<div className="flex flex-wrap gap-2">
{selectedEdit.outputAssets.slice(0, 4).map((asset: any, index: number) => {
{selectedEdit.outputAssets.slice(0, 4).map((asset: {id: string, url?: string, blobUrl?: string, width: number, height: number}, index: number) => {
// 获取上传后的远程链接(如果存在)
const uploadedUrl = selectedEdit.uploadResults && selectedEdit.uploadResults[index] && selectedEdit.uploadResults[index].success
? `${selectedEdit.uploadResults[index].url}?x-oss-process=image/quality,q_30`
? selectedEdit.uploadResults[index].url
: null;
// 如果没有上传的URL则使用asset中的URL
@@ -1401,6 +1566,96 @@ export const HistoryPanel: React.FC<{
</div>
)}
{/* 编辑参考图像 */}
{selectedEdit.sourceAssets && selectedEdit.sourceAssets.length > 0 && (
<div className="pt-2 border-t border-gray-200">
<h5 className="text-xs font-medium text-gray-500 mb-2"></h5>
<div className="text-xs text-gray-600 mb-2">
{selectedEdit.sourceAssets.length}
</div>
<div className="flex flex-wrap gap-2">
{selectedEdit.sourceAssets.slice(0, 4).map((asset: {id: string, url?: string, blobUrl?: string, width: number, height: number}, index: number) => {
// 优先使用上传后的远程链接如果没有则使用asset中的URL
// 参考图像在uploadResults中从索引1开始图像2字段
const uploadResultIndex = 1 + index;
const uploadedUrl = selectedEdit.uploadResults && selectedEdit.uploadResults[uploadResultIndex] && selectedEdit.uploadResults[uploadResultIndex].success
? selectedEdit.uploadResults[uploadResultIndex].url
: null;
const displayUrl = uploadedUrl || asset.url || asset.blobUrl;
// 如果URL是blob:开头但已失效,尝试重新创建
if (displayUrl && displayUrl.startsWith('blob:')) {
// 检查blob是否仍然有效
const img = new Image();
img.onerror = () => {
// Blob URL可能已失效尝试重新创建
import('../store/useAppStore').then((module) => {
const useAppStore = module.useAppStore;
const blob = useAppStore.getState().getBlob(displayUrl);
if (blob) {
const newUrl = URL.createObjectURL(blob);
// 更新显示
const imgElement = document.querySelector(`img[src="${displayUrl}"]`);
if (imgElement) {
imgElement.src = newUrl;
}
}
});
};
img.src = displayUrl;
}
return (
<div
key={asset.id}
className="relative w-16 h-16 rounded border overflow-hidden cursor-pointer hover:ring-2 hover:ring-yellow-400"
onClick={(e) => {
e.stopPropagation();
setPreviewModal({
open: true,
imageUrl: displayUrl,
title: `编辑参考图像 ${index + 1}`,
description: `${asset.width} × ${asset.height}`
});
}}
>
{displayUrl ? (
<img
src={displayUrl}
alt={`编辑参考图像 ${index + 1}`}
className="w-full h-full object-cover"
onError={(e) => {
// 如果图像加载失败尝试重新创建Blob URL
if (displayUrl.startsWith('blob:')) {
import('../store/useAppStore').then((module) => {
const useAppStore = module.useAppStore;
const blob = useAppStore.getState().getBlob(displayUrl);
if (blob) {
const newUrl = URL.createObjectURL(blob);
(e.target as HTMLImageElement).src = newUrl;
}
});
}
}}
/>
) : (
<div className="w-full h-full bg-gray-100 flex items-center justify-center">
<ImageIcon className="h-4 w-4 text-gray-400" />
</div>
)}
</div>
);
})}
{selectedEdit.sourceAssets.length > 4 && (
<div className="w-16 h-16 rounded border flex items-center justify-center bg-gray-100 text-xs text-gray-500">
+{selectedEdit.sourceAssets.length - 4}
</div>
)}
</div>
</div>
)}
{/* 原始生成参考 */}
{parentGen && (
<div className="pt-2 border-t border-gray-200">
@@ -1415,18 +1670,14 @@ export const HistoryPanel: React.FC<{
:
</div>
<div className="flex flex-wrap gap-2">
{parentGen.sourceAssets.slice(0, 4).map((asset: any, index: number) => {
// 获取上传后的远程链接如果存在)
// 参考图像在uploadResults中从索引outputAssets.length开始
const outputAssetsCount = parentGen.outputAssets?.length || 0;
// 确保索引在有效范围内
const uploadResultIndex = outputAssetsCount + index;
{parentGen.sourceAssets.slice(0, 4).map((asset: {id: string, url?: string, blobUrl?: string, width: number, height: number}, index: number) => {
// 优先使用上传后的远程链接如果没有则使用asset中的URL
// 参考图像在uploadResults中从索引1开始图像2字段
const uploadResultIndex = 1 + index;
const uploadedUrl = parentGen.uploadResults && parentGen.uploadResults[uploadResultIndex] && parentGen.uploadResults[uploadResultIndex].success
? `${parentGen.uploadResults[uploadResultIndex].url}?x-oss-process=image/quality,q_30`
? parentGen.uploadResults[uploadResultIndex].url
: null;
// 如果没有上传的URL则使用asset中的URL
const displayUrl = uploadedUrl || asset.url || asset.blobUrl;
// 如果URL是blob:开头但已失效,尝试重新创建

View File

@@ -2,25 +2,19 @@ import React, { useRef, useEffect, useState, useCallback } from 'react';
import { Stage, Layer, Image as KonvaImage, Line } from 'react-konva';
import { useAppStore } from '../store/useAppStore';
import { Button } from './ui/Button';
import { ZoomIn, ZoomOut, RotateCcw, Download, Eye, EyeOff, Eraser } from 'lucide-react';
import { cn } from '../utils/cn';
import { ZoomIn, ZoomOut, RotateCcw, Download } from 'lucide-react';
export const ImageCanvas: React.FC = () => {
const {
canvasImage,
canvasZoom,
setCanvasZoom,
canvasPan,
setCanvasPan,
brushStrokes,
addBrushStroke,
clearBrushStrokes,
showMasks,
setShowMasks,
selectedTool,
isGenerating,
brushSize,
setBrushSize,
showHistory,
showPromptPanel
} = useAppStore();
@@ -50,7 +44,7 @@ export const ImageCanvas: React.FC = () => {
}
}, 0);
}
}, []);
}, [setCanvasZoom]);
// 加载图像
useEffect(() => {
@@ -60,7 +54,7 @@ export const ImageCanvas: React.FC = () => {
console.log('开始加载图像URL:', canvasImage);
img = new window.Image();
let isCancelled = false;
const isCancelled = false;
img.onload = () => {
// 检查是否已取消
@@ -195,7 +189,7 @@ export const ImageCanvas: React.FC = () => {
image.src = '';
}
};
}, [canvasImage]); // 只依赖canvasImage避免其他依赖引起循环
}, [canvasImage, image, setCanvasZoom, stageSize.height, stageSize.width]); // 添加所有依赖项
// 处理舞台大小调整
useEffect(() => {
@@ -212,7 +206,7 @@ export const ImageCanvas: React.FC = () => {
updateSize();
window.addEventListener('resize', updateSize);
return () => window.removeEventListener('resize', updateSize);
}, []);
}, [showPromptPanel, showHistory]);
// 监听面板状态变化以调整画布大小
useEffect(() => {
@@ -243,14 +237,13 @@ export const ImageCanvas: React.FC = () => {
container.addEventListener('wheel', handleWheel, { passive: false });
return () => container.removeEventListener('wheel', handleWheel);
}, [canvasZoom]);
}, [canvasZoom, handleZoom]);
const handleMouseDown = (e: any) => {
const handleMouseDown = (e: Konva.KonvaEventObject<MouseEvent>) => {
if (selectedTool !== 'mask' || !image) return;
setIsDrawing(true);
const stage = e.target.getStage();
const pos = stage.getPointerPosition();
// 使用 Konva 的 getRelativePointerPosition 获取准确坐标
const relativePos = stage.getRelativePointerPosition();
@@ -269,11 +262,10 @@ export const ImageCanvas: React.FC = () => {
}
};
const handleMouseMove = (e: any) => {
const handleMouseMove = (e: Konva.KonvaEventObject<MouseEvent>) => {
if (!isDrawing || selectedTool !== 'mask' || !image) return;
const stage = e.target.getStage();
const pos = stage.getPointerPosition();
// 使用 Konva 的 getRelativePointerPosition 获取准确坐标
const relativePos = stage.getRelativePointerPosition();
@@ -351,17 +343,17 @@ export const ImageCanvas: React.FC = () => {
// 下载第一个上传结果(通常是生成的图像)
const uploadResult = selectedRecord.uploadResults[0];
if (uploadResult.success && uploadResult.url) {
// 使用async IIFE处理异步操作
(async () => {
try {
// 首先尝试使用fetch获取图像数据
const response = await fetch(uploadResult.url);
if (!response.ok) {
throw new Error(`HTTP error! status: ${response.status}`);
}
const blob = await response.blob();
// 创建下载链接
// 使用fetch获取图像数据并创建Blob URL以确保正确下载
// 添加更多缓存控制头以绕过CDN缓存
fetch(uploadResult.url, {
headers: {
'Cache-Control': 'no-cache, no-store, must-revalidate',
'Pragma': 'no-cache',
'Expires': '0'
}
})
.then(response => response.blob())
.then(blob => {
const url = URL.createObjectURL(blob);
const link = document.createElement('a');
link.href = url;
@@ -369,64 +361,21 @@ export const ImageCanvas: React.FC = () => {
document.body.appendChild(link);
link.click();
document.body.removeChild(link);
// 清理创建的URL
setTimeout(() => URL.revokeObjectURL(url), 100);
console.log('上传后的图像下载成功:', uploadResult.url);
} catch (error) {
console.error('使用fetch下载上传后的图像时出错:', error);
// 如果fetch失败可能是跨域问题使用Canvas方案
try {
const img = new Image();
img.crossOrigin = 'Anonymous'; // 设置跨域属性
img.onload = () => {
try {
// 创建canvas并绘制图像
const canvas = document.createElement('canvas');
canvas.width = img.width;
canvas.height = img.height;
const ctx = canvas.getContext('2d');
ctx.drawImage(img, 0, 0);
// 将canvas转换为blob并下载
canvas.toBlob((blob) => {
if (blob) {
const url = URL.createObjectURL(blob);
const link = document.createElement('a');
link.href = url;
link.download = `nano-banana-${Date.now()}.png`;
document.body.appendChild(link);
link.click();
document.body.removeChild(link);
// 清理创建的URL
setTimeout(() => URL.revokeObjectURL(url), 100);
console.log('使用Canvas方案下载成功');
} else {
console.error('Canvas转换为blob失败');
}
}, 'image/png');
} catch (canvasError) {
console.error('Canvas处理失败:', canvasError);
}
};
img.onerror = (imgError) => {
console.error('图像加载失败:', imgError);
console.log('下载失败,未执行回退方案');
};
img.src = uploadResult.url;
} catch (canvasError) {
console.error('Canvas方案也失败了:', canvasError);
console.log('下载失败,未执行回退方案');
}
}
})();
URL.revokeObjectURL(url);
})
.catch(error => {
console.error('下载图像失败:', error);
// 如果fetch失败回退到直接使用a标签
const link = document.createElement('a');
link.href = uploadResult.url;
link.download = `nano-banana-${Date.now()}.png`;
link.target = '_blank';
document.body.appendChild(link);
link.click();
document.body.removeChild(link);
});
// 立即返回,让异步操作在后台进行
// 立即返回
return;
}
}
@@ -463,14 +412,17 @@ export const ImageCanvas: React.FC = () => {
document.body.removeChild(link);
} else if (canvasImage.startsWith('blob:')) {
// Blob URL格式
// 使用async IIFE处理异步操作
(async () => {
try {
const response = await fetch(canvasImage);
if (!response.ok) {
throw new Error(`HTTP error! status: ${response.status}`);
}
const blob = await response.blob();
// 使用fetch获取图像数据并创建Blob URL以确保正确下载
// 添加更多缓存控制头以绕过CDN缓存
fetch(canvasImage, {
headers: {
'Cache-Control': 'no-cache, no-store, must-revalidate',
'Pragma': 'no-cache',
'Expires': '0'
}
})
.then(response => response.blob())
.then(blob => {
const url = URL.createObjectURL(blob);
const link = document.createElement('a');
link.href = url;
@@ -478,68 +430,32 @@ export const ImageCanvas: React.FC = () => {
document.body.appendChild(link);
link.click();
document.body.removeChild(link);
// 清理创建的URL
setTimeout(() => URL.revokeObjectURL(url), 100);
} catch (error) {
console.error('下载Blob图像时出错:', error);
// 如果fetch失败可能是跨域问题使用Canvas方案
try {
const img = new Image();
img.onload = () => {
try {
// 创建canvas并绘制图像
const canvas = document.createElement('canvas');
canvas.width = img.width;
canvas.height = img.height;
const ctx = canvas.getContext('2d');
ctx.drawImage(img, 0, 0);
// 将canvas转换为blob并下载
canvas.toBlob((blob) => {
if (blob) {
const url = URL.createObjectURL(blob);
const link = document.createElement('a');
link.href = url;
link.download = `nano-banana-${Date.now()}.png`;
document.body.appendChild(link);
link.click();
document.body.removeChild(link);
// 清理创建的URL
setTimeout(() => URL.revokeObjectURL(url), 100);
console.log('使用Canvas方案下载成功');
} else {
console.error('Canvas转换为blob失败');
}
}, 'image/png');
} catch (canvasError) {
console.error('Canvas处理失败:', canvasError);
}
};
img.onerror = (imgError) => {
console.error('图像加载失败:', imgError);
console.log('下载失败,未执行回退方案');
};
img.src = canvasImage;
} catch (canvasError) {
console.error('Canvas方案也失败了:', canvasError);
console.log('下载失败,未执行回退方案');
}
}
})();
URL.revokeObjectURL(url);
})
.catch(error => {
console.error('下载图像失败:', error);
// 如果fetch失败回退到直接使用a标签
const link = document.createElement('a');
link.href = canvasImage;
link.download = `nano-banana-${Date.now()}.png`;
link.target = '_blank';
document.body.appendChild(link);
link.click();
document.body.removeChild(link);
});
} else {
// 普通URL格式
// 使用async IIFE处理异步操作
(async () => {
try {
const response = await fetch(canvasImage);
if (!response.ok) {
throw new Error(`HTTP error! status: ${response.status}`);
}
const blob = await response.blob();
// 使用fetch获取图像数据并创建Blob URL以确保正确下载
// 添加更多缓存控制头以绕过CDN缓存
fetch(canvasImage, {
headers: {
'Cache-Control': 'no-cache, no-store, must-revalidate',
'Pragma': 'no-cache',
'Expires': '0'
}
})
.then(response => response.blob())
.then(blob => {
const url = URL.createObjectURL(blob);
const link = document.createElement('a');
link.href = url;
@@ -547,59 +463,19 @@ export const ImageCanvas: React.FC = () => {
document.body.appendChild(link);
link.click();
document.body.removeChild(link);
// 清理创建的URL
setTimeout(() => URL.revokeObjectURL(url), 100);
} catch (error) {
console.error('下载图像时出错:', error);
// 如果fetch失败可能是跨域问题使用Canvas方案
try {
const img = new Image();
img.crossOrigin = 'Anonymous'; // 设置跨域属性
img.onload = () => {
try {
// 创建canvas并绘制图像
const canvas = document.createElement('canvas');
canvas.width = img.width;
canvas.height = img.height;
const ctx = canvas.getContext('2d');
ctx.drawImage(img, 0, 0);
// 将canvas转换为blob并下载
canvas.toBlob((blob) => {
if (blob) {
const url = URL.createObjectURL(blob);
const link = document.createElement('a');
link.href = url;
link.download = `nano-banana-${Date.now()}.png`;
document.body.appendChild(link);
link.click();
document.body.removeChild(link);
// 清理创建的URL
setTimeout(() => URL.revokeObjectURL(url), 100);
console.log('使用Canvas方案下载成功');
} else {
console.error('Canvas转换为blob失败');
}
}, 'image/png');
} catch (canvasError) {
console.error('Canvas处理失败:', canvasError);
}
};
img.onerror = (imgError) => {
console.error('图像加载失败:', imgError);
console.log('下载失败,未执行回退方案');
};
img.src = canvasImage;
} catch (canvasError) {
console.error('Canvas方案也失败了:', canvasError);
console.log('下载失败,未执行回退方案');
}
}
})();
URL.revokeObjectURL(url);
})
.catch(error => {
console.error('下载图像失败:', error);
// 如果fetch失败回退到直接使用a标签
const link = document.createElement('a');
link.href = canvasImage;
link.download = `nano-banana-${Date.now()}.png`;
link.target = '_blank';
document.body.appendChild(link);
link.click();
document.body.removeChild(link);
});
}
}
}
@@ -647,7 +523,7 @@ export const ImageCanvas: React.FC = () => {
width={stageSize.width}
height={stageSize.height}
draggable={selectedTool !== 'mask'}
onDragEnd={(e) => {
onDragEnd={() => {
// 通过stageRef直接获取和设置位置
const stage = stageRef.current;
if (stage) {

View File

@@ -1,13 +1,101 @@
import React, { useState, useRef } from 'react';
import React, { useState, useRef, useEffect } from 'react';
import { Textarea } from './ui/Textarea';
import { Button } from './ui/Button';
import { useAppStore } from '../store/useAppStore';
import { useImageGeneration, useImageEditing } from '../hooks/useImageGeneration';
import { Upload, Wand2, Edit3, MousePointer, HelpCircle, ChevronDown, ChevronRight, RotateCcw } from 'lucide-react';
import { blobToBase64, urlToBlob } from '../utils/imageUtils';
import { urlToBlob } from '../utils/imageUtils';
import { PromptHints } from './PromptHints';
import { PromptSuggestions } from './PromptSuggestions';
import { cn } from '../utils/cn';
import * as referenceImageService from '../services/referenceImageService';
// 图像预览组件
const ImagePreview: React.FC<{
image: string;
index: number;
selectedTool: 'generate' | 'edit' | 'mask';
onRemove: () => void;
}> = ({ image, index, onRemove }) => {
const [imageSrc, setImageSrc] = useState<string>(image);
const [loading, setLoading] = useState<boolean>(true);
const [error, setError] = useState<boolean>(false);
useEffect(() => {
// 如果是IndexedDB图像需要获取实际的Blob数据
if (image.startsWith('indexeddb://')) {
const imageId = image.replace('indexeddb://', '');
referenceImageService.getReferenceImage(imageId)
.then(blob => {
if (blob) {
const url = URL.createObjectURL(blob);
setImageSrc(url);
setLoading(false);
} else {
setError(true);
setLoading(false);
}
})
.catch(err => {
console.error('获取参考图像失败:', err);
setError(true);
setLoading(false);
});
} else {
// 对于其他类型的URL直接使用
setImageSrc(image);
setLoading(false);
}
}, [image]);
// 清理创建的Blob URL
useEffect(() => {
return () => {
if (imageSrc.startsWith('blob:') && imageSrc !== image) {
URL.revokeObjectURL(imageSrc);
}
};
}, [imageSrc, image]);
if (loading) {
return (
<div className="relative w-full h-20 rounded-lg border-2 border-gray-200 bg-gray-100 flex items-center justify-center">
<div className="animate-spin rounded-full h-6 w-6 border-b-2 border-gray-500"></div>
</div>
);
}
if (error) {
return (
<div className="relative w-full h-20 rounded-lg border-2 border-gray-200 bg-red-50 flex items-center justify-center">
<span className="text-red-500 text-sm"></span>
</div>
);
}
return (
<div className="relative">
<img
src={imageSrc}
alt={`参考图像 ${index + 1}`}
className="w-full h-20 object-cover rounded-lg border-2 border-gray-200"
onError={() => setError(true)}
/>
<button
onClick={onRemove}
className="absolute -top-2 -right-2 bg-red-500 text-white rounded-full p-1.5 transition-colors shadow-md hover:bg-red-600"
>
<svg xmlns="http://www.w3.org/2000/svg" width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="3">
<line x1="18" y1="6" x2="6" y2="18"></line>
<line x1="6" y1="6" x2="18" y2="18"></line>
</svg>
</button>
<div className="absolute bottom-2 left-2 bg-black/60 text-white text-xs px-2 py-1 rounded-lg">
{index + 1}
</div>
</div>
);
};
export const PromptComposer: React.FC = () => {
const {
@@ -32,8 +120,7 @@ export const PromptComposer: React.FC = () => {
setCanvasImage,
showPromptPanel,
setShowPromptPanel,
clearBrushStrokes,
addBlob
clearBrushStrokes
} = useAppStore();
const { generate, cancelGeneration } = useImageGeneration();
@@ -45,12 +132,27 @@ export const PromptComposer: React.FC = () => {
const [isDragOver, setIsDragOver] = useState(false);
const fileInputRef = useRef<HTMLInputElement>(null);
// 初始化参考图像数据库
useEffect(() => {
const initDB = async () => {
try {
await referenceImageService.initReferenceImageDB();
console.log('参考图像数据库初始化成功');
} catch (error) {
console.error('参考图像数据库初始化失败:', error);
}
};
initDB();
}, []);
const handleGenerate = async () => {
if (!currentPrompt.trim()) return;
if (selectedTool === 'generate') {
// 将上传的图像转换为Blob对象
const referenceImageBlobs: Blob[] = [];
for (const img of uploadedImages) {
if (img.startsWith('data:')) {
// 从base64数据创建Blob
@@ -63,6 +165,23 @@ export const PromptComposer: React.FC = () => {
ia[i] = byteString.charCodeAt(i);
}
referenceImageBlobs.push(new Blob([ab], { type: mimeString }));
} else if (img.startsWith('indexeddb://')) {
// 从IndexedDB获取参考图像
const imageId = img.replace('indexeddb://', '');
try {
const blob = await referenceImageService.getReferenceImage(imageId);
if (blob) {
referenceImageBlobs.push(blob);
} else {
console.warn('无法从IndexedDB获取参考图像:', imageId);
// 如果无法获取图像,尝试重新上传
console.log('尝试重新处理参考图像...');
}
} catch (error) {
console.warn('无法从IndexedDB获取参考图像:', imageId, error);
// 如果无法获取图像,尝试重新上传
console.log('尝试重新处理参考图像...');
}
} else if (img.startsWith('blob:')) {
// 从Blob URL获取Blob
const { getBlob } = useAppStore.getState();
@@ -75,17 +194,9 @@ export const PromptComposer: React.FC = () => {
const response = await fetch(img);
if (response.ok) {
const blob = await response.blob();
// 重新添加到AppStore
const newUrl = useAppStore.getState().addBlob(blob);
referenceImageBlobs.push(blob);
// 更新uploadedImages中的URL
const index = uploadedImages.indexOf(img);
if (index !== -1) {
const newImages = [...uploadedImages];
newImages[index] = newUrl;
useAppStore.getState().clearUploadedImages();
newImages.forEach(imageUrl => useAppStore.getState().addUploadedImage(imageUrl));
}
} else {
console.warn('无法重新获取参考图像:', img);
}
} catch (error) {
console.warn('无法重新获取参考图像:', img, error);
@@ -102,9 +213,13 @@ export const PromptComposer: React.FC = () => {
}
}
// 过滤掉无效的Blob只保留有效的参考图像
const validBlobs = referenceImageBlobs.filter(blob => blob.size > 0);
// 即使没有参考图像也继续生成,因为提示文本是必需的
generate({
prompt: currentPrompt,
referenceImages: referenceImageBlobs.length > 0 ? referenceImageBlobs : undefined,
referenceImages: validBlobs.length > 0 ? validBlobs : undefined,
temperature,
seed: seed !== null ? seed : undefined
});
@@ -116,28 +231,36 @@ export const PromptComposer: React.FC = () => {
const handleFileUpload = async (file: File) => {
if (file && file.type.startsWith('image/')) {
try {
// 直接使用Blob创建URL
const blobUrl = addBlob(file);
// 保存参考图像到IndexedDB
const imageId = await referenceImageService.saveReferenceImage(file);
// 创建一个特殊的URL来标识这是存储在IndexedDB中的图像
const imageUrl = `indexeddb://${imageId}`;
if (selectedTool === 'generate') {
// 添加到参考图像最多2张
if (uploadedImages.length < 2) {
addUploadedImage(blobUrl);
addUploadedImage(imageUrl);
}
} else if (selectedTool === 'edit') {
// 编辑模式下添加到单独的编辑参考图像最多2张
if (editReferenceImages.length < 2) {
addEditReferenceImage(blobUrl);
addEditReferenceImage(imageUrl);
}
// 如果没有画布图像,则设置为画布图像
if (!canvasImage) {
setCanvasImage(blobUrl);
setCanvasImage(imageUrl);
}
} else if (selectedTool === 'mask') {
// 遮罩模式下,立即设置为画布图像
clearUploadedImages();
addUploadedImage(blobUrl);
setCanvasImage(blobUrl);
// 遮罩模式下,将图像添加为参考图像而不是清除现有图像
// 只有在没有画布图像时才设置为画布图像
if (!canvasImage) {
setCanvasImage(imageUrl);
}
// 不清除现有的上传图像,而是将新图像添加为参考图像(如果还有空间)
if (uploadedImages.length < 2) {
addUploadedImage(imageUrl);
}
}
} catch (error) {
console.error('上传图像失败:', error);
@@ -171,7 +294,7 @@ export const PromptComposer: React.FC = () => {
}
};
const handleClearSession = () => {
const handleClearSession = async () => {
setCurrentPrompt('');
clearUploadedImages();
clearEditReferenceImages();
@@ -180,6 +303,14 @@ export const PromptComposer: React.FC = () => {
setSeed(null);
setTemperature(0.7);
setShowClearConfirm(false);
// 清空IndexedDB中的所有参考图像
try {
await referenceImageService.clearAllReferenceImages();
console.log('已清空IndexedDB中的所有参考图像');
} catch (error) {
console.error('清空IndexedDB中的参考图像失败:', error);
}
};
const tools = [
@@ -324,25 +455,13 @@ export const PromptComposer: React.FC = () => {
(selectedTool === 'edit' && editReferenceImages.length > 0)) && (
<div className="space-y-2.5">
{(selectedTool === 'generate' ? uploadedImages : editReferenceImages).map((image, index) => (
<div key={index} className="relative">
<img
src={image}
alt={`参考图像 ${index + 1}`}
className="w-full h-20 object-cover rounded-lg border-2 border-gray-200"
/>
<button
onClick={() => selectedTool === 'generate' ? removeUploadedImage(index) : removeEditReferenceImage(index)}
className="absolute -top-2 -right-2 bg-red-500 text-white rounded-full p-1.5 transition-colors shadow-md hover:bg-red-600"
>
<svg xmlns="http://www.w3.org/2000/svg" width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="3">
<line x1="18" y1="6" x2="6" y2="18"></line>
<line x1="6" y1="6" x2="18" y2="18"></line>
</svg>
</button>
<div className="absolute bottom-2 left-2 bg-black/60 text-white text-xs px-2 py-1 rounded-lg">
{index + 1}
</div>
</div>
<ImagePreview
key={index}
image={image}
index={index}
selectedTool={selectedTool}
onRemove={() => selectedTool === 'generate' ? removeUploadedImage(index) : removeEditReferenceImage(index)}
/>
))}
</div>
)}
@@ -423,7 +542,7 @@ export const PromptComposer: React.FC = () => {
<div className="mt-4 animate-in slide-down duration-300">
<PromptSuggestions
onWordSelect={(word) => {
setCurrentPrompt(currentPrompt ? `${currentPrompt}, ${word}` : word);
setCurrentPrompt(currentPrompt ? `${currentPrompt}${word}` : word);
}}
minFrequency={3}
showTitle={false}
@@ -493,7 +612,7 @@ export const PromptComposer: React.FC = () => {
<div className="flex space-x-3">
<Button
variant="destructive"
onClick={handleClearSession}
onClick={async () => await handleClearSession()}
className="flex-1 h-10 text-sm font-semibold card text-gray-700"
>

View File

@@ -1,4 +1,4 @@
import React, { useState, useEffect } from 'react';
import React, { useState, useEffect, useCallback } from 'react';
import { Button } from './ui/Button';
import { useAppStore } from '../store/useAppStore';
import { cn } from '../utils/cn';
@@ -18,17 +18,17 @@ export const PromptSuggestions: React.FC<{
const [showAll, setShowAll] = useState(false);
// 从提示词中提取词语并统计频次
const extractWords = (text: string): string[] => {
const extractWords = useCallback((text: string): string[] => {
// 移除标点符号并分割词语
return text
.toLowerCase()
.replace(/[^\w\s\u4e00-\u9fff]/g, ' ') // 保留中文字符
.split(/\s+/)
.filter(word => word.length > 1); // 过滤掉单字符
};
}, []);
// 统计词语频次
const calculateWordFrequency = (): WordFrequency[] => {
const calculateWordFrequency = useCallback((): WordFrequency[] => {
const wordCount: Record<string, number> = {};
// 收集所有提示词
@@ -52,7 +52,7 @@ export const PromptSuggestions: React.FC<{
});
}
// 提取词语并统计频次
// 统计词语频次
allPrompts.forEach(prompt => {
const words = extractWords(prompt);
words.forEach(word => {
@@ -65,11 +65,11 @@ export const PromptSuggestions: React.FC<{
.map(([word, count]) => ({ word, count }))
.filter(({ count }) => count >= minFrequency)
.sort((a, b) => b.count - a.count);
};
}, [currentProject, minFrequency, extractWords]);
useEffect(() => {
setFrequentWords(calculateWordFrequency());
}, [currentProject, minFrequency]);
}, [currentProject, minFrequency, calculateWordFrequency]);
// 显示的词语数量
const displayWords = showAll ? frequentWords : frequentWords.slice(0, 20);

View File

@@ -1,4 +1,4 @@
import React, { createContext, useContext, useReducer, useState, useEffect, useRef } from 'react';
import React, { createContext, useContext, useEffect, useReducer, useRef } from 'react';
import { Toast } from './Toast';
type ToastType = 'success' | 'error' | 'warning' | 'info';

View File

@@ -1,22 +1,38 @@
import React from 'react';
import { cva, type VariantProps } from 'class-variance-authority';
import { cn } from '../../utils/cn';
export interface InputProps extends React.InputHTMLAttributes<HTMLInputElement> {}
export interface InputProps
extends React.InputHTMLAttributes<HTMLInputElement>,
VariantProps<typeof inputVariants> {
// Additional props can be added here if needed
}
export const Input = React.forwardRef<HTMLInputElement, InputProps>(
({ className, type, ...props }, ref) => {
const inputVariants = cva(
'flex h-10 w-full rounded-md border border-input bg-background px-3 py-2 text-sm ring-offset-background file:border-0 file:bg-transparent file:text-sm file:font-medium placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-50',
{
variants: {
variant: {
default: 'border-gray-300 focus-visible:ring-yellow-400',
},
},
defaultVariants: {
variant: 'default',
},
}
);
const Input = React.forwardRef<HTMLInputElement, InputProps>(
({ className, variant, type, ...props }, ref) => {
return (
<input
type={type}
className={cn(
'flex h-10 w-full rounded-lg border border-gray-300 bg-white px-3 py-2 text-sm text-gray-900 ring-offset-white file:border-0 file:bg-transparent file:text-sm file:font-medium placeholder:text-gray-400 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-yellow-400 focus-visible:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-50',
className
)}
className={cn(inputVariants({ variant, className }))}
ref={ref}
{...props}
/>
);
}
);
Input.displayName = 'Input';
Input.displayName = 'Input';
export { Input };

View File

@@ -1,7 +1,11 @@
import React from 'react';
import { cn } from '../../utils/cn';
export interface TextareaProps extends React.TextareaHTMLAttributes<HTMLTextAreaElement> {}
export interface TextareaProps
extends React.TextareaHTMLAttributes<HTMLTextAreaElement>,
VariantProps<typeof textareaVariants> {
// Additional props can be added here if needed
}
export const Textarea = React.forwardRef<HTMLTextAreaElement, TextareaProps>(
({ className, ...props }, ref) => {

View File

@@ -14,12 +14,21 @@ export const useImageGeneration = () => {
// 创建中断标志引用
const isCancelledRef = React.useRef(false)
// 创建AbortController引用
const abortControllerRef = React.useRef<AbortController | null>(null)
// 保存参考图像Blob的引用防止在错误后丢失
const referenceImageBlobsRef = React.useRef<Blob[]>([])
const generateMutation = useMutation({
mutationFn: async (request: GenerationRequest) => {
// 重置中断标志
isCancelledRef.current = false
// 创建新的AbortController
abortControllerRef.current = new AbortController()
// 将参考图像从base64转换为Blob如果需要
let blobReferenceImages: Blob[] | undefined;
if (request.referenceImages) {
@@ -34,17 +43,21 @@ export const useImageGeneration = () => {
for (let i = 0; i < byteString.length; i++) {
ia[i] = byteString.charCodeAt(i);
}
blobReferenceImages.push(new Blob([ab], { type: mimeString }));
const blob = new Blob([ab], { type: mimeString });
blobReferenceImages.push(blob);
} else {
// 如果已经是Blob直接使用
blobReferenceImages.push(img);
}
}
// 保存参考图像Blob的引用
referenceImageBlobsRef.current = blobReferenceImages;
}
const blobRequest: GenerationRequest = {
...request,
referenceImages: blobReferenceImages
referenceImages: blobReferenceImages,
abortSignal: abortControllerRef.current.signal
};
const result = await geminiService.generateImage(blobRequest)
@@ -63,7 +76,7 @@ export const useImageGeneration = () => {
const { images, usageMetadata } = result;
if (images.length > 0) {
// 直接使用Blob并创建URL避免存储base64数据
const outputAssets: Asset[] = await Promise.all(images.map(async (blob, index) => {
const outputAssets: Asset[] = await Promise.all(images.map(async (blob) => {
// 使用AppStore的addBlob方法存储Blob并获取URL
const blobUrl = useAppStore.getState().addBlob(blob);
@@ -77,7 +90,7 @@ export const useImageGeneration = () => {
checksum += uint8Array[i].toString(16).padStart(2, '0');
}
resolve(checksum || generateId().slice(0, 32));
} catch (error) {
} catch {
resolve(generateId().slice(0, 32));
}
});
@@ -95,7 +108,7 @@ export const useImageGeneration = () => {
// 获取accessToken
const accessToken = import.meta.env.VITE_ACCESS_TOKEN || '';
let uploadResults: any[] | undefined;
let uploadResults: Array<{success: boolean, url?: string, error?: string, timestamp: number}> | undefined;
// 上传生成的图像和参考图像
if (accessToken) {
@@ -105,7 +118,7 @@ export const useImageGeneration = () => {
const outputUploadResults = await uploadImages(imageUrls, accessToken, true);
// 上传参考图像(如果存在,使用缓存机制)
let referenceUploadResults: any[] = [];
let referenceUploadResults: Array<{success: boolean, url?: string, error?: string, timestamp: number}> = [];
if (request.referenceImages && request.referenceImages.length > 0) {
// 将参考图像转换为base64字符串格式上传与老版本保持一致
const referenceBase64s = await Promise.all(request.referenceImages.map(async (blob) => {
@@ -136,7 +149,7 @@ export const useImageGeneration = () => {
console.log(`${uploadResults.length}张图像全部上传成功`);
addToast('图像已成功上传', 'success', 3000);
}
} catch (error) {
} catch {
console.error('上传图像时出错:', error);
addToast('图像上传失败', 'error', 5000);
uploadResults = undefined;
@@ -158,7 +171,7 @@ export const useImageGeneration = () => {
seed: request.seed,
temperature: request.temperature
},
sourceAssets: request.referenceImages ? await Promise.all(request.referenceImages.map(async (blob, index) => {
sourceAssets: request.referenceImages ? await Promise.all(request.referenceImages.map(async (blob) => {
// 将参考图像转换为Blob URL
const blobUrl = useAppStore.getState().addBlob(blob);
@@ -172,7 +185,7 @@ export const useImageGeneration = () => {
checksum += uint8Array[i].toString(16).padStart(2, '0');
}
resolve(checksum || generateId().slice(0, 32));
} catch (error) {
} catch {
resolve(generateId().slice(0, 32));
}
});
@@ -209,11 +222,21 @@ export const useImageGeneration = () => {
const errorDetails = error instanceof Error ? error.stack : undefined
addToast(`图像生成失败: ${errorMessage}`, 'error', 5000, errorDetails)
setIsGenerating(false)
// 保持参考图像不变,以便用户可以重新尝试生成
console.log('生成失败,但参考图像已保留,用户可以重新尝试生成');
// 如果有参考图像数据,确保它们不会被清除
if (referenceImageBlobsRef.current.length > 0) {
console.log(`保留了 ${referenceImageBlobsRef.current.length} 个参考图像,用户可以重新尝试生成`);
}
},
})
const cancelGeneration = () => {
isCancelledRef.current = true
// 取消网络请求
if (abortControllerRef.current) {
abortControllerRef.current.abort()
}
setIsGenerating(false)
addToast('生成已中断', 'info', 3000)
}
@@ -233,12 +256,18 @@ export const useImageEditing = () => {
// 创建中断标志引用
const isCancelledRef = React.useRef(false)
// 创建AbortController引用
const abortControllerRef = React.useRef<AbortController | null>(null)
const editMutation = useMutation({
mutationFn: async (instruction: string) => {
// 重置中断标志
isCancelledRef.current = false
// 创建新的AbortController
abortControllerRef.current = new AbortController()
// 如果可用,始终使用画布图像作为主要目标,否则使用第一张上传的图像
const sourceImage = canvasImage || uploadedImages[0]
if (!sourceImage) throw new Error('没有要编辑的图像')
@@ -269,7 +298,10 @@ export const useImageEditing = () => {
// 获取用于样式指导的参考图像
let referenceImageBlobs: Blob[] = [];
for (const img of editReferenceImages) {
const updatedReferenceImageUrls: string[] = [...editReferenceImages]; // 保存更新后的URL
for (let i = 0; i < editReferenceImages.length; i++) {
const img = editReferenceImages[i];
if (img.startsWith('blob:')) {
// 从Blob URL获取Blob数据
const blob = useAppStore.getState().getBlob(img);
@@ -284,24 +316,15 @@ export const useImageEditing = () => {
referenceImageBlobs.push(blob);
// 重新添加到AppStore
const newUrl = useAppStore.getState().addBlob(blob);
// 更新editReferenceImages中的URL
const index = editReferenceImages.indexOf(img);
if (index !== -1) {
const { removeEditReferenceImage, addEditReferenceImage } = useAppStore.getState();
removeEditReferenceImage(index);
// 重新添加所有图像以保持顺序
const currentImages = [...editReferenceImages];
currentImages[index] = newUrl;
// 清空并重新添加
for (let i = 0; i < currentImages.length; i++) {
if (i < 2) { // 最多2张参考图像
addEditReferenceImage(currentImages[i]);
}
}
}
// 更新editReferenceImages中的URL(但不立即修改状态)
updatedReferenceImageUrls[i] = newUrl;
} else {
// 即使无法重新获取也要保留原始URL并在下次尝试时重新获取
console.warn('无法重新获取参考图像,将在下次尝试时重新获取:', img);
}
} catch (error) {
console.warn('无法重新获取参考图像:', img, error);
} catch {
// 即使出现错误也要保留原始URL并在下次尝试时重新获取
console.warn('无法重新获取参考图像,将在下次尝试时重新获取:', img, error);
}
}
} else if (img.includes('base64,')) {
@@ -311,8 +334,8 @@ export const useImageEditing = () => {
const mimeString = 'image/png';
const ab = new ArrayBuffer(byteString.length);
const ia = new Uint8Array(ab);
for (let i = 0; i < byteString.length; i++) {
ia[i] = byteString.charCodeAt(i);
for (let j = 0; j < byteString.length; j++) {
ia[j] = byteString.charCodeAt(j);
}
referenceImageBlobs.push(new Blob([ab], { type: mimeString }));
} else {
@@ -321,11 +344,28 @@ export const useImageEditing = () => {
const response = await fetch(img);
const blob = await response.blob();
referenceImageBlobs.push(blob);
} catch (error) {
} catch {
console.warn('无法获取参考图像:', img, error);
}
}
}
// 过滤掉无效的Blob只保留有效的参考图像
const validBlobs = referenceImageBlobs.filter(blob => blob.size > 0);
// 更新editReferenceImages状态如果需要
if (updatedReferenceImageUrls.some((url, index) => url !== editReferenceImages[index])) {
const { clearEditReferenceImages, addEditReferenceImage } = useAppStore.getState();
clearEditReferenceImages();
updatedReferenceImageUrls.forEach(imageUrl => {
if (imageUrl) {
addEditReferenceImage(imageUrl);
}
});
}
// 使用有效的参考图像Blob
referenceImageBlobs = validBlobs;
let maskImageBlob: Blob | undefined;
let maskedReferenceImage: string | undefined;
@@ -426,6 +466,7 @@ export const useImageEditing = () => {
maskImage: maskImageBlob,
temperature,
seed,
abortSignal: abortControllerRef.current.signal
}
const result = await geminiService.editImage(request)
@@ -444,7 +485,7 @@ export const useImageEditing = () => {
const { images, usageMetadata } = result;
if (images.length > 0) {
// 直接使用Blob并创建URL避免存储base64数据
const outputAssets: Asset[] = await Promise.all(images.map(async (blob, index) => {
const outputAssets: Asset[] = await Promise.all(images.map(async (blob) => {
// 使用AppStore的addBlob方法存储Blob并获取URL
const blobUrl = useAppStore.getState().addBlob(blob);
@@ -458,7 +499,7 @@ export const useImageEditing = () => {
checksum += uint8Array[i].toString(16).padStart(2, '0');
}
resolve(checksum || generateId().slice(0, 32));
} catch (error) {
} catch {
resolve(generateId().slice(0, 32));
}
});
@@ -499,7 +540,7 @@ export const useImageEditing = () => {
checksum += uint8Array[i].toString(16).padStart(2, '0');
}
resolve(checksum || generateId().slice(0, 32));
} catch (error) {
} catch {
resolve(generateId().slice(0, 32));
}
});
@@ -517,7 +558,7 @@ export const useImageEditing = () => {
// 获取accessToken
const accessToken = import.meta.env.VITE_ACCESS_TOKEN || '';
let uploadResults: any[] | undefined;
let uploadResults: Array<{success: boolean, url?: string, error?: string, timestamp: number}> | undefined;
// 上传编辑后的图像
if (accessToken) {
@@ -527,7 +568,7 @@ export const useImageEditing = () => {
const outputUploadResults = await uploadImages(imageUrls, accessToken, true);
// 上传参考图像(如果存在,使用缓存机制)
let referenceUploadResults: any[] = [];
let referenceUploadResults: Array<{success: boolean, url?: string, error?: string, timestamp: number}> = [];
if (referenceImageBlobs.length > 0) {
// 将参考图像转换为base64字符串格式上传与老版本保持一致
const referenceBase64s = await Promise.all(referenceImageBlobs.map(async (blob) => {
@@ -552,7 +593,7 @@ export const useImageEditing = () => {
console.log(`${uploadResults.length}张编辑后的图像全部上传成功`);
addToast('编辑后的图像已成功上传', 'success', 3000);
}
} catch (error) {
} catch {
console.error('上传编辑后的图像时出错:', error);
addToast('编辑后的图像上传失败', 'error', 5000);
uploadResults = undefined;
@@ -566,12 +607,44 @@ export const useImageEditing = () => {
addToast(`本次编辑消耗 ${usageMetadata.totalTokenCount} Tokens`, 'info', 3000);
}
// 将参考图像Blob转换为Asset对象
const sourceAssets: Asset[] = await Promise.all(referenceImageBlobs.map(async (blob) => {
// 使用AppStore的addBlob方法存储Blob并获取URL
const blobUrl = useAppStore.getState().addBlob(blob);
// 生成校验和使用Blob的一部分数据
const checksum = await new Promise<string>(async (resolve) => {
try {
const arrayBuffer = await blob.slice(0, 32).arrayBuffer();
const uint8Array = new Uint8Array(arrayBuffer);
let checksum = '';
for (let i = 0; i < uint8Array.length; i++) {
checksum += uint8Array[i].toString(16).padStart(2, '0');
}
resolve(checksum || generateId().slice(0, 32));
} catch {
resolve(generateId().slice(0, 32));
}
});
return {
id: generateId(),
type: 'original' as const,
url: blobUrl, // 存储Blob URL而不是base64
mime: 'image/png',
width: 1024,
height: 1024,
checksum
};
}));
const edit: Edit = {
id: generateId(),
parentGenerationId: selectedGenerationId || '',
maskAssetId: brushStrokes.length > 0 ? generateId() : undefined,
maskReferenceAsset,
instruction,
sourceAssets, // 添加参考图像信息
outputAssets,
timestamp: Date.now(),
uploadResults: uploadResults,
@@ -598,11 +671,17 @@ export const useImageEditing = () => {
const errorDetails = error instanceof Error ? error.stack : undefined
addToast(`图像编辑失败: ${errorMessage}`, 'error', 5000, errorDetails)
setIsGenerating(false)
// 保持参考图像不变,以便用户可以重新尝试编辑
console.log('编辑失败,但参考图像已保留,用户可以重新尝试编辑');
},
})
const cancelEdit = () => {
isCancelledRef.current = true
// 取消网络请求
if (abortControllerRef.current) {
abortControllerRef.current.abort()
}
setIsGenerating(false)
addToast('编辑已中断', 'info', 3000)
}

View File

@@ -1,9 +1,10 @@
import { useEffect, useRef, useState } from 'react';
import * as indexedDBService from '../services/indexedDBService';
import { Generation, Edit } from '../types';
export const useIndexedDBListener = () => {
const [generations, setGenerations] = useState<any[]>([]);
const [edits, setEdits] = useState<any[]>([]);
const [generations, setGenerations] = useState<Generation[]>([]);
const [edits, setEdits] = useState<Edit[]>([]);
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
const isMountedRef = useRef(true);

View File

@@ -1,6 +1,8 @@
import { useEffect } from 'react';
import { useAppStore } from '../store/useAppStore';
import { useImageGeneration, useImageEditing } from '../hooks/useImageGeneration';
import * as referenceImageService from '../services/referenceImageService';
import { urlToBlob } from '../utils/imageUtils';
export const useKeyboardShortcuts = () => {
const {
@@ -20,11 +22,24 @@ export const useKeyboardShortcuts = () => {
uploadedImages: generateUploadedImages
} = useAppStore();
const { generate } = useImageGeneration();
const { edit } = useImageEditing();
const { generate, cancelGeneration } = useImageGeneration();
const { edit, cancelEdit } = useImageEditing();
useEffect(() => {
const handleKeyDown = (event: KeyboardEvent) => {
// Handle Escape key to cancel generation/editing
if (event.key === 'Escape') {
if (isGenerating) {
event.preventDefault();
if (selectedTool === 'generate') {
cancelGeneration();
} else {
cancelEdit();
}
}
return;
}
// Ignore if user is typing in an input
if (event.target instanceof HTMLInputElement ||
event.target instanceof HTMLTextAreaElement) {
@@ -34,16 +49,63 @@ export const useKeyboardShortcuts = () => {
if (!isGenerating && currentPrompt.trim()) {
// 触发生成操作
if (selectedTool === 'generate') {
const referenceImages = generateUploadedImages
.filter(img => img.includes('base64,'))
.map(img => img.split('base64,')[1]);
// 使用与PromptComposer中相同的逻辑处理参考图像
const processReferenceImages = async () => {
const referenceImageBlobs: Blob[] = [];
for (const img of generateUploadedImages) {
if (img.startsWith('data:')) {
// 从base64数据创建Blob
const base64 = img.split('base64,')[1];
const byteString = atob(base64);
const mimeString = img.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);
}
referenceImageBlobs.push(new Blob([ab], { type: mimeString }));
} else if (img.startsWith('indexeddb://')) {
// 从IndexedDB获取参考图像
const imageId = img.replace('indexeddb://', '');
try {
const blob = await referenceImageService.getReferenceImage(imageId);
if (blob) {
referenceImageBlobs.push(blob);
}
} catch (error) {
console.warn('无法从IndexedDB获取参考图像:', imageId, error);
}
} else if (img.startsWith('blob:')) {
// 从Blob URL获取Blob
const { getBlob } = useAppStore.getState();
const blob = getBlob(img);
if (blob) {
referenceImageBlobs.push(blob);
}
} else {
// 从URL获取Blob
try {
const blob = await urlToBlob(img);
referenceImageBlobs.push(blob);
} catch (error) {
console.warn('无法获取参考图像:', img, error);
}
}
}
// 过滤掉无效的Blob只保留有效的参考图像
const validBlobs = referenceImageBlobs.filter(blob => blob.size > 0);
generate({
prompt: currentPrompt,
referenceImages: validBlobs.length > 0 ? validBlobs : undefined,
temperature,
seed: seed !== null ? seed : undefined
});
};
generate({
prompt: currentPrompt,
referenceImages: referenceImages.length > 0 ? referenceImages : undefined,
temperature,
seed: seed !== null ? seed : undefined
});
processReferenceImages();
} else if (selectedTool === 'edit' || selectedTool === 'mask') {
edit(currentPrompt);
}
@@ -84,16 +146,63 @@ export const useKeyboardShortcuts = () => {
if (currentPrompt.trim() && !isGenerating) {
event.preventDefault();
if (selectedTool === 'generate') {
const referenceImages = generateUploadedImages
.filter(img => img.includes('base64,'))
.map(img => img.split('base64,')[1]);
// 使用与PromptComposer中相同的逻辑处理参考图像
const processReferenceImages = async () => {
const referenceImageBlobs: Blob[] = [];
for (const img of generateUploadedImages) {
if (img.startsWith('data:')) {
// 从base64数据创建Blob
const base64 = img.split('base64,')[1];
const byteString = atob(base64);
const mimeString = img.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);
}
referenceImageBlobs.push(new Blob([ab], { type: mimeString }));
} else if (img.startsWith('indexeddb://')) {
// 从IndexedDB获取参考图像
const imageId = img.replace('indexeddb://', '');
try {
const blob = await referenceImageService.getReferenceImage(imageId);
if (blob) {
referenceImageBlobs.push(blob);
}
} catch (error) {
console.warn('无法从IndexedDB获取参考图像:', imageId, error);
}
} else if (img.startsWith('blob:')) {
// 从Blob URL获取Blob
const { getBlob } = useAppStore.getState();
const blob = getBlob(img);
if (blob) {
referenceImageBlobs.push(blob);
}
} else {
// 从URL获取Blob
try {
const blob = await urlToBlob(img);
referenceImageBlobs.push(blob);
} catch (error) {
console.warn('无法获取参考图像:', img, error);
}
}
}
// 过滤掉无效的Blob只保留有效的参考图像
const validBlobs = referenceImageBlobs.filter(blob => blob.size > 0);
generate({
prompt: currentPrompt,
referenceImages: validBlobs.length > 0 ? validBlobs : undefined,
temperature,
seed: seed !== null ? seed : undefined
});
};
generate({
prompt: currentPrompt,
referenceImages: referenceImages.length > 0 ? referenceImages : undefined,
temperature,
seed: seed !== null ? seed : undefined
});
processReferenceImages();
} else if (selectedTool === 'edit' || selectedTool === 'mask') {
edit(currentPrompt);
}
@@ -120,6 +229,8 @@ export const useKeyboardShortcuts = () => {
temperature,
seed,
generate,
edit
edit,
cancelGeneration,
cancelEdit
]);
};

View File

@@ -4,9 +4,9 @@ import { Project, Generation, Asset } from '../types';
const CACHE_PREFIX = 'nano-banana';
const CACHE_VERSION = '1.0';
// 限制缓存项目数量
const MAX_CACHED_ITEMS = 50;
const MAX_CACHED_ITEMS = 1000;
// 限制缓存最大年龄 (3天)
const MAX_CACHE_AGE = 3 * 24 * 60 * 60 * 1000;
const MAX_CACHE_AGE = 30 * 24 * 60 * 60 * 1000;
export class CacheService {
private static getKey(type: string, id: string): string {

View File

@@ -9,6 +9,8 @@ export interface GenerationRequest {
referenceImages?: Blob[] // Blob数组
temperature?: number
seed?: number
// 添加abortSignal参数
abortSignal?: AbortSignal
}
export interface EditRequest {
@@ -18,6 +20,8 @@ export interface EditRequest {
maskImage?: Blob // Blob
temperature?: number
seed?: number
// 添加abortSignal参数
abortSignal?: AbortSignal
}
export interface UsageMetadata {
@@ -29,48 +33,130 @@ export interface UsageMetadata {
export interface SegmentationRequest {
image: Blob // Blob
query: string // "像素(x,y)处的对象" 或 "红色汽车"
// 添加abortSignal参数
abortSignal?: AbortSignal
}
export class GeminiService {
// 缓存base64图像数据确保它们不会被清除
private base64ImagesCache: Map<string, string> = new Map()
// 将Blob转换为base64的辅助函数
private async blobToBase64(blob: Blob): Promise<string> {
return new Promise((resolve, reject) => {
const reader = new FileReader();
const reader = new FileReader()
reader.onload = () => {
const result = reader.result as string;
const base64 = result.split(',')[1]; // Remove data:image/png;base64, prefix
resolve(base64);
};
reader.onerror = reject;
reader.readAsDataURL(blob);
});
const result = reader.result as string
const base64 = result.split(',')[1] // Remove data:image/png;base64, prefix
resolve(base64)
}
reader.onerror = reject
reader.readAsDataURL(blob)
})
}
async generateImage(request: GenerationRequest): Promise<{ images: Blob[]; usageMetadata?: any }> {
// 生成Blob的唯一标识符
private async generateBlobId(blob: Blob): Promise<string> {
// 使用Blob的部分内容生成唯一标识符
const arrayBuffer = await blob.slice(0, 1024).arrayBuffer()
const uint8Array = new Uint8Array(arrayBuffer)
let hash = ''
for (let i = 0; i < uint8Array.length; i++) {
hash += uint8Array[i].toString(16).padStart(2, '0')
}
return `${blob.type}-${blob.size}-${hash.substring(0, 32)}`
}
// 清理过期的缓存项(可选)
private cleanupExpiredCache(): void {
// 在这个实现中,我们不自动清理缓存
// 只有在显式调用clearBase64Cache时才清理
console.log('缓存大小:', this.base64ImagesCache.size)
}
async generateImage(request: GenerationRequest): Promise<{ images: Blob[]; usageMetadata?: UsageMetadata }> {
try {
const contents: any[] = [{ text: request.prompt }]
const contents: Array<{ text: string } | { inlineData: { mimeType: string; data: string } }> = [{ text: request.prompt }]
// 如果提供了参考图像则添加
if (request.referenceImages && request.referenceImages.length > 0) {
// 将Blob转换为base64以发送到API
const base64Images = await Promise.all(
request.referenceImages.map(blob => this.blobToBase64(blob))
);
const base64Images: string[] = []
// 为每个参考图像生成或获取base64数据
for (const blob of request.referenceImages) {
// 生成Blob的唯一标识符
const blobId = await this.generateBlobId(blob)
let base64: string
// 检查缓存中是否已有该图像的base64数据
if (this.base64ImagesCache.has(blobId)) {
// 从缓存中获取base64数据
base64 = this.base64ImagesCache.get(blobId)!
console.log('从缓存中获取参考图像base64数据')
} else {
// 转换Blob为base64并缓存结果
base64 = await this.blobToBase64(blob)
// 将base64数据存储到缓存中
this.base64ImagesCache.set(blobId, base64)
console.log('生成并缓存参考图像base64数据')
}
// 如果base64数据为空重新生成
if (!base64 || base64.length === 0) {
console.warn('参考图像base64数据为空重新生成')
base64 = await this.blobToBase64(blob)
// 更新缓存
this.base64ImagesCache.set(blobId, base64)
}
base64Images.push(base64)
}
base64Images.forEach(image => {
contents.push({
inlineData: {
mimeType: 'image/png',
data: image,
},
})
// 确保图像数据不为空
if (image && image.length > 0) {
contents.push({
inlineData: {
mimeType: 'image/png',
data: image,
},
})
} else {
console.warn('跳过空的参考图像数据')
}
})
}
const response = await genAI.models.generateContent({
// 检查contents是否包含有效的图像数据或文本提示
const hasImageData = contents.some(item => item.inlineData && item.inlineData.data && item.inlineData.data.length > 0)
const hasTextPrompt = contents.some(item => item.text && item.text.length > 0)
// 如果既没有图像数据也没有文本提示,抛出错误
if (!hasImageData && !hasTextPrompt) {
throw new Error('没有有效的图像数据或文本提示用于生成')
}
// 准备请求配置包括abortSignal
const generateContentParams: {
model: string;
contents: Array<{ text: string } | { inlineData: { mimeType: string; data: string } }>;
config?: { httpOptions: { abortSignal: AbortSignal } };
} = {
model: 'gemini-2.5-flash-image-preview',
contents,
})
}
// 如果提供了abortSignal则添加到请求配置中
if (request.abortSignal) {
generateContentParams.config = {
httpOptions: {
abortSignal: request.abortSignal
}
}
}
const response = await genAI.models.generateContent(generateContentParams)
// 检查是否有被禁止的内容
if (response.candidates && response.candidates.length > 0) {
@@ -79,24 +165,24 @@ export class GeminiService {
throw new Error('内容被禁止:您的请求包含不允许的内容。请尝试其他提示。')
}
if (candidate.finishReason === 'IMAGE_SAFETY') {
throw new Error('内容被禁止:您的请求包含不允许的内容。请尝试其他提示。')
throw new Error('图像安全检查失败:请求的图像内容可能不安全。请尝试调整提示。')
}
// 检查finishReason为STOP但没有inlineData的情况
if (candidate.finishReason === 'STOP') {
// 检查是否有inlineData
let hasInlineData = false;
let hasInlineData = false
if (candidate.content && candidate.content.parts) {
for (const part of candidate.content.parts) {
if (part.inlineData) {
hasInlineData = true;
break;
hasInlineData = true
break
}
}
}
// 如果没有inlineData则抛出错误
if (!hasInlineData && candidate.content && candidate.content.parts && candidate.content.parts.length > 0) {
throw new Error(candidate.content.parts[0].text || '生成失败:未返回图像数据');
throw new Error(candidate.content.parts[0].text || '生成失败:未返回图像数据')
}
}
}
@@ -108,15 +194,28 @@ export class GeminiService {
for (const part of response.candidates[0].content.parts) {
if (part.inlineData) {
// 将返回的base64数据转换为Blob
const byteString = atob(part.inlineData.data);
const mimeString = part.inlineData.mimeType || 'image/png';
const ab = new ArrayBuffer(byteString.length);
const ia = new Uint8Array(ab);
const byteString = atob(part.inlineData.data)
const mimeString = part.inlineData.mimeType || 'image/png'
const ab = new ArrayBuffer(byteString.length)
const ia = new Uint8Array(ab)
for (let i = 0; i < byteString.length; i++) {
ia[i] = byteString.charCodeAt(i);
ia[i] = byteString.charCodeAt(i)
}
const blob = new Blob([ab], { type: mimeString });
images.push(blob);
const blob = new Blob([ab], { type: mimeString })
images.push(blob)
}
}
// 如果没有图像数据但有文本响应,抛出包含文本的错误
if (images.length === 0) {
let textResponse = ''
for (const part of response.candidates[0].content.parts) {
if (part.text) {
textResponse += part.text
}
}
if (textResponse) {
throw new Error(`生成失败:${textResponse}`)
}
}
}
@@ -127,6 +226,10 @@ export class GeminiService {
return { images, usageMetadata }
} catch (error) {
console.error('生成图像时出错:', error)
// 检查是否是由于abortSignal导致的取消
if (error instanceof Error && error.name === 'AbortError') {
throw new Error('生成已取消')
}
if (error instanceof Error && error.message) {
throw error
}
@@ -134,11 +237,35 @@ export class GeminiService {
}
}
async editImage(request: EditRequest): Promise<{ images: Blob[]; usageMetadata?: any }> {
async editImage(request: EditRequest): Promise<{ images: Blob[]; usageMetadata?: UsageMetadata }> {
try {
// 将Blob转换为base64以发送到API
const originalImageBase64 = await this.blobToBase64(request.originalImage);
// 将原始图像Blob转换为base64以发送到API
let originalImageBase64: string
// 生成原始图像Blob的唯一标识符
const originalBlobId = await this.generateBlobId(request.originalImage)
// 检查缓存中是否已有该图像的base64数据
if (this.base64ImagesCache.has(originalBlobId)) {
// 从缓存中获取base64数据
originalImageBase64 = this.base64ImagesCache.get(originalBlobId)!
console.log('从缓存中获取原始图像base64数据')
} else {
// 转换Blob为base64并缓存结果
originalImageBase64 = await this.blobToBase64(request.originalImage)
// 将base64数据存储到缓存中
this.base64ImagesCache.set(originalBlobId, originalImageBase64)
console.log('生成并缓存原始图像base64数据')
}
// 如果base64数据为空重新生成
if (!originalImageBase64 || originalImageBase64.length === 0) {
console.warn('原始图像base64数据为空重新生成')
originalImageBase64 = await this.blobToBase64(request.originalImage)
// 更新缓存
this.base64ImagesCache.set(originalBlobId, originalImageBase64)
}
const contents = [
{ text: this.buildEditPrompt(request) },
{
@@ -152,35 +279,123 @@ export class GeminiService {
// 如果提供了参考图像则添加
if (request.referenceImages && request.referenceImages.length > 0) {
// 将Blob转换为base64以发送到API
const base64ReferenceImages = await Promise.all(
request.referenceImages.map(blob => this.blobToBase64(blob))
);
const base64ReferenceImages: string[] = []
// 为每个参考图像生成或获取base64数据
for (const blob of request.referenceImages) {
// 生成Blob的唯一标识符
const blobId = await this.generateBlobId(blob)
let base64: string
// 检查缓存中是否已有该图像的base64数据
if (this.base64ImagesCache.has(blobId)) {
// 从缓存中获取base64数据
base64 = this.base64ImagesCache.get(blobId)!
console.log('从缓存中获取参考图像base64数据')
} else {
// 转换Blob为base64并缓存结果
base64 = await this.blobToBase64(blob)
// 将base64数据存储到缓存中
this.base64ImagesCache.set(blobId, base64)
console.log('生成并缓存参考图像base64数据')
}
// 如果base64数据为空重新生成
if (!base64 || base64.length === 0) {
console.warn('参考图像base64数据为空重新生成')
base64 = await this.blobToBase64(blob)
// 更新缓存
this.base64ImagesCache.set(blobId, base64)
}
base64ReferenceImages.push(base64)
}
base64ReferenceImages.forEach(image => {
contents.push({
inlineData: {
mimeType: 'image/png',
data: image,
},
})
// 确保图像数据不为空
if (image && image.length > 0) {
contents.push({
inlineData: {
mimeType: 'image/png',
data: image,
},
})
} else {
console.warn('跳过空的参考图像数据')
}
})
}
if (request.maskImage) {
// 将Blob转换为base64以发送到API
const maskImageBase64 = await this.blobToBase64(request.maskImage);
contents.push({
inlineData: {
mimeType: 'image/png',
data: maskImageBase64,
},
})
// 将遮罩图像Blob转换为base64以发送到API
let maskImageBase64: string
// 生成遮罩图像Blob的唯一标识符
const maskBlobId = await this.generateBlobId(request.maskImage)
// 检查缓存中是否已有该图像的base64数据
if (this.base64ImagesCache.has(maskBlobId)) {
// 从缓存中获取base64数据
maskImageBase64 = this.base64ImagesCache.get(maskBlobId)!
console.log('从缓存中获取遮罩图像base64数据')
} else {
// 转换Blob为base64并缓存结果
maskImageBase64 = await this.blobToBase64(request.maskImage)
// 将base64数据存储到缓存中
this.base64ImagesCache.set(maskBlobId, maskImageBase64)
console.log('生成并缓存遮罩图像base64数据')
}
// 如果base64数据为空重新生成
if (!maskImageBase64 || maskImageBase64.length === 0) {
console.warn('遮罩图像base64数据为空重新生成')
maskImageBase64 = await this.blobToBase64(request.maskImage)
// 更新缓存
this.base64ImagesCache.set(maskBlobId, maskImageBase64)
}
// 确保遮罩图像数据不为空
if (maskImageBase64 && maskImageBase64.length > 0) {
contents.push({
inlineData: {
mimeType: 'image/png',
data: maskImageBase64,
},
})
} else {
console.warn('跳过空的遮罩图像数据')
}
}
const response = await genAI.models.generateContent({
// 检查contents是否包含有效的图像数据或文本提示
const hasImageData = contents.some(item => item.inlineData && item.inlineData.data && item.inlineData.data.length > 0)
const hasTextPrompt = contents.some(item => item.text && item.text.length > 0)
// 如果既没有图像数据也没有文本提示,抛出错误
if (!hasImageData && !hasTextPrompt) {
throw new Error('没有有效的图像数据或文本提示用于编辑')
}
// 准备请求配置包括abortSignal
const generateContentParams: {
model: string;
contents: Array<{ text: string } | { inlineData: { mimeType: string; data: string } }>;
config?: { httpOptions: { abortSignal: AbortSignal } };
} = {
model: 'gemini-2.5-flash-image-preview',
contents,
})
}
// 如果提供了abortSignal则添加到请求配置中
if (request.abortSignal) {
generateContentParams.config = {
httpOptions: {
abortSignal: request.abortSignal
}
}
}
const response = await genAI.models.generateContent(generateContentParams)
// 检查是否有被禁止的内容
if (response.candidates && response.candidates.length > 0) {
@@ -188,22 +403,25 @@ export class GeminiService {
if (candidate.finishReason === 'PROHIBITED_CONTENT') {
throw new Error('内容被禁止:您的请求包含不允许的内容。请尝试其他提示。')
}
if (candidate.finishReason === 'IMAGE_SAFETY') {
throw new Error('图像安全检查失败:请求的图像内容可能不安全。请尝试调整提示词。')
}
// 检查finishReason为STOP但没有inlineData的情况
if (candidate.finishReason === 'STOP') {
// 检查是否有inlineData
let hasInlineData = false;
let hasInlineData = false
if (candidate.content && candidate.content.parts) {
for (const part of candidate.content.parts) {
if (part.inlineData) {
hasInlineData = true;
break;
hasInlineData = true
break
}
}
}
// 如果没有inlineData则抛出错误
if (!hasInlineData && candidate.content && candidate.content.parts && candidate.content.parts.length > 0) {
throw new Error(candidate.content.parts[0].text || '编辑失败:未返回图像数据');
throw new Error(candidate.content.parts[0].text || '编辑失败:未返回图像数据')
}
}
}
@@ -215,15 +433,28 @@ export class GeminiService {
for (const part of response.candidates[0].content.parts) {
if (part.inlineData) {
// 将返回的base64数据转换为Blob
const byteString = atob(part.inlineData.data);
const mimeString = part.inlineData.mimeType || 'image/png';
const ab = new ArrayBuffer(byteString.length);
const ia = new Uint8Array(ab);
const byteString = atob(part.inlineData.data)
const mimeString = part.inlineData.mimeType || 'image/png'
const ab = new ArrayBuffer(byteString.length)
const ia = new Uint8Array(ab)
for (let i = 0; i < byteString.length; i++) {
ia[i] = byteString.charCodeAt(i);
ia[i] = byteString.charCodeAt(i)
}
const blob = new Blob([ab], { type: mimeString });
images.push(blob);
const blob = new Blob([ab], { type: mimeString })
images.push(blob)
}
}
// 如果没有图像数据但有文本响应,抛出包含文本的错误
if (images.length === 0) {
let textResponse = ''
for (const part of response.candidates[0].content.parts) {
if (part.text) {
textResponse += part.text
}
}
if (textResponse) {
throw new Error(`编辑失败:${textResponse}`)
}
}
}
@@ -234,6 +465,10 @@ export class GeminiService {
return { images, usageMetadata }
} catch (error) {
console.error('编辑图像时出错:', error)
// 检查是否是由于abortSignal导致的取消
if (error instanceof Error && error.name === 'AbortError') {
throw new Error('编辑已取消')
}
if (error instanceof Error && error.message) {
throw error
}
@@ -241,11 +476,35 @@ export class GeminiService {
}
}
async segmentImage(request: SegmentationRequest): Promise<any> {
async segmentImage(request: SegmentationRequest): Promise<{ masks: Array<{ label: string; box_2d: [number, number, number, number]; mask: string }> }> {
try {
// 将Blob转换为base64以发送到API
const imageBase64 = await this.blobToBase64(request.image);
// 将图像Blob转换为base64以发送到API
let imageBase64: string
// 生成图像Blob的唯一标识符
const blobId = await this.generateBlobId(request.image)
// 检查缓存中是否已有该图像的base64数据
if (this.base64ImagesCache.has(blobId)) {
// 从缓存中获取base64数据
imageBase64 = this.base64ImagesCache.get(blobId)!
console.log('从缓存中获取分割图像base64数据')
} else {
// 转换Blob为base64并缓存结果
imageBase64 = await this.blobToBase64(request.image)
// 将base64数据存储到缓存中
this.base64ImagesCache.set(blobId, imageBase64)
console.log('生成并缓存分割图像base64数据')
}
// 如果base64数据为空重新生成
if (!imageBase64 || imageBase64.length === 0) {
console.warn('分割图像base64数据为空重新生成')
imageBase64 = await this.blobToBase64(request.image)
// 更新缓存
this.base64ImagesCache.set(blobId, imageBase64)
}
const prompt = [
{
text: `分析此图像并为以下对象创建分割遮罩: ${request.query}
@@ -263,18 +522,49 @@ export class GeminiService {
仅分割请求的特定对象或区域。遮罩应该是二进制PNG其中白色像素(255)表示选定区域,黑色像素(0)表示背景。`,
},
{
]
// 确保图像数据不为空
if (imageBase64 && imageBase64.length > 0) {
prompt.push({
inlineData: {
mimeType: 'image/png',
data: imageBase64,
},
},
]
})
} else {
console.warn('跳过空的分割图像数据')
}
const response = await genAI.models.generateContent({
// 检查prompt是否包含有效的图像数据或文本提示
const hasImageData = prompt.some(item => item.inlineData && item.inlineData.data && item.inlineData.data.length > 0)
const hasTextPrompt = prompt.some(item => item.text && item.text.length > 0)
// 如果既没有图像数据也没有文本提示,抛出错误
if (!hasImageData && !hasTextPrompt) {
throw new Error('没有有效的图像数据或文本提示用于分割')
}
// 准备请求配置包括abortSignal
const generateContentParams: {
model: string;
contents: Array<{ text: string } | { inlineData: { mimeType: string; data: string } }>;
config?: { httpOptions: { abortSignal: AbortSignal } };
} = {
model: 'gemini-2.5-flash-image-preview',
contents: prompt,
})
}
// 如果提供了abortSignal则添加到请求配置中
if (request.abortSignal) {
generateContentParams.config = {
httpOptions: {
abortSignal: request.abortSignal
}
}
}
const response = await genAI.models.generateContent(generateContentParams)
// 检查是否有被禁止的内容
if (response.candidates && response.candidates.length > 0) {
@@ -282,22 +572,25 @@ export class GeminiService {
if (candidate.finishReason === 'PROHIBITED_CONTENT') {
throw new Error('内容被禁止:您的请求包含不允许的内容。请尝试其他提示。')
}
if (candidate.finishReason === 'IMAGE_SAFETY') {
throw new Error('图像安全检查失败:请求的图像内容可能不安全。请尝试调整提示词。')
}
// 检查finishReason为STOP但没有inlineData的情况
if (candidate.finishReason === 'STOP') {
// 检查是否有inlineData
let hasInlineData = false;
let hasInlineData = false
if (candidate.content && candidate.content.parts) {
for (const part of candidate.content.parts) {
if (part.inlineData) {
hasInlineData = true;
break;
hasInlineData = true
break
}
}
}
// 如果没有inlineData则抛出错误
if (!hasInlineData && candidate.content && candidate.content.parts && candidate.content.parts.length > 0) {
throw new Error(candidate.content.parts[0].text || '分割失败:未返回结果数据');
throw new Error(candidate.content.parts[0].text || '分割失败:未返回结果数据')
}
}
}
@@ -306,6 +599,10 @@ export class GeminiService {
return JSON.parse(responseText)
} catch (error) {
console.error('分割图像时出错:', error)
// 检查是否是由于abortSignal导致的取消
if (error instanceof Error && error.name === 'AbortError') {
throw new Error('分割已取消')
}
if (error instanceof Error && error.message) {
throw error
}
@@ -316,12 +613,19 @@ export class GeminiService {
private buildEditPrompt(request: EditRequest): string {
const maskInstruction = request.maskImage ? '\n\n重要: 仅在遮罩图像显示白色像素(值255)的地方应用更改。完全不更改所有其他区域。精确遵守遮罩边界并在边缘保持无缝混合。' : ''
return `根据以下指令编辑此图像: ${request.instruction}
return `根据以下指令编辑此图像: ${request.instruction}\n\n保持原始图像的光照、透视和整体构图。使更改看起来自然且无缝集成。${maskInstruction}\n\n保持图像质量并确保编辑看起来专业且逼真。`
}
保持原始图像的光照、透视和整体构图。使更改看起来自然且无缝集成。${maskInstruction}
// 公共方法清除base64图像缓存
public clearBase64Cache(): void {
this.base64ImagesCache.clear()
console.log('已清除base64图像缓存')
}
保持图像质量并确保编辑看起来专业且逼真。`
// 公共方法:获取缓存大小
public getCacheSize(): number {
return this.base64ImagesCache.size
}
}
export const geminiService = new GeminiService()
export const geminiService = new GeminiService()

View File

@@ -2,13 +2,14 @@ import { Generation, Edit } from '../types';
// 数据库配置
const DB_NAME = 'NanoBananaDB';
const DB_VERSION = 1;
const DB_VERSION = 2; // 更新版本号
const GENERATIONS_STORE = 'generations';
const EDITS_STORE = 'edits';
const REFERENCE_IMAGES_STORE = 'referenceImages'; // 新增参考图像存储
// 重试配置
const MAX_RETRIES = 3;
const RETRY_DELAY = 1000;
// const MAX_RETRIES = 3;
// const RETRY_DELAY = 1000;
// IndexedDB实例
let db: IDBDatabase | null = null;
@@ -45,6 +46,12 @@ export const initDB = (): Promise<void> => {
editStore.createIndex('timestamp', 'timestamp', { unique: false });
editStore.createIndex('parentGenerationId', 'parentGenerationId', { unique: false });
}
// 创建参考图像存储
if (!db.objectStoreNames.contains(REFERENCE_IMAGES_STORE)) {
const refImageStore = db.createObjectStore(REFERENCE_IMAGES_STORE, { keyPath: 'id' });
refImageStore.createIndex('timestamp', 'timestamp', { unique: false });
}
};
});
};
@@ -318,7 +325,7 @@ export const cleanupBase64Data = async (): Promise<void> => {
let needsUpdate = false;
// 清理源资产中的base64数据
const cleanedSourceAssets = generation.sourceAssets.map((asset: any) => {
const cleanedSourceAssets = generation.sourceAssets.map((asset) => {
if (asset.url && asset.url.startsWith('data:')) {
needsUpdate = true;
return {
@@ -330,7 +337,7 @@ export const cleanupBase64Data = async (): Promise<void> => {
});
// 清理输出资产中的base64数据
const cleanedOutputAssets = generation.outputAssets.map((asset: any) => {
const cleanedOutputAssets = generation.outputAssets.map((asset) => {
if (asset.url && asset.url.startsWith('data:')) {
needsUpdate = true;
return {
@@ -375,7 +382,7 @@ export const cleanupBase64Data = async (): Promise<void> => {
}
// 清理输出资产中的base64数据
const cleanedOutputAssets = edit.outputAssets.map((asset: any) => {
const cleanedOutputAssets = edit.outputAssets.map((asset) => {
if (asset.url && asset.url.startsWith('data:')) {
needsUpdate = true;
return {
@@ -478,6 +485,86 @@ export const deleteEdits = async (ids: string[]): Promise<void> => {
return Promise.all(promises).then(() => undefined);
};
/**
* 添加参考图像
*/
export const addReferenceImage = async (image: { id: string; data: string; timestamp: number }): Promise<void> => {
const db = getDB();
const transaction = db.transaction([REFERENCE_IMAGES_STORE], 'readwrite');
const store = transaction.objectStore(REFERENCE_IMAGES_STORE);
return new Promise((resolve, reject) => {
const request = store.add(image);
request.onsuccess = () => resolve();
request.onerror = () => reject(request.error);
});
};
/**
* 获取所有参考图像
*/
export const getAllReferenceImages = async (): Promise<Array<{ id: string; data: string; timestamp: number }>> => {
const db = getDB();
const transaction = db.transaction([REFERENCE_IMAGES_STORE], 'readonly');
const store = transaction.objectStore(REFERENCE_IMAGES_STORE);
const index = store.index('timestamp');
return new Promise((resolve, reject) => {
const request = index.getAll();
request.onsuccess = () => {
// 按时间倒序排列
const images = request.result.sort((a, b) => b.timestamp - a.timestamp);
resolve(images);
};
request.onerror = () => reject(request.error);
});
};
/**
* 根据ID获取参考图像
*/
export const getReferenceImageById = async (id: string): Promise<{ id: string; data: string; timestamp: number } | undefined> => {
const db = getDB();
const transaction = db.transaction([REFERENCE_IMAGES_STORE], 'readonly');
const store = transaction.objectStore(REFERENCE_IMAGES_STORE);
return new Promise((resolve, reject) => {
const request = store.get(id);
request.onsuccess = () => resolve(request.result);
request.onerror = () => reject(request.error);
});
};
/**
* 删除参考图像
*/
export const deleteReferenceImage = async (id: string): Promise<void> => {
const db = getDB();
const transaction = db.transaction([REFERENCE_IMAGES_STORE], 'readwrite');
const store = transaction.objectStore(REFERENCE_IMAGES_STORE);
return new Promise((resolve, reject) => {
const request = store.delete(id);
request.onsuccess = () => resolve();
request.onerror = () => reject(request.error);
});
};
/**
* 清空所有参考图像
*/
export const clearAllReferenceImages = async (): Promise<void> => {
const db = getDB();
const transaction = db.transaction([REFERENCE_IMAGES_STORE], 'readwrite');
const store = transaction.objectStore(REFERENCE_IMAGES_STORE);
return new Promise((resolve, reject) => {
const request = store.clear();
request.onsuccess = () => resolve();
request.onerror = () => reject(request.error);
});
};
/**
* 清空所有记录
*/
@@ -490,6 +577,9 @@ export const clearAllRecords = async (): Promise<void> => {
const editTransaction = db.transaction([EDITS_STORE], 'readwrite');
const editStore = editTransaction.objectStore(EDITS_STORE);
const refImageTransaction = db.transaction([REFERENCE_IMAGES_STORE], 'readwrite');
const refImageStore = refImageTransaction.objectStore(REFERENCE_IMAGES_STORE);
return Promise.all([
new Promise<void>((resolve, reject) => {
const request = genStore.clear();
@@ -500,13 +590,14 @@ export const clearAllRecords = async (): Promise<void> => {
const request = editStore.clear();
request.onsuccess = () => resolve();
request.onerror = () => reject(request.error);
}),
new Promise<void>((resolve, reject) => {
const request = refImageStore.clear();
request.onsuccess = () => resolve();
request.onerror = () => reject(request.error);
})
]).then(() => undefined);
};
/**
* 关闭数据库连接
*/
export const closeDB = (): void => {
if (db) {
db.close();

View File

@@ -0,0 +1,114 @@
import * as indexedDBService from './indexedDBService';
import { generateId } from '../utils/imageUtils';
// 初始化数据库
export const initReferenceImageDB = async (): Promise<void> => {
try {
await indexedDBService.initDB();
} catch (error) {
console.error('初始化参考图像数据库失败:', error);
throw error;
}
};
// 将Blob转换为base64
const blobToBase64 = (blob: Blob): Promise<string> => {
return new Promise((resolve, reject) => {
const reader = new FileReader();
reader.onload = () => {
const result = reader.result as string;
resolve(result);
};
reader.onerror = reject;
reader.readAsDataURL(blob);
});
};
// 将base64转换为Blob
const base64ToBlob = (base64: string, mimeType: string): Blob => {
const byteString = atob(base64.split(',')[1]);
const ab = new ArrayBuffer(byteString.length);
const ia = new Uint8Array(ab);
for (let i = 0; i < byteString.length; i++) {
ia[i] = byteString.charCodeAt(i);
}
return new Blob([ab], { type: mimeType });
};
// 保存参考图像到IndexedDB
export const saveReferenceImage = async (blob: Blob): Promise<string> => {
try {
// 生成唯一ID
const id = generateId();
// 转换Blob为base64
const base64Data = await blobToBase64(blob);
// 保存到IndexedDB
await indexedDBService.addReferenceImage({
id,
data: base64Data,
timestamp: Date.now()
});
console.log('参考图像已保存到IndexedDB:', id);
return id;
} catch (error) {
console.error('保存参考图像失败:', error);
throw error;
}
};
// 从IndexedDB获取参考图像
export const getReferenceImage = async (id: string): Promise<Blob | null> => {
try {
const imageRecord = await indexedDBService.getReferenceImageById(id);
if (!imageRecord) {
console.warn('未找到参考图像:', id);
return null;
}
// 从base64数据创建Blob
const mimeType = imageRecord.data.match(/data:([^;]+)/)?.[1] || 'image/png';
const blob = base64ToBlob(imageRecord.data, mimeType);
console.log('参考图像已从IndexedDB获取:', id);
return blob;
} catch (error) {
console.error('获取参考图像失败:', error);
return null;
}
};
// 删除参考图像
export const deleteReferenceImage = async (id: string): Promise<void> => {
try {
await indexedDBService.deleteReferenceImage(id);
console.log('参考图像已从IndexedDB删除:', id);
} catch (error) {
console.error('删除参考图像失败:', error);
throw error;
}
};
// 获取所有参考图像ID
export const getAllReferenceImageIds = async (): Promise<string[]> => {
try {
const images = await indexedDBService.getAllReferenceImages();
return images.map(image => image.id);
} catch (error) {
console.error('获取所有参考图像ID失败:', error);
return [];
}
};
// 清空所有参考图像
export const clearAllReferenceImages = async (): Promise<void> => {
try {
await indexedDBService.clearAllReferenceImages();
console.log('所有参考图像已从IndexedDB清空');
} catch (error) {
console.error('清空所有参考图像失败:', error);
throw error;
}
};

View File

@@ -1,8 +1,9 @@
import { create } from 'zustand';
import { devtools, persist } from 'zustand/middleware';
import { Project, Generation, Edit, SegmentationMask, BrushStroke, UploadResult } from '../types';
import { Generation, Edit, BrushStroke, UploadResult } from '../types';
import { generateId } from '../utils/imageUtils';
import * as indexedDBService from '../services/indexedDBService';
import * as referenceImageService from '../services/referenceImageService';
// 定义不包含图像数据的轻量级项目结构
interface LightweightProject {
@@ -135,7 +136,7 @@ interface AppState {
}
// 限制历史记录数量
const MAX_HISTORY_ITEMS = 50;
const MAX_HISTORY_ITEMS = 1000;
export const useAppStore = create<AppState>()(
devtools(
@@ -179,18 +180,64 @@ export const useAppStore = create<AppState>()(
addUploadedImage: (url) => set((state) => ({
uploadedImages: [...state.uploadedImages, url]
})),
removeUploadedImage: (index) => set((state) => ({
uploadedImages: state.uploadedImages.filter((_, i) => i !== index)
})),
clearUploadedImages: () => set({ uploadedImages: [] }),
removeUploadedImage: (index) => set((state) => {
// 如果删除的是IndexedDB中的参考图像同时从IndexedDB中删除
const imageUrl = state.uploadedImages[index];
if (imageUrl && imageUrl.startsWith('indexeddb://')) {
const imageId = imageUrl.replace('indexeddb://', '');
referenceImageService.deleteReferenceImage(imageId).catch(err => {
console.error('删除参考图像失败:', err);
});
}
return {
uploadedImages: state.uploadedImages.filter((_, i) => i !== index)
};
}),
clearUploadedImages: () => set((state) => {
// 删除所有存储在IndexedDB中的参考图像
state.uploadedImages.forEach(imageUrl => {
if (imageUrl.startsWith('indexeddb://')) {
const imageId = imageUrl.replace('indexeddb://', '');
referenceImageService.deleteReferenceImage(imageId).catch(err => {
console.error('删除参考图像失败:', err);
});
}
});
return { uploadedImages: [] };
}),
addEditReferenceImage: (url) => set((state) => ({
editReferenceImages: [...state.editReferenceImages, url]
})),
removeEditReferenceImage: (index) => set((state) => ({
editReferenceImages: state.editReferenceImages.filter((_, i) => i !== index)
})),
clearEditReferenceImages: () => set({ editReferenceImages: [] }),
removeEditReferenceImage: (index) => set((state) => {
// 如果删除的是IndexedDB中的参考图像同时从IndexedDB中删除
const imageUrl = state.editReferenceImages[index];
if (imageUrl && imageUrl.startsWith('indexeddb://')) {
const imageId = imageUrl.replace('indexeddb://', '');
referenceImageService.deleteReferenceImage(imageId).catch(err => {
console.error('删除参考图像失败:', err);
});
}
return {
editReferenceImages: state.editReferenceImages.filter((_, i) => i !== index)
};
}),
clearEditReferenceImages: () => set((state) => {
// 删除所有存储在IndexedDB中的参考图像
state.editReferenceImages.forEach(imageUrl => {
if (imageUrl.startsWith('indexeddb://')) {
const imageId = imageUrl.replace('indexeddb://', '');
referenceImageService.deleteReferenceImage(imageId).catch(err => {
console.error('删除参考图像失败:', err);
});
}
});
return { editReferenceImages: [] };
}),
addBrushStroke: (stroke) => set((state) => ({
brushStrokes: [...state.brushStrokes, stroke]
@@ -342,43 +389,18 @@ export const useAppStore = create<AppState>()(
};
// 清理旧记录以保持在限制内
if (updatedProject.generations.length > MAX_HISTORY_ITEMS) {
// 收集需要释放的Blob URLs
const urlsToRevoke: string[] = [];
const generationsToRemove = updatedProject.generations.slice(0, updatedProject.generations.length - MAX_HISTORY_ITEMS);
generationsToRemove.forEach(gen => {
gen.sourceAssets.forEach(asset => {
if (asset.blobUrl.startsWith('blob:')) {
urlsToRevoke.push(asset.blobUrl);
}
});
gen.outputAssetsBlobUrls.forEach(url => {
if (url.startsWith('blob:')) {
urlsToRevoke.push(url);
}
});
});
// 释放Blob URLs
if (urlsToRevoke.length > 0) {
set((innerState) => {
urlsToRevoke.forEach(url => {
URL.revokeObjectURL(url);
const newBlobStore = new Map(innerState.blobStore);
newBlobStore.delete(url);
innerState = { ...innerState, blobStore: newBlobStore };
});
return innerState;
});
}
// 清理数组
updatedProject.generations.splice(0, updatedProject.generations.length - MAX_HISTORY_ITEMS);
// 同时清理IndexedDB中的旧记录
indexedDBService.cleanupOldRecords(MAX_HISTORY_ITEMS).catch(err => {
console.error('清理IndexedDB旧记录失败:', err);
});
}
if (updatedProject.generations.length > MAX_HISTORY_ITEMS) {
// 不再清理Blob URLs以确保参考图像不会被意外删除
// 只记录信息
console.log('历史记录已达到限制但Blob清理已禁用参考图像将被永久保留');
// 清理数组
updatedProject.generations.splice(0, updatedProject.generations.length - MAX_HISTORY_ITEMS);
// 同时清理IndexedDB中的旧记录
indexedDBService.cleanupOldRecords(MAX_HISTORY_ITEMS).catch(err => {
console.error('清理IndexedDB旧记录失败:', err);
});
}
return {
currentProject: updatedProject
@@ -472,32 +494,9 @@ export const useAppStore = create<AppState>()(
// 清理旧记录以保持在限制内
if (updatedProject.edits.length > MAX_HISTORY_ITEMS) {
// 收集需要释放的Blob URLs
const urlsToRevoke: string[] = [];
const editsToRemove = updatedProject.edits.slice(0, updatedProject.edits.length - MAX_HISTORY_ITEMS);
editsToRemove.forEach(edit => {
if (edit.maskReferenceAssetBlobUrl && edit.maskReferenceAssetBlobUrl.startsWith('blob:')) {
urlsToRevoke.push(edit.maskReferenceAssetBlobUrl);
}
edit.outputAssetsBlobUrls.forEach(url => {
if (url.startsWith('blob:')) {
urlsToRevoke.push(url);
}
});
});
// 释放Blob URLs
if (urlsToRevoke.length > 0) {
set((innerState) => {
urlsToRevoke.forEach(url => {
URL.revokeObjectURL(url);
const newBlobStore = new Map(innerState.blobStore);
newBlobStore.delete(url);
innerState = { ...innerState, blobStore: newBlobStore };
});
return innerState;
});
}
// 不再清理Blob URLs,以确保参考图像不会被意外删除
// 只记录信息
console.log('编辑记录已达到限制但Blob清理已禁用参考图像将被永久保留');
// 清理数组
updatedProject.edits.splice(0, updatedProject.edits.length - MAX_HISTORY_ITEMS);
@@ -528,56 +527,20 @@ export const useAppStore = create<AppState>()(
const generations = [...state.currentProject.generations];
const edits = [...state.currentProject.edits];
// 收集需要释放的Blob URLs
const urlsToRevoke: string[] = [];
// 不再清理Blob URLs,以确保参考图像不会被意外删除
// 只记录信息
console.log('历史记录清理已禁用,参考图像将被永久保留');
// 如果生成记录超过限制,只保留最新的记录
if (generations.length > MAX_HISTORY_ITEMS) {
const generationsToRemove = generations.slice(0, generations.length - MAX_HISTORY_ITEMS);
generationsToRemove.forEach(gen => {
gen.sourceAssets.forEach(asset => {
if (asset.blobUrl.startsWith('blob:')) {
urlsToRevoke.push(asset.blobUrl);
}
});
gen.outputAssetsBlobUrls.forEach(url => {
if (url.startsWith('blob:')) {
urlsToRevoke.push(url);
}
});
});
generations.splice(0, generations.length - MAX_HISTORY_ITEMS);
}
// 如果编辑记录超过限制,只保留最新的记录
if (edits.length > MAX_HISTORY_ITEMS) {
const editsToRemove = edits.slice(0, edits.length - MAX_HISTORY_ITEMS);
editsToRemove.forEach(edit => {
if (edit.maskReferenceAssetBlobUrl && edit.maskReferenceAssetBlobUrl.startsWith('blob:')) {
urlsToRevoke.push(edit.maskReferenceAssetBlobUrl);
}
edit.outputAssetsBlobUrls.forEach(url => {
if (url.startsWith('blob:')) {
urlsToRevoke.push(url);
}
});
});
edits.splice(0, edits.length - MAX_HISTORY_ITEMS);
}
// 释放Blob URLs
if (urlsToRevoke.length > 0) {
set((innerState) => {
urlsToRevoke.forEach(url => {
URL.revokeObjectURL(url);
const newBlobStore = new Map(innerState.blobStore);
newBlobStore.delete(url);
innerState = { ...innerState, blobStore: newBlobStore };
});
return innerState;
});
}
// 同时清理IndexedDB中的旧记录
indexedDBService.cleanupOldRecords(MAX_HISTORY_ITEMS).catch(err => {
console.error('清理IndexedDB旧记录失败:', err);
@@ -594,110 +557,35 @@ export const useAppStore = create<AppState>()(
}),
// 释放指定的Blob URLs
revokeBlobUrls: (urls: string[]) => set((state) => {
urls.forEach(url => {
if (state.blobStore.has(url)) {
URL.revokeObjectURL(url);
const newBlobStore = new Map(state.blobStore);
newBlobStore.delete(url);
state = { ...state, blobStore: newBlobStore };
}
});
revokeBlobUrls: () => set((state) => {
// 不再自动清理Blob URL以确保参考图像不会被意外删除
// 只有在用户明确请求清除会话时才清理
console.log('Blob清理已禁用参考图像将被永久保留');
return state;
}),
// 释放所有Blob URLs
cleanupAllBlobUrls: () => set((state) => {
state.blobStore.forEach((_, url) => {
URL.revokeObjectURL(url);
});
return { ...state, blobStore: new Map() };
// 不再自动清理Blob URL以确保参考图像不会被意外删除
// 只有在用户明确请求清除会话时才清理
console.log('Blob清理已禁用参考图像将被永久保留');
return state;
}),
// 定期清理Blob URL
scheduleBlobCleanup: () => {
// 清理超过10分钟未使用的Blob
const state = get();
const now = Date.now();
state.blobStore.forEach((blob, url) => {
// 检查URL是否仍在使用中
const isUsedInProject = state.currentProject && (
state.currentProject.generations.some(gen =>
gen.sourceAssets.some(asset => asset.blobUrl === url) ||
gen.outputAssetsBlobUrls.some(outputUrl => outputUrl === url)
) ||
state.currentProject.edits.some(edit =>
(edit.maskReferenceAssetBlobUrl === url) ||
edit.outputAssetsBlobUrls.some(outputUrl => outputUrl === url)
)
);
const isUsedInCanvas = state.canvasImage === url;
const isUsedInUploads = state.uploadedImages.includes(url);
const isUsedInEdits = state.editReferenceImages.includes(url);
// 检查是否是历史记录中的参考图像
const isUsedAsReference = state.currentProject && (
state.currentProject.generations.some(gen =>
gen.sourceAssets.some(asset => asset.blobUrl === url)
) ||
state.currentProject.edits.some(edit =>
(edit.maskReferenceAssetBlobUrl === url)
)
);
// 检查是否是当前编辑操作中的参考图像
const isUsedInCurrentEdit = state.editReferenceImages.includes(url);
// 检查是否是当前生成操作中的参考图像
const isUsedInCurrentGeneration = state.uploadedImages.includes(url);
// 如果Blob没有被使用则清理它
// 但保留仍在作为参考图像使用的Blob
if (!isUsedInProject && !isUsedInCanvas && !isUsedInUploads && !isUsedInEdits && !isUsedAsReference && !isUsedInCurrentEdit && !isUsedInCurrentGeneration) {
URL.revokeObjectURL(url);
const newBlobStore = new Map(state.blobStore);
newBlobStore.delete(url);
set({ blobStore: newBlobStore });
}
});
// 不再自动清理Blob URL以确保参考图像不会被意外删除
// 只有在用户明确请求清除会话时才清理
console.log('Blob清理已禁用参考图像将被永久保留');
},
// 删除生成记录
removeGeneration: (id) => set((state) => {
if (!state.currentProject) return {};
// 收集需要释放的Blob URLs
const urlsToRevoke: string[] = [];
const generationToRemove = state.currentProject.generations.find(gen => gen.id === id);
if (generationToRemove) {
// 收集要删除的生成记录中的Blob URLs
generationToRemove.sourceAssets.forEach(asset => {
if (asset.blobUrl.startsWith('blob:')) {
urlsToRevoke.push(asset.blobUrl);
}
});
generationToRemove.outputAssetsBlobUrls.forEach(url => {
if (url.startsWith('blob:')) {
urlsToRevoke.push(url);
}
});
// 释放Blob URLs
if (urlsToRevoke.length > 0) {
set((innerState) => {
urlsToRevoke.forEach(url => {
URL.revokeObjectURL(url);
const newBlobStore = new Map(innerState.blobStore);
newBlobStore.delete(url);
innerState = { ...innerState, blobStore: newBlobStore };
});
return innerState;
});
}
}
// 不再清理Blob URLs,以确保参考图像不会被意外删除
// 只记录信息
console.log('生成记录删除操作已执行但Blob清理已禁用参考图像将被永久保留');
// 从项目中移除生成记录
const updatedProject = {
@@ -715,34 +603,9 @@ export const useAppStore = create<AppState>()(
removeEdit: (id) => set((state) => {
if (!state.currentProject) return {};
// 收集需要释放的Blob URLs
const urlsToRevoke: string[] = [];
const editToRemove = state.currentProject.edits.find(edit => edit.id === id);
if (editToRemove) {
// 收集要删除的编辑记录中的Blob URLs
if (editToRemove.maskReferenceAssetBlobUrl && editToRemove.maskReferenceAssetBlobUrl.startsWith('blob:')) {
urlsToRevoke.push(editToRemove.maskReferenceAssetBlobUrl);
}
editToRemove.outputAssetsBlobUrls.forEach(url => {
if (url.startsWith('blob:')) {
urlsToRevoke.push(url);
}
});
// 释放Blob URLs
if (urlsToRevoke.length > 0) {
set((innerState) => {
urlsToRevoke.forEach(url => {
URL.revokeObjectURL(url);
const newBlobStore = new Map(innerState.blobStore);
newBlobStore.delete(url);
innerState = { ...innerState, blobStore: newBlobStore };
});
return innerState;
});
}
}
// 不再清理Blob URLs,以确保参考图像不会被意外删除
// 只记录信息
console.log('编辑记录删除操作已执行但Blob清理已禁用参考图像将被永久保留');
// 从项目中移除编辑记录
const updatedProject = {

View File

@@ -42,6 +42,7 @@ export interface Edit {
maskAssetId?: string;
maskReferenceAsset?: Asset;
instruction: string;
sourceAssets?: Asset[]; // 添加参考图像字段
outputAssets: Asset[];
timestamp: number;
uploadResults?: UploadResult[];

View File

@@ -139,7 +139,7 @@ export async function compressImage(blob: Blob, quality: number = 0.8): Promise<
URL.revokeObjectURL(url);
// 调用原始的onload处理程序
if (img.onload) {
(img.onload as any).call(img);
(img.onload as (() => void)).call(img);
}
};
});