初始化提交

This commit is contained in:
2025-09-19 20:23:07 +08:00
parent c5ee5dd2a3
commit 7172b16917
36 changed files with 7302 additions and 100 deletions

142
IFLOW.md Normal file
View File

@@ -0,0 +1,142 @@
# Nano Banana AI Image Editor - iFlow 文档
## 项目概述
Nano Banana AI Image Editor 是一个基于 React 的 AI 图像编辑工具,用户可以通过直观的界面与 Google 的 Gemini AI 模型进行交互,实现图像生成和编辑功能。
## 技术栈
- **核心框架**: React 18.x (TypeScript)
- **构建工具**: Vite 5.x
- **语言**: TypeScript (ES2020)
- **状态管理**: Zustand
- **数据获取**: TanStack Query (React Query)
- **UI 框架**:
- Tailwind CSS 3.x (样式框架)
- Radix UI (无样式的可访问 UI 组件)
- Lucide React (图标库)
- **图像处理**:
- Konva (2D 画布库)
- react-konva (Konva 的 React 封装)
- **本地存储**: IndexedDB (通过 idb-keyval 库操作)
- **AI 集成**: Google Generative AI SDK (@google/genai)
- **工具库**:
- class-variance-authority (组件变体管理)
- clsx + tailwind-merge (CSS 类名合并)
- fabric.js (备用画布库)
## 代码风格和命名规范
### 代码风格
- 使用 TypeScript 严格模式 (strict: true)
- 函数式组件为主,使用 React Hooks
- 组件文件使用 .tsx 扩展名
- 工具函数文件使用 .ts 扩展名
- 使用 ESLint 进行代码检查
- 启用严格的 TypeScript 编译选项
### 命名规范
- 组件文件和组件名使用 PascalCase (如 `Header.tsx`, `ImageCanvas`)
- 工具函数和普通文件使用 camelCase (如 `imageUtils.ts`, `useAppStore.ts`)
- 常量使用 UPPER_SNAKE_CASE
- 变量和函数使用 camelCase
- 组件 Props 接口命名为组件名 + Props (如 `ButtonProps`)
- Hook 函数以 use 开头 (如 `useAppStore`, `useImageGeneration`)
## 样式和 UI 框架
### Tailwind CSS
- 使用自定义颜色方案,以香蕉黄 (banana) 为主题色
- 定义了 light 主题颜色 (背景、面板、边框、文字等)
- 使用自定义间距、阴影和动画
- 使用 tailwind-merge 和 clsx 进行类名合并
### 组件设计
- 使用 Radix UI 构建无样式的可访问组件
- 自定义 UI 组件位于 `src/components/ui` 目录
- 组件变体使用 class-variance-authority 管理
- 图标使用 Lucide React
## 项目结构
```
Nano-Banana-AI-Image-Editor/
├── src/
│ ├── components/ # React 组件
│ │ ├── ui/ # 基础 UI 组件
│ │ ├── Header.tsx # 应用头部
│ │ ├── ImageCanvas.tsx # 图像画布
│ │ ├── PromptComposer.tsx # 提示词编辑器
│ │ ├── HistoryPanel.tsx # 历史记录面板
│ │ └── ... # 其他组件
│ ├── hooks/ # 自定义 React Hooks
│ │ ├── useImageGeneration.ts # 图像生成 Hook
│ │ ├── useIndexedDBListener.ts # IndexedDB 监听 Hook
│ │ └── useKeyboardShortcuts.ts # 键盘快捷键 Hook
│ ├── services/ # 业务逻辑服务
│ │ ├── geminiService.ts # Gemini AI 服务
│ │ ├── indexedDBService.ts # IndexedDB 操作服务
│ │ ├── uploadService.ts # 文件上传服务
│ │ └── cacheService.ts # 缓存服务
│ ├── store/ # 状态管理
│ │ └── useAppStore.ts # 全局状态管理 (Zustand)
│ ├── utils/ # 工具函数
│ │ ├── cn.ts # 类名合并工具
│ │ └── imageUtils.ts # 图像处理工具
│ ├── types/ # TypeScript 类型定义
│ ├── App.tsx # 根组件
│ ├── main.tsx # 应用入口
│ └── vite-env.d.ts # Vite 类型声明
├── public/ # 静态资源
├── node_modules/ # 依赖包
├── index.html # HTML 模板
├── package.json # 项目配置
├── tsconfig.json # TypeScript 配置
├── vite.config.ts # Vite 配置
├── tailwind.config.js # Tailwind 配置
├── postcss.config.js # PostCSS 配置
├── eslint.config.js # ESLint 配置
└── README.md # 项目说明
```
## 核心功能模块
### 1. 图像画布 (ImageCanvas)
- 使用 Konva 和 react-konva 实现图像显示和编辑
- 支持图像缩放、平移
- 实现画笔工具进行遮罩绘制
- 支持图像下载功能
### 2. 提示词编辑 (PromptComposer)
- 用户输入提示词生成图像
- 提供提示词建议功能
- 集成 AI 模型参数调整 (如风格、质量等)
### 3. 历史记录 (HistoryPanel)
- 显示生成的图像历史
- 支持历史图像的查看和重新编辑
- 使用 IndexedDB 存储历史数据
### 4. 状态管理 (useAppStore)
- 使用 Zustand 管理全局状态
- 存储画布状态、用户设置、历史记录等
- 提供状态操作方法
### 5. AI 服务 (geminiService)
- 集成 Google Gemini AI 模型
- 实现图像生成和编辑功能
- 处理与 AI 模型的交互
## 开发环境配置
1. 安装依赖: `npm install`
2. 启动开发服务器: `npm run dev`
3. 构建生产版本: `npm run build`
4. 代码检查: `npm run lint`
## 注意事项
- 项目使用 IndexedDB 存储图像数据,需要注意存储空间管理
- AI 功能需要配置 Google API 密钥
- 图像处理功能依赖浏览器 Canvas API
- 移动端适配需要特别关注界面布局和交互

View File

@@ -1,7 +1,7 @@
import React, { useState, useEffect } from 'react';
import { useAppStore } from '../store/useAppStore';
import { Button } from './ui/Button';
import { History, Download, Image as ImageIcon } from 'lucide-react';
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';
@@ -20,7 +20,11 @@ export const HistoryPanel: React.FC = () => {
showHistory,
setShowHistory,
setCanvasImage,
selectedTool
selectedTool,
deleteGeneration,
deleteEdit,
deleteGenerations,
deleteEdits
} = useAppStore();
const { getBlob } = useAppStore.getState();
@@ -37,6 +41,19 @@ export const HistoryPanel: React.FC = () => {
description: ''
});
// 删除确认对话框状态
const [deleteConfirm, setDeleteConfirm] = React.useState<{
open: boolean;
ids: string[];
type: 'generation' | 'edit' | 'multiple';
count: number;
}>({
open: false,
ids: [],
type: 'generation',
count: 0
});
// 存储从Blob URL解码的图像数据
const [decodedImages, setDecodedImages] = useState<Record<string, string>>({});
@@ -479,9 +496,32 @@ export const HistoryPanel: React.FC = () => {
<div className="mb-6 flex-shrink-0">
<div className="flex items-center justify-between mb-2">
<h4 className="text-xs font-medium text-gray-500 uppercase tracking-wide"></h4>
<span className="text-xs text-gray-400">
{filteredGenerations.length + filteredEdits.length}/100
</span>
<div className="flex items-center space-x-2">
<span className="text-xs text-gray-400">
{filteredGenerations.length + filteredEdits.length}/100
</span>
{(filteredGenerations.length > 0 || filteredEdits.length > 0) && (
<button
className="text-red-500 hover:text-red-700 text-xs flex items-center"
onClick={() => {
const allIds = [
...filteredGenerations.map(g => g.id),
...filteredEdits.map(e => e.id)
];
setDeleteConfirm({
open: true,
ids: allIds,
type: 'multiple',
count: allIds.length
});
}}
title="清空所有历史记录"
>
<Trash2 className="h-3 w-3 mr-1" />
</button>
)}
</div>
</div>
{filteredGenerations.length === 0 && filteredEdits.length === 0 ? (
<div className="text-center py-8">
@@ -697,6 +737,23 @@ export const HistoryPanel: React.FC = () => {
<div className="absolute top-1 left-1 bg-gray-900/80 text-xs px-1 py-0.5 rounded text-white">
G{globalIndex + 1}
</div>
{/* 删除按钮 */}
<button
className="absolute top-1 right-1 bg-red-500/80 text-white p-1 rounded-full hover:bg-red-600 transition-colors"
onClick={(e) => {
e.stopPropagation();
setDeleteConfirm({
open: true,
ids: [generation.id],
type: 'generation',
count: 1
});
}}
title="删除记录"
>
<Trash2 className="h-3 w-3" />
</button>
</div>
);
});
@@ -903,6 +960,23 @@ export const HistoryPanel: React.FC = () => {
<div className="absolute top-1 left-1 bg-purple-900/80 text-xs px-1 py-0.5 rounded text-white">
E{globalIndex + 1}
</div>
{/* 删除按钮 */}
<button
className="absolute top-1 right-1 bg-red-500/80 text-white p-1 rounded-full hover:bg-red-600 transition-colors"
onClick={(e) => {
e.stopPropagation();
setDeleteConfirm({
open: true,
ids: [edit.id],
type: 'edit',
count: 1
});
}}
title="删除记录"
>
<Trash2 className="h-3 w-3" />
</button>
</div>
);
});
@@ -1010,6 +1084,53 @@ export const HistoryPanel: React.FC = () => {
</div>
)}
{/* 生成结果图像 */}
{gen.outputAssets && gen.outputAssets.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">
{gen.outputAssets.length}
</div>
<div className="flex flex-wrap gap-2">
{gen.outputAssets.slice(0, 4).map((asset: any, index: number) => {
// 获取上传后的远程链接(如果存在)
const uploadedUrl = gen.uploadResults && gen.uploadResults[index] && gen.uploadResults[index].success
? `${gen.uploadResults[index].url}?x-oss-process=image/quality,q_30`
: null;
const displayUrl = uploadedUrl || asset.url;
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}`
});
}}
>
<img
src={displayUrl}
alt={`生成结果 ${index + 1}`}
className="w-full h-full object-cover"
/>
</div>
);
})}
{gen.outputAssets.length > 4 && (
<div className="w-16 h-16 rounded border flex items-center justify-center bg-gray-100 text-xs text-gray-500">
+{gen.outputAssets.length - 4}
</div>
)}
</div>
</div>
)}
{/* 参考图像信息 */}
{gen.sourceAssets && gen.sourceAssets.length > 0 && (
<div className="pt-2 border-t border-gray-200">
@@ -1020,10 +1141,15 @@ export const HistoryPanel: React.FC = () => {
<div className="flex flex-wrap gap-2">
{gen.sourceAssets.slice(0, 4).map((asset: any, index: number) => {
// 获取上传后的远程链接(如果存在)
// 参考图像在uploadResults中从索引1开始索引0是生成的图像
const uploadedUrl = gen.uploadResults && gen.uploadResults[index + 1] && gen.uploadResults[index + 1].success
? `${gen.uploadResults[index + 1].url}?x-oss-process=image/quality,q_30`
// 参考图像在uploadResults中从索引outputAssets.length开始
// 但由于gen可能是轻量级记录我们需要从dbGenerations中获取完整的记录
const fullGen = dbGenerations.find(g => g.id === gen.id) || gen;
const outputAssetsCount = fullGen.outputAssets?.length || 0;
const uploadedUrl = gen.uploadResults && gen.uploadResults[outputAssetsCount + index] && gen.uploadResults[outputAssetsCount + index].success
? `${gen.uploadResults[outputAssetsCount + index].url}?x-oss-process=image/quality,q_30`
: null;
const displayUrl = uploadedUrl || asset.url;
return (
@@ -1128,9 +1254,10 @@ export const HistoryPanel: React.FC = () => {
<div className="flex flex-wrap gap-2">
{parentGen.sourceAssets.slice(0, 4).map((asset: any, index: number) => {
// 获取上传后的远程链接(如果存在)
// 参考图像在uploadResults中从索引1开始索引0是生成的图像
const uploadedUrl = parentGen.uploadResults && parentGen.uploadResults[index + 1] && parentGen.uploadResults[index + 1].success
? `${parentGen.uploadResults[index + 1].url}?x-oss-process=image/quality,q_30`
// 参考图像在uploadResults中从索引outputAssets.length开始
const outputAssetsCount = parentGen.outputAssets?.length || 0;
const uploadedUrl = parentGen.uploadResults && parentGen.uploadResults[outputAssetsCount + index] && parentGen.uploadResults[outputAssetsCount + index].success
? `${parentGen.uploadResults[outputAssetsCount + index].url}?x-oss-process=image/quality,q_30`
: null;
const displayUrl = uploadedUrl || asset.url;
@@ -1189,6 +1316,68 @@ export const HistoryPanel: React.FC = () => {
description={previewModal.description}
/>
{/* 删除确认对话框 */}
{deleteConfirm.open && (
<div className="absolute inset-0 bg-black/50 flex items-center justify-center z-[100] backdrop-blur-sm rounded-lg">
<div className="bg-white rounded-xl p-6 card-lg max-w-xs w-full mx-4">
<div className="text-center">
<div className="mx-auto flex items-center justify-center h-12 w-12 rounded-full bg-red-100 mb-4">
<Trash2 className="h-6 w-6 text-red-600" />
</div>
<h3 className="text-lg font-medium text-gray-900 mb-2"></h3>
<p className="text-sm text-gray-500 mb-4">
{deleteConfirm.count > 1
? `确定要删除这 ${deleteConfirm.count} 条历史记录吗?此操作无法撤销。`
: '确定要删除这条历史记录吗?此操作无法撤销。'}
</p>
<div className="flex justify-center gap-3">
<Button
variant="outline"
size="sm"
className="text-xs px-4 py-2 h-8 border-gray-200 text-gray-600 hover:bg-gray-100 card"
onClick={() => setDeleteConfirm(prev => ({ ...prev, open: false }))}
>
</Button>
<Button
variant="destructive"
size="sm"
className="text-xs px-4 py-2 h-8 card"
onClick={() => {
// 执行删除操作
if (deleteConfirm.type === 'generation') {
deleteConfirm.ids.forEach(id => deleteGeneration(id));
} else if (deleteConfirm.type === 'edit') {
deleteConfirm.ids.forEach(id => deleteEdit(id));
} else {
// 多选删除
const genIds = deleteConfirm.ids.filter(id =>
filteredGenerations.some(g => g.id === id)
);
const editIds = deleteConfirm.ids.filter(id =>
filteredEdits.some(e => e.id === id)
);
if (genIds.length > 0) {
deleteGenerations(genIds);
}
if (editIds.length > 0) {
deleteEdits(editIds);
}
}
// 关闭对话框
setDeleteConfirm(prev => ({ ...prev, open: false }));
}}
>
</Button>
</div>
</div>
</div>
</div>
)}
{/* 悬浮预览 */}
{hoveredImage && (
<div

View File

@@ -318,7 +318,103 @@ export const ImageCanvas: React.FC = () => {
};
const handleDownload = () => {
// 直接下载当前画布内容
// 首先尝试从当前选中的生成记录或编辑记录中获取上传后的URL
const { selectedGenerationId, selectedEditId, currentProject } = useAppStore.getState();
// 获取当前选中的记录
let selectedRecord = null;
if (selectedGenerationId && currentProject) {
selectedRecord = currentProject.generations.find(g => g.id === selectedGenerationId);
} else if (selectedEditId && currentProject) {
selectedRecord = currentProject.edits.find(e => e.id === selectedEditId);
}
// 如果有选中的记录且有上传结果,尝试下载上传后的图像
if (selectedRecord && selectedRecord.uploadResults && selectedRecord.uploadResults.length > 0) {
// 下载第一个上传结果(通常是生成的图像)
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();
// 创建下载链接
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('上传后的图像下载成功:', 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('下载失败,未执行回退方案');
}
}
})();
// 立即返回,让异步操作在后台进行
return;
}
}
// 如果没有上传后的URL或下载失败回退到下载当前画布内容
const stage = stageRef.current;
if (stage) {
try {
@@ -350,9 +446,14 @@ export const ImageCanvas: React.FC = () => {
document.body.removeChild(link);
} else if (canvasImage.startsWith('blob:')) {
// Blob URL格式
fetch(canvasImage)
.then(response => response.blob())
.then(blob => {
// 使用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();
const url = URL.createObjectURL(blob);
const link = document.createElement('a');
link.href = url;
@@ -362,15 +463,66 @@ export const ImageCanvas: React.FC = () => {
document.body.removeChild(link);
// 清理创建的URL
setTimeout(() => URL.revokeObjectURL(url), 100);
})
.catch(error => {
} 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('下载失败,未执行回退方案');
}
}
})();
} else {
// 普通URL格式
fetch(canvasImage)
.then(response => response.blob())
.then(blob => {
// 使用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();
const url = URL.createObjectURL(blob);
const link = document.createElement('a');
link.href = url;
@@ -380,81 +532,60 @@ export const ImageCanvas: React.FC = () => {
document.body.removeChild(link);
// 清理创建的URL
setTimeout(() => URL.revokeObjectURL(url), 100);
})
.catch(error => {
} catch (error) {
console.error('下载图像时出错:', error);
// 如果fetch失败,尝试直接下载
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);
});
// 如果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('下载失败,未执行回退方案');
}
}
})();
}
}
}
} else {
console.warn('Stage未初始化无法下载画布内容');
// 回退到下载原始图像
if (canvasImage) {
// 处理不同类型的URL
if (canvasImage.startsWith('data:')) {
// base64格式
const link = document.createElement('a');
link.href = canvasImage;
link.download = `nano-banana-${Date.now()}.png`;
document.body.appendChild(link);
link.click();
document.body.removeChild(link);
} else if (canvasImage.startsWith('blob:')) {
// Blob URL格式
fetch(canvasImage)
.then(response => response.blob())
.then(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);
})
.catch(error => {
console.error('下载Blob图像时出错:', error);
});
} else {
// 普通URL格式
fetch(canvasImage)
.then(response => response.blob())
.then(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);
})
.catch(error => {
console.error('下载图像时出错:', error);
// 如果fetch失败尝试直接下载
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);
});
}
}
}
};

View File

@@ -107,11 +107,21 @@ export const useImageGeneration = () => {
// 上传参考图像(如果存在,使用缓存机制)
let referenceUploadResults: any[] = [];
if (request.referenceImages && request.referenceImages.length > 0) {
// 将参考图像转换为Blob URL
const referenceUrls = await Promise.all(request.referenceImages.map(async (blob) => {
return useAppStore.getState().addBlob(blob);
// 将参考图像转换为base64字符串格式上传与老版本保持一致
const referenceBase64s = await Promise.all(request.referenceImages.map(async (blob) => {
if (typeof blob === 'string') {
// 如果已经是base64字符串直接返回
return blob;
} else {
// 如果是Blob对象转换为base64字符串
return new Promise<string>((resolve) => {
const reader = new FileReader();
reader.onload = () => resolve(reader.result as string);
reader.readAsDataURL(blob);
});
}
}));
referenceUploadResults = await uploadImages(referenceUrls, accessToken, false);
referenceUploadResults = await uploadImages(referenceBase64s, accessToken, false);
}
// 合并上传结果
@@ -186,6 +196,10 @@ export const useImageGeneration = () => {
addGeneration(generation);
setCanvasImage(outputAssets[0].url);
// 自动选择新生成的记录
const { selectGeneration } = useAppStore.getState();
selectGeneration(generation.id);
}
setIsGenerating(false);
},
@@ -482,7 +496,24 @@ export const useImageEditing = () => {
try {
const imageUrls = outputAssets.map(asset => asset.url);
// 上传编辑后的图像(跳过缓存,因为这些是新生成的图像)
uploadResults = await uploadImages(imageUrls, accessToken, true);
const outputUploadResults = await uploadImages(imageUrls, accessToken, true);
// 上传参考图像(如果存在,使用缓存机制)
let referenceUploadResults: any[] = [];
if (referenceImageBlobs.length > 0) {
// 将参考图像转换为base64字符串格式上传与老版本保持一致
const referenceBase64s = await Promise.all(referenceImageBlobs.map(async (blob) => {
return new Promise<string>((resolve) => {
const reader = new FileReader();
reader.onload = () => resolve(reader.result as string);
reader.readAsDataURL(blob);
});
}));
referenceUploadResults = await uploadImages(referenceBase64s, accessToken, false);
}
// 合并上传结果
uploadResults = [...outputUploadResults, ...referenceUploadResults];
// 检查上传结果
const failedUploads = uploadResults.filter(r => !r.success);

View File

@@ -410,6 +410,74 @@ export const cleanupBase64Data = async (): Promise<void> => {
}
};
/**
* 删除指定的生成记录
*/
export const deleteGeneration = async (id: string): Promise<void> => {
const db = getDB();
const transaction = db.transaction([GENERATIONS_STORE], 'readwrite');
const store = transaction.objectStore(GENERATIONS_STORE);
return new Promise((resolve, reject) => {
const request = store.delete(id);
request.onsuccess = () => resolve();
request.onerror = () => reject(request.error);
});
};
/**
* 删除指定的编辑记录
*/
export const deleteEdit = async (id: string): Promise<void> => {
const db = getDB();
const transaction = db.transaction([EDITS_STORE], 'readwrite');
const store = transaction.objectStore(EDITS_STORE);
return new Promise((resolve, reject) => {
const request = store.delete(id);
request.onsuccess = () => resolve();
request.onerror = () => reject(request.error);
});
};
/**
* 批量删除生成记录
*/
export const deleteGenerations = async (ids: string[]): Promise<void> => {
const db = getDB();
const transaction = db.transaction([GENERATIONS_STORE], 'readwrite');
const store = transaction.objectStore(GENERATIONS_STORE);
const promises = ids.map(id => {
return new Promise<void>((resolve, reject) => {
const request = store.delete(id);
request.onsuccess = () => resolve();
request.onerror = () => reject(request.error);
});
});
return Promise.all(promises).then(() => undefined);
};
/**
* 批量删除编辑记录
*/
export const deleteEdits = async (ids: string[]): Promise<void> => {
const db = getDB();
const transaction = db.transaction([EDITS_STORE], 'readwrite');
const store = transaction.objectStore(EDITS_STORE);
const promises = ids.map(id => {
return new Promise<void>((resolve, reject) => {
const request = store.delete(id);
request.onsuccess = () => resolve();
request.onerror = () => reject(request.error);
});
});
return Promise.all(promises).then(() => undefined);
};
/**
* 清空所有记录
*/

View File

@@ -167,9 +167,12 @@ export const uploadImage = async (imageData: string | Blob, accessToken: string,
blob = imageData;
}
// 创建FormData对象
// 创建FormData对象,使用唯一文件名
const timestamp = Date.now();
const random = Math.floor(Math.random() * 10000);
const fileName = `image-${timestamp}-${random}.png`;
const formData = new FormData();
formData.append('file', blob, 'generated-image.png');
formData.append('file', blob, fileName);
// 发送POST请求
const response = await fetch(UPLOAD_URL, {

View File

@@ -119,6 +119,12 @@ interface AppState {
setSelectedTool: (tool: 'generate' | 'edit' | 'mask') => void;
// 删除历史记录
deleteGeneration: (id: string) => void;
deleteEdit: (id: string) => void;
deleteGenerations: (ids: string[]) => void;
deleteEdits: (ids: string[]) => void;
// Blob存储操作
addBlob: (blob: Blob) => string;
getBlob: (url: string) => Blob | undefined;
@@ -643,7 +649,243 @@ export const useAppStore = create<AppState>()(
set({ blobStore: newBlobStore });
}
});
}
},
// 删除单个生成记录
deleteGeneration: (id) => set((state) => {
if (!state.currentProject) return {};
// 找到要删除的记录
const generationToDelete = state.currentProject.generations.find(gen => gen.id === id);
if (!generationToDelete) return {};
// 收集需要释放的Blob URLs
const urlsToRevoke: string[] = [];
// 收集生成记录中的Blob URLs
generationToDelete.sourceAssets.forEach(asset => {
if (asset.blobUrl.startsWith('blob:')) {
urlsToRevoke.push(asset.blobUrl);
}
});
generationToDelete.outputAssetsBlobUrls.forEach(url => {
if (url.startsWith('blob:')) {
urlsToRevoke.push(url);
}
});
// 从IndexedDB中删除记录
indexedDBService.deleteGeneration(id).catch(err => {
console.error('从IndexedDB删除生成记录失败:', err);
});
// 释放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;
});
}
// 如果删除的是当前选中的记录,清除选择
let selectedGenerationId = state.selectedGenerationId;
if (selectedGenerationId === id) {
selectedGenerationId = null;
}
// 更新项目中的生成记录列表
const updatedGenerations = state.currentProject.generations.filter(gen => gen.id !== id);
return {
currentProject: {
...state.currentProject,
generations: updatedGenerations,
updatedAt: Date.now()
},
selectedGenerationId
};
}),
// 删除单个编辑记录
deleteEdit: (id) => set((state) => {
if (!state.currentProject) return {};
// 找到要删除的记录
const editToDelete = state.currentProject.edits.find(edit => edit.id === id);
if (!editToDelete) return {};
// 收集需要释放的Blob URLs
const urlsToRevoke: string[] = [];
// 收集编辑记录中的Blob URLs
if (editToDelete.maskReferenceAssetBlobUrl && editToDelete.maskReferenceAssetBlobUrl.startsWith('blob:')) {
urlsToRevoke.push(editToDelete.maskReferenceAssetBlobUrl);
}
editToDelete.outputAssetsBlobUrls.forEach(url => {
if (url.startsWith('blob:')) {
urlsToRevoke.push(url);
}
});
// 从IndexedDB中删除记录
indexedDBService.deleteEdit(id).catch(err => {
console.error('从IndexedDB删除编辑记录失败:', err);
});
// 释放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;
});
}
// 如果删除的是当前选中的记录,清除选择
let selectedEditId = state.selectedEditId;
if (selectedEditId === id) {
selectedEditId = null;
}
// 更新项目中的编辑记录列表
const updatedEdits = state.currentProject.edits.filter(edit => edit.id !== id);
return {
currentProject: {
...state.currentProject,
edits: updatedEdits,
updatedAt: Date.now()
},
selectedEditId
};
}),
// 批量删除生成记录
deleteGenerations: (ids) => set((state) => {
if (!state.currentProject) return {};
// 收集需要释放的Blob URLs
const urlsToRevoke: string[] = [];
// 收集所有要删除记录中的Blob URLs
state.currentProject.generations.forEach(gen => {
if (ids.includes(gen.id)) {
gen.sourceAssets.forEach(asset => {
if (asset.blobUrl.startsWith('blob:')) {
urlsToRevoke.push(asset.blobUrl);
}
});
gen.outputAssetsBlobUrls.forEach(url => {
if (url.startsWith('blob:')) {
urlsToRevoke.push(url);
}
});
}
});
// 从IndexedDB中批量删除记录
indexedDBService.deleteGenerations(ids).catch(err => {
console.error('从IndexedDB批量删除生成记录失败:', err);
});
// 释放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;
});
}
// 如果删除的是当前选中的记录,清除选择
let selectedGenerationId = state.selectedGenerationId;
if (selectedGenerationId && ids.includes(selectedGenerationId)) {
selectedGenerationId = null;
}
// 更新项目中的生成记录列表
const updatedGenerations = state.currentProject.generations.filter(gen => !ids.includes(gen.id));
return {
currentProject: {
...state.currentProject,
generations: updatedGenerations,
updatedAt: Date.now()
},
selectedGenerationId
};
}),
// 批量删除编辑记录
deleteEdits: (ids) => set((state) => {
if (!state.currentProject) return {};
// 收集需要释放的Blob URLs
const urlsToRevoke: string[] = [];
// 收集所有要删除记录中的Blob URLs
state.currentProject.edits.forEach(edit => {
if (ids.includes(edit.id)) {
if (edit.maskReferenceAssetBlobUrl && edit.maskReferenceAssetBlobUrl.startsWith('blob:')) {
urlsToRevoke.push(edit.maskReferenceAssetBlobUrl);
}
edit.outputAssetsBlobUrls.forEach(url => {
if (url.startsWith('blob:')) {
urlsToRevoke.push(url);
}
});
}
});
// 从IndexedDB中批量删除记录
indexedDBService.deleteEdits(ids).catch(err => {
console.error('从IndexedDB批量删除编辑记录失败:', err);
});
// 释放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;
});
}
// 如果删除的是当前选中的记录,清除选择
let selectedEditId = state.selectedEditId;
if (selectedEditId && ids.includes(selectedEditId)) {
selectedEditId = null;
}
// 更新项目中的编辑记录列表
const updatedEdits = state.currentProject.edits.filter(edit => !ids.includes(edit.id));
return {
currentProject: {
...state.currentProject,
edits: updatedEdits,
updatedAt: Date.now()
},
selectedEditId
};
})
}),
{
name: 'nano-banana-store',

164
v1/src/App.tsx Normal file
View File

@@ -0,0 +1,164 @@
import React, { useEffect, useState } from 'react';
import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
import { cn } from './utils/cn';
import { Header } from './components/Header';
import { PromptComposer } from './components/PromptComposer';
import { ImageCanvas } from './components/ImageCanvas';
import { HistoryPanel } from './components/HistoryPanel';
import { useKeyboardShortcuts } from './hooks/useKeyboardShortcuts';
import { useAppStore } from './store/useAppStore';
import { ToastProvider } from './components/ToastContext';
import * as indexedDBService from './services/indexedDBService';
const queryClient = new QueryClient({
defaultOptions: {
queries: {
staleTime: 5 * 60 * 1000, // 5分钟
retry: 2,
},
},
});
function AppContent() {
useKeyboardShortcuts();
const { showPromptPanel, setShowPromptPanel, showHistory, setShowHistory } = useAppStore();
const [hoveredImage, setHoveredImage] = useState<{url: string, title: string, width?: number, height?: number} | null>(null);
const [previewPosition, setPreviewPosition] = useState<{x: number, y: number} | null>(null);
const [isPreviewVisible, setIsPreviewVisible] = useState(false);
// 在挂载时初始化IndexedDB并清理base64数据
useEffect(() => {
const init = async () => {
try {
await indexedDBService.initDB();
// 清理已有的base64数据
await indexedDBService.cleanupBase64Data();
} catch (err) {
console.error('初始化IndexedDB或清理base64数据失败:', err);
}
};
init();
}, []);
// 在挂载时设置移动设备默认值
React.useEffect(() => {
const checkMobile = () => {
const isMobile = window.innerWidth < 768;
if (isMobile) {
setShowPromptPanel(false);
setShowHistory(false);
}
};
checkMobile();
window.addEventListener('resize', checkMobile);
return () => window.removeEventListener('resize', checkMobile);
}, [setShowPromptPanel, setShowHistory]);
// 定期清理旧的历史记录
useEffect(() => {
const interval = setInterval(() => {
useAppStore.getState().cleanupOldHistory();
}, 30000); // 每30秒清理一次
return () => clearInterval(interval);
}, []);
// 定期清理未使用的Blob URL
useEffect(() => {
const interval = setInterval(() => {
useAppStore.getState().scheduleBlobCleanup();
}, 60000); // 每分钟清理一次
return () => clearInterval(interval);
}, []);
// 控制预览窗口的显示和隐藏动画
useEffect(() => {
if (hoveredImage) {
// 延迟一小段时间后设置为可见,以触发动画
const timer = setTimeout(() => {
setIsPreviewVisible(true);
}, 10);
return () => clearTimeout(timer);
} else {
setIsPreviewVisible(false);
}
}, [hoveredImage]);
return (
<div className="h-screen bg-gray-50 text-gray-900 flex flex-col font-sans">
<div className="card card-lg rounded-none">
<Header />
</div>
<div className="flex-1 flex overflow-hidden p-4 gap-4 relative">
<div className={cn("flex-shrink-0 transition-all duration-300 ease-in-out overflow-hidden", showPromptPanel ? "w-72" : "w-8")}>
<div className={cn("h-full", showPromptPanel ? "card card-lg" : "")}>
<PromptComposer />
</div>
</div>
<div className="flex-1 min-w-0">
<div className="h-full card card-lg">
<ImageCanvas />
</div>
</div>
<div className={cn("flex-shrink-0 transition-all duration-300 ease-in-out overflow-hidden", showHistory ? "w-72" : "w-8")}>
<div className={cn("h-full", showHistory ? "card card-lg" : "")}>
<HistoryPanel setHoveredImage={setHoveredImage} setPreviewPosition={setPreviewPosition} />
</div>
</div>
</div>
{/* 悬浮预览 */}
{hoveredImage && (
<div
className="fixed inset-0 z-[99999] flex items-center justify-center pointer-events-none"
>
<div
className="bg-white rounded-xl shadow-2xl border border-gray-300 overflow-hidden max-w-2xl max-h-[80vh] flex flex-col transition-all duration-200 ease-out"
style={{
transform: isPreviewVisible ? 'scale(1)' : 'scale(0.8)',
opacity: isPreviewVisible ? 1 : 0,
transformOrigin: previewPosition ? `${previewPosition.x}px ${previewPosition.y}px` : 'center'
}}
>
<div className="bg-gray-900 text-white text-sm p-3 truncate font-medium">
{hoveredImage.title}
</div>
<div className="flex-1 flex items-center justify-center p-4">
<img
src={hoveredImage.url}
alt="预览"
className="max-w-full max-h-[60vh] object-contain"
/>
</div>
{/* 图像信息 */}
<div className="p-3 bg-gray-50 border-t border-gray-200 text-sm">
{hoveredImage.width && hoveredImage.height && (
<div className="flex justify-between text-gray-600">
<span>:</span>
<span className="text-gray-800 font-medium">{hoveredImage.width} × {hoveredImage.height}</span>
</div>
)}
</div>
</div>
</div>
)}
</div>
);
}
function App() {
return (
<QueryClientProvider client={queryClient}>
<ToastProvider>
<AppContent />
</ToastProvider>
</QueryClientProvider>
);
}
export default App;

View File

@@ -0,0 +1,36 @@
import React, { useState } from 'react';
import { Button } from './ui/Button';
import { HelpCircle } from 'lucide-react';
import { InfoModal } from './InfoModal';
export const Header: React.FC = () => {
const [showInfoModal, setShowInfoModal] = useState(false);
return (
<>
<header className="h-12 bg-white flex items-center justify-between px-3 rounded-t-xl">
<div className="flex items-center space-x-2">
<div className="flex items-center justify-center w-8 h-8 rounded-lg bg-yellow-50">
<div className="text-lg">🍌</div>
</div>
<h1 className="text-base font-medium text-gray-800 hidden sm:block">
Nano Banana
</h1>
</div>
<div className="flex items-center space-x-1">
<Button
variant="ghost"
size="icon"
onClick={() => setShowInfoModal(true)}
className="h-7 w-7 text-gray-500 hover:text-gray-700 hover:bg-gray-100 rounded-full card"
>
<HelpCircle className="h-4 w-4" />
</Button>
</div>
</header>
<InfoModal open={showInfoModal} onOpenChange={setShowInfoModal} />
</>
);
};

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,629 @@
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';
export const ImageCanvas: React.FC = () => {
const {
canvasImage,
canvasZoom,
setCanvasZoom,
canvasPan,
setCanvasPan,
brushStrokes,
addBrushStroke,
clearBrushStrokes,
showMasks,
setShowMasks,
selectedTool,
isGenerating,
brushSize,
setBrushSize,
showHistory,
showPromptPanel
} = useAppStore();
const stageRef = useRef<any>(null);
const [image, setImage] = useState<HTMLImageElement | null>(null);
const [stageSize, setStageSize] = useState({ width: 800, height: 600 });
const [isDrawing, setIsDrawing] = useState(false);
const [currentStroke, setCurrentStroke] = useState<number[]>([]);
const handleZoom = useCallback((delta: number) => {
const stage = stageRef.current;
if (stage) {
const currentZoom = stage.scaleX();
const newZoom = Math.max(0.1, Math.min(3, currentZoom + delta));
// 先更新React状态以确保Konva Image组件使用正确的缩放值
setCanvasZoom(newZoom);
// 使用setTimeout确保DOM已更新后再设置Stage
setTimeout(() => {
const stage = stageRef.current;
if (stage) {
// 直接通过stageRef控制Stage
stage.scale({ x: newZoom, y: newZoom });
stage.batchDraw();
}
}, 0);
}
}, []);
// 加载图像
useEffect(() => {
let img: HTMLImageElement | null = null;
if (canvasImage) {
console.log('开始加载图像URL:', canvasImage);
img = new window.Image();
let isCancelled = false;
img.onload = () => {
// 检查是否已取消
if (isCancelled) {
console.log('图像加载被取消');
return;
}
console.log('图像加载成功,尺寸:', img.width, 'x', img.height);
setImage(img);
// 只在图像首次加载时自动适应画布
if (!isCancelled && img) {
const isMobile = window.innerWidth < 768;
const padding = isMobile ? 0.9 : 0.8;
const scaleX = (stageSize.width * padding) / img.width;
const scaleY = (stageSize.height * padding) / img.height;
const maxZoom = isMobile ? 0.3 : 0.8;
const optimalZoom = Math.min(scaleX, scaleY, maxZoom);
// 立即更新React状态以确保Konva Image组件使用正确的缩放值
setCanvasZoom(optimalZoom);
setCanvasPan({ x: 0, y: 0 });
// 使用setTimeout确保DOM已更新后再设置Stage
setTimeout(() => {
if (!isCancelled && img) {
// 直接设置缩放但保持Stage居中
const stage = stageRef.current;
if (stage) {
stage.scale({ x: optimalZoom, y: optimalZoom });
// 重置Stage位置以确保居中
stage.position({ x: 0, y: 0 });
stage.batchDraw();
}
console.log('图像自动适应画布完成,缩放:', optimalZoom);
}
}, 0);
}
};
img.onerror = (error) => {
if (!isCancelled) {
console.error('图像加载失败:', error);
console.error('图像URL:', canvasImage);
// 检查是否是Blob URL
if (canvasImage.startsWith('blob:')) {
console.log('正在检查Blob URL是否有效...');
// 检查Blob URL是否仍然有效
fetch(canvasImage)
.then(response => {
if (!response.ok) {
console.error('Blob URL无法访问:', response.status, response.statusText);
} else {
console.log('Blob URL可以访问');
}
})
.catch(err => {
console.error('检查Blob URL时出错:', err);
// 尝试从AppStore重新获取Blob
import('../store/useAppStore').then((module) => {
const useAppStore = module.useAppStore;
const blob = useAppStore.getState().getBlob(canvasImage);
if (blob) {
console.log('从AppStore找到Blob尝试重新创建URL...');
// 重新创建Blob URL并重试加载
const newUrl = URL.createObjectURL(blob);
console.log('创建新的Blob URL:', newUrl);
// 更新canvasImage为新的URL
useAppStore.getState().setCanvasImage(newUrl);
} else {
console.error('AppStore中未找到Blob');
}
}).catch(err => {
console.error('导入AppStore时出错:', err);
});
});
}
}
};
img.src = canvasImage;
} else {
console.log('没有图像需要加载');
// 当没有图像时,清理之前的图像对象
if (image) {
// 清理图像对象以释放内存
image.onload = null;
image.onerror = null;
image.src = '';
}
setImage(null);
}
// 清理函数
return () => {
console.log('清理图像加载资源');
// 取消图像加载
if (img) {
img.onload = null;
img.onerror = null;
// 清理图像源以释放内存
img.src = '';
}
// 清理之前的图像对象
if (image) {
image.onload = null;
image.onerror = null;
image.src = '';
}
};
}, [canvasImage]); // 只依赖canvasImage避免其他依赖引起循环
// 处理舞台大小调整
useEffect(() => {
const updateSize = () => {
const container = document.getElementById('canvas-container');
if (container) {
setStageSize({
width: container.offsetWidth,
height: container.offsetHeight
});
}
};
updateSize();
window.addEventListener('resize', updateSize);
return () => window.removeEventListener('resize', updateSize);
}, []);
// 监听面板状态变化以调整画布大小
useEffect(() => {
// 使用 setTimeout 确保 DOM 已更新
const timer = setTimeout(() => {
const container = document.getElementById('canvas-container');
if (container) {
setStageSize({
width: container.offsetWidth,
height: container.offsetHeight
});
}
}, 100);
return () => clearTimeout(timer);
}, [showPromptPanel, showHistory]);
// 处理鼠标滚轮缩放
useEffect(() => {
const container = document.getElementById('canvas-container');
if (!container) return;
const handleWheel = (e: WheelEvent) => {
e.preventDefault();
const delta = e.deltaY > 0 ? -0.1 : 0.1;
handleZoom(delta);
};
container.addEventListener('wheel', handleWheel, { passive: false });
return () => container.removeEventListener('wheel', handleWheel);
}, [canvasZoom]);
const handleMouseDown = (e: any) => {
if (selectedTool !== 'mask' || !image) return;
setIsDrawing(true);
const stage = e.target.getStage();
const pos = stage.getPointerPosition();
// 使用 Konva 的 getRelativePointerPosition 获取准确坐标
const relativePos = stage.getRelativePointerPosition();
// 计算图像在舞台上的边界
const imageX = (stageSize.width / canvasZoom - image.width) / 2;
const imageY = (stageSize.height / canvasZoom - image.height) / 2;
// 转换为相对于图像的坐标
const relativeX = relativePos.x - imageX;
const relativeY = relativePos.y - imageY;
// 检查点击是否在图像边界内
if (relativeX >= 0 && relativeX <= image.width && relativeY >= 0 && relativeY <= image.height) {
setCurrentStroke([relativeX, relativeY]);
}
};
const handleMouseMove = (e: any) => {
if (!isDrawing || selectedTool !== 'mask' || !image) return;
const stage = e.target.getStage();
const pos = stage.getPointerPosition();
// 使用 Konva 的 getRelativePointerPosition 获取准确坐标
const relativePos = stage.getRelativePointerPosition();
// 计算图像在舞台上的边界
const imageX = (stageSize.width / canvasZoom - image.width) / 2;
const imageY = (stageSize.height / canvasZoom - image.height) / 2;
// 转换为相对于图像的坐标
const relativeX = relativePos.x - imageX;
const relativeY = relativePos.y - imageY;
// 检查是否在图像边界内
if (relativeX >= 0 && relativeX <= image.width && relativeY >= 0 && relativeY <= image.height) {
setCurrentStroke([...currentStroke, relativeX, relativeY]);
}
};
const handleMouseUp = () => {
if (!isDrawing || currentStroke.length < 4) {
setIsDrawing(false);
setCurrentStroke([]);
return;
}
setIsDrawing(false);
addBrushStroke({
id: `stroke-${Date.now()}`,
points: currentStroke,
brushSize,
});
setCurrentStroke([]);
};
const handleReset = () => {
if (image) {
const isMobile = window.innerWidth < 768;
const padding = isMobile ? 0.9 : 0.8;
const scaleX = (stageSize.width * padding) / image.width;
const scaleY = (stageSize.height * padding) / image.height;
const maxZoom = isMobile ? 0.3 : 0.8;
const optimalZoom = Math.min(scaleX, scaleY, maxZoom);
// 同时更新React状态以确保Konva Image组件使用正确的缩放值
setCanvasZoom(optimalZoom);
setCanvasPan({ x: 0, y: 0 });
// 使用setTimeout确保DOM已更新后再设置Stage
setTimeout(() => {
// 直接通过stageRef控制Stage
const stage = stageRef.current;
if (stage) {
stage.scale({ x: optimalZoom, y: optimalZoom });
stage.position({ x: 0, y: 0 });
stage.batchDraw();
}
}, 0);
}
};
const handleDownload = () => {
// 直接下载当前画布内容
const stage = stageRef.current;
if (stage) {
try {
// 使用Konva的toDataURL方法获取画布内容
const dataURL = stage.toDataURL();
// 创建下载链接
const link = document.createElement('a');
link.href = dataURL;
link.download = `nano-banana-${Date.now()}.png`;
document.body.appendChild(link);
link.click();
document.body.removeChild(link);
console.log('画布内容下载成功');
} catch (error) {
console.error('下载画布内容时出错:', error);
// 如果Konva下载失败回退到下载原始图像
if (canvasImage) {
// 处理不同类型的URL
if (canvasImage.startsWith('data:')) {
// base64格式
const link = document.createElement('a');
link.href = canvasImage;
link.download = `nano-banana-${Date.now()}.png`;
document.body.appendChild(link);
link.click();
document.body.removeChild(link);
} else if (canvasImage.startsWith('blob:')) {
// Blob URL格式
fetch(canvasImage)
.then(response => response.blob())
.then(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);
})
.catch(error => {
console.error('下载Blob图像时出错:', error);
});
} else {
// 普通URL格式
fetch(canvasImage)
.then(response => response.blob())
.then(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);
})
.catch(error => {
console.error('下载图像时出错:', error);
// 如果fetch失败尝试直接下载
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 {
console.warn('Stage未初始化无法下载画布内容');
// 回退到下载原始图像
if (canvasImage) {
// 处理不同类型的URL
if (canvasImage.startsWith('data:')) {
// base64格式
const link = document.createElement('a');
link.href = canvasImage;
link.download = `nano-banana-${Date.now()}.png`;
document.body.appendChild(link);
link.click();
document.body.removeChild(link);
} else if (canvasImage.startsWith('blob:')) {
// Blob URL格式
fetch(canvasImage)
.then(response => response.blob())
.then(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);
})
.catch(error => {
console.error('下载Blob图像时出错:', error);
});
} else {
// 普通URL格式
fetch(canvasImage)
.then(response => response.blob())
.then(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);
})
.catch(error => {
console.error('下载图像时出错:', error);
// 如果fetch失败尝试直接下载
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);
});
}
}
}
};
return (
<div className="flex flex-col h-full">
{/* 工具栏 */}
{/* 画布区域 */}
<div
id="canvas-container"
className="flex-1 relative overflow-hidden bg-gray-100 rounded-lg"
>
{!image && !isGenerating && (
<div className="absolute inset-0 flex items-center justify-center z-0">
<div className="text-center max-w-xs">
<div className="text-5xl mb-3">🍌</div>
<h2 className="text-lg font-medium text-gray-400 mb-1">
Nano Banana AI
</h2>
<p className="text-gray-500 text-sm">
{selectedTool === 'generate'
? '在提示框中描述您想要创建的内容'
: '上传图像开始编辑'
}
</p>
</div>
</div>
)}
{isGenerating && (
<div className="absolute inset-0 flex items-center justify-center bg-gray-900/40 z-50 backdrop-blur-sm rounded-lg animate-in fade-in duration-300">
<div className="text-center bg-white/90 rounded-xl p-6 card-lg backdrop-blur-sm animate-in scale-in duration-200">
<div className="animate-spin rounded-full h-10 w-10 border-2 border-yellow-400 border-t-transparent mx-auto mb-3" />
<p className="text-gray-700 text-sm font-medium">...</p>
</div>
</div>
)}
<Stage
ref={stageRef}
width={stageSize.width}
height={stageSize.height}
draggable={selectedTool !== 'mask'}
onDragEnd={(e) => {
// 通过stageRef直接获取和设置位置
const stage = stageRef.current;
if (stage) {
const scale = stage.scaleX();
setCanvasPan({
x: stage.x() / scale,
y: stage.y() / scale
});
}
}}
onMouseDown={handleMouseDown}
onMousemove={handleMouseMove}
onMouseup={handleMouseUp}
style={{
cursor: selectedTool === 'mask' ? 'crosshair' : 'default',
zIndex: 10
}}
>
<Layer>
{image && (
<KonvaImage
image={image}
x={(stageSize.width / canvasZoom - image.width) / 2}
y={(stageSize.height / canvasZoom - image.height) / 2}
onRender={() => {
console.log('KonvaImage组件渲染完成');
}}
/>
)}
{/* 画笔描边 */}
{showMasks && brushStrokes.map((stroke) => (
<Line
key={stroke.id}
points={stroke.points}
stroke="#A855F7"
strokeWidth={stroke.brushSize}
tension={0.5}
lineCap="round"
lineJoin="round"
globalCompositeOperation="source-over"
opacity={0.6}
x={(stageSize.width / canvasZoom - (image?.width || 0)) / 2}
y={(stageSize.height / canvasZoom - (image?.height || 0)) / 2}
/>
))}
{/* 正在绘制的当前描边 */}
{isDrawing && currentStroke.length > 2 && (
<Line
points={currentStroke}
stroke="#A855F7"
strokeWidth={brushSize}
tension={0.5}
lineCap="round"
lineJoin="round"
globalCompositeOperation="source-over"
opacity={0.6}
x={(stageSize.width / canvasZoom - (image?.width || 0)) / 2}
y={(stageSize.height / canvasZoom - (image?.height || 0)) / 2}
/>
)}
</Layer>
</Stage>
{/* 悬浮操作按钮 */}
{image && !isGenerating && (
<div className="absolute bottom-4 left-1/2 transform -translate-x-1/2 bg-white/80 backdrop-blur-sm rounded-full card border border-gray-200 px-3 py-2 flex items-center space-x-2">
<Button
variant="ghost"
size="sm"
onClick={() => handleZoom(-0.1)}
className="h-8 w-8 p-0 text-gray-600 hover:text-gray-900 hover:bg-gray-100 rounded-full card"
>
<ZoomOut className="h-4 w-4" />
</Button>
<span className="text-xs text-gray-500 min-w-[40px] text-center">
{Math.round(canvasZoom * 100)}%
</span>
<Button
variant="ghost"
size="sm"
onClick={() => handleZoom(0.1)}
className="h-8 w-8 p-0 text-gray-600 hover:text-gray-900 hover:bg-gray-100 rounded-full card"
>
<ZoomIn className="h-4 w-4" />
</Button>
<Button
variant="ghost"
size="sm"
onClick={handleReset}
className="h-8 w-8 p-0 text-gray-600 hover:text-gray-900 hover:bg-gray-100 rounded-full card"
>
<RotateCcw className="h-4 w-4" />
</Button>
<div className="w-px h-6 bg-gray-200"></div>
<Button
variant="ghost"
size="sm"
onClick={handleDownload}
className="h-8 w-8 p-0 text-gray-600 hover:text-gray-900 hover:bg-gray-100 rounded-full card"
>
<Download className="h-4 w-4" />
</Button>
</div>
)}
</div>
{/* 状态栏 */}
<div className="p-2 border-t border-gray-200 bg-white">
<div className="flex items-center justify-between text-xs text-gray-500">
<div className="flex items-center space-x-2">
{brushStrokes.length > 0 && (
<span className="text-yellow-400">{brushStrokes.length} </span>
)}
</div>
<div className="flex items-center space-x-1">
<span className="text-xs text-gray-500">
© 2025 Mark Fulton
</span>
</div>
</div>
</div>
</div>
);
};

View File

@@ -0,0 +1,54 @@
import React from 'react';
import * as Dialog from '@radix-ui/react-dialog';
import { X } from 'lucide-react';
import { Button } from './ui/Button';
interface ImagePreviewModalProps {
open: boolean;
onOpenChange: (open: boolean) => void;
imageUrl: string;
title: string;
description?: string;
}
export const ImagePreviewModal: React.FC<ImagePreviewModalProps> = ({
open,
onOpenChange,
imageUrl,
title,
description
}) => {
return (
<Dialog.Root open={open} onOpenChange={onOpenChange}>
<Dialog.Portal>
<Dialog.Overlay className="fixed inset-0 bg-black/50 z-50 animate-in fade-in duration-200" />
<Dialog.Content className="fixed inset-0 m-auto w-full max-w-2xl h-fit max-h-[90vh] overflow-y-auto bg-white border border-gray-200 rounded-lg p-6 z-50 animate-in scale-in duration-200">
<div className="flex items-center justify-between mb-4">
<Dialog.Title className="text-lg font-semibold text-gray-900">
{title}
</Dialog.Title>
<Dialog.Close asChild>
<Button variant="ghost" size="icon" className="h-6 w-6">
<X className="h-4 w-4" />
</Button>
</Dialog.Close>
</div>
<div className="space-y-4">
{description && (
<p className="text-sm text-gray-600">{description}</p>
)}
<div className="bg-gray-100 rounded-lg p-4">
<img
src={imageUrl}
alt={title}
className="w-full h-auto rounded-lg border border-gray-200"
/>
</div>
</div>
</Dialog.Content>
</Dialog.Portal>
</Dialog.Root>
);
};

View File

@@ -0,0 +1,124 @@
import React from 'react';
import * as Dialog from '@radix-ui/react-dialog';
import { X, ExternalLink, Lightbulb, Download } from 'lucide-react';
import { Button } from './ui/Button';
interface InfoModalProps {
open: boolean;
onOpenChange: (open: boolean) => void;
}
export const InfoModal: React.FC<InfoModalProps> = ({ open, onOpenChange }) => {
return (
<Dialog.Root open={open} onOpenChange={onOpenChange}>
<Dialog.Portal>
<Dialog.Overlay className="fixed inset-0 bg-black/50 z-50 animate-in fade-in duration-200" />
<Dialog.Content className="fixed inset-0 m-auto w-full max-w-4xl h-fit max-h-[90vh] overflow-y-auto bg-white border border-gray-200 rounded-lg p-6 z-50 animate-in scale-in duration-200">
<div className="flex items-center justify-between mb-4">
<Dialog.Title className="text-lg font-semibold text-gray-900">
Nano Banana AI
</Dialog.Title>
<Dialog.Close asChild>
<Button variant="ghost" size="icon" className="h-6 w-6">
<X className="h-4 w-4" />
</Button>
</Dialog.Close>
</div>
<div className="space-y-6">
<div className="space-y-3 text-sm text-gray-700">
<p>
{' '}
<a
href="https://markfulton.com"
target="_blank"
rel="noopener noreferrer"
className="text-yellow-600 hover:text-yellow-700 transition-colors font-semibold"
>
Mark Fulton
<ExternalLink className="h-3 w-3 inline ml-1" />
</a>
</p>
<div className="grid grid-cols-1 md:grid-cols-2 gap-6">
<div className="p-4 bg-gradient-to-br from-purple-100 to-indigo-100 rounded-lg border border-purple-200">
<div className="flex items-center mb-3">
<Lightbulb className="h-5 w-5 text-purple-600 mr-2" />
<h4 className="text-sm font-semibold text-purple-700">
AI应用和其他解决方案
</h4>
</div>
<p className="text-sm text-gray-700 mb-4">
AI自动化沿
</p>
<a
href="https://www.reinventing.ai/"
target="_blank"
rel="noopener noreferrer"
className="inline-flex items-center px-4 py-2 bg-gradient-to-r from-purple-500 to-indigo-500 hover:from-purple-600 hover:to-indigo-600 text-white rounded-lg transition-all duration-200 font-medium"
>
AI加速器计划
<ExternalLink className="h-4 w-4 ml-1" />
</a>
</div>
<div className="p-4 bg-gradient-to-br from-yellow-100 to-orange-100 rounded-lg border border-yellow-200">
<div className="flex items-center mb-3">
<Download className="h-5 w-5 text-yellow-600 mr-2" />
<h4 className="text-sm font-semibold text-yellow-700">
</h4>
</div>
<p className="text-sm text-gray-700 mb-4">
Vibe Coding is Life Skool社区获取此应用程序的副本
</p>
<a
href="https://www.skool.com/vibe-coding-is-life/about?ref=456537abaf37491cbcc6976f3c26af41"
target="_blank"
rel="noopener noreferrer"
className="inline-flex items-center px-4 py-2 bg-gradient-to-r from-yellow-500 to-orange-500 hover:from-yellow-600 hover:to-orange-600 text-white rounded-lg transition-all duration-200 font-medium"
>
Vibe Coding is Life社区
<ExternalLink className="h-4 w-4 ml-1" />
</a>
</div>
</div>
</div>
{/* 键盘快捷键 */}
<div className="border-t border-gray-200 pt-4">
<h3 className="text-base font-semibold text-gray-900 mb-3"></h3>
<div className="grid grid-cols-1 sm:grid-cols-2 md:grid-cols-3 gap-3">
<div className="flex justify-between items-center p-2 bg-gray-50 rounded">
<span className="text-sm text-gray-600"></span>
<span className="font-mono bg-gray-200 px-2 py-1 rounded text-xs"> + Enter</span>
</div>
<div className="flex justify-between items-center p-2 bg-gray-50 rounded">
<span className="text-sm text-gray-600"></span>
<span className="font-mono bg-gray-200 px-2 py-1 rounded text-xs"> + R</span>
</div>
<div className="flex justify-between items-center p-2 bg-gray-50 rounded">
<span className="text-sm text-gray-600"></span>
<span className="font-mono bg-gray-200 px-2 py-1 rounded text-xs">E</span>
</div>
<div className="flex justify-between items-center p-2 bg-gray-50 rounded">
<span className="text-sm text-gray-600"></span>
<span className="font-mono bg-gray-200 px-2 py-1 rounded text-xs">H</span>
</div>
<div className="flex justify-between items-center p-2 bg-gray-50 rounded">
<span className="text-sm text-gray-600"></span>
<span className="font-mono bg-gray-200 px-2 py-1 rounded text-xs">P</span>
</div>
<div className="flex justify-between items-center p-2 bg-gray-50 rounded">
<span className="text-sm text-gray-600"></span>
<span className="font-mono bg-gray-200 px-2 py-1 rounded text-xs">Esc</span>
</div>
</div>
</div>
</div>
</Dialog.Content>
</Dialog.Portal>
</Dialog.Root>
);
};

View File

@@ -0,0 +1,36 @@
import React from 'react';
import { useAppStore } from '../store/useAppStore';
export const MaskOverlay: React.FC = () => {
const { selectedMask, showMasks } = useAppStore();
if (!showMasks || !selectedMask) return null;
return (
<div className="absolute inset-0 pointer-events-none">
{/* Marching ants effect */}
<div
className="absolute border-2 border-yellow-400 animate-pulse"
style={{
left: selectedMask.bounds.x,
top: selectedMask.bounds.y,
width: selectedMask.bounds.width,
height: selectedMask.bounds.height,
borderStyle: 'dashed',
animationDuration: '1s'
}}
/>
{/* Mask overlay */}
<div
className="absolute bg-yellow-400/20"
style={{
left: selectedMask.bounds.x,
top: selectedMask.bounds.y,
width: selectedMask.bounds.width,
height: selectedMask.bounds.height,
}}
/>
</div>
);
};

View File

@@ -0,0 +1,483 @@
import React, { useState, useRef } 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 { PromptHints } from './PromptHints';
import { PromptSuggestions } from './PromptSuggestions';
import { cn } from '../utils/cn';
export const PromptComposer: React.FC = () => {
const {
currentPrompt,
setCurrentPrompt,
selectedTool,
setSelectedTool,
temperature,
setTemperature,
seed,
setSeed,
isGenerating,
uploadedImages,
addUploadedImage,
removeUploadedImage,
clearUploadedImages,
editReferenceImages,
addEditReferenceImage,
removeEditReferenceImage,
clearEditReferenceImages,
canvasImage,
setCanvasImage,
showPromptPanel,
setShowPromptPanel,
clearBrushStrokes,
addBlob
} = useAppStore();
const { generate, cancelGeneration } = useImageGeneration();
const { edit, cancelEdit } = useImageEditing();
const [showAdvanced, setShowAdvanced] = useState(false);
const [showClearConfirm, setShowClearConfirm] = useState(false);
const [showHintsModal, setShowHintsModal] = useState(false);
const [isDragOver, setIsDragOver] = useState(false);
const fileInputRef = useRef<HTMLInputElement>(null);
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
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('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);
}
}
}
generate({
prompt: currentPrompt,
referenceImages: referenceImageBlobs.length > 0 ? referenceImageBlobs : undefined,
temperature,
seed: seed !== null ? seed : undefined
});
} else if (selectedTool === 'edit' || selectedTool === 'mask') {
edit(currentPrompt);
}
};
const handleFileUpload = async (file: File) => {
if (file && file.type.startsWith('image/')) {
try {
// 直接使用Blob创建URL
const blobUrl = addBlob(file);
if (selectedTool === 'generate') {
// 添加到参考图像最多2张
if (uploadedImages.length < 2) {
addUploadedImage(blobUrl);
}
} else if (selectedTool === 'edit') {
// 编辑模式下添加到单独的编辑参考图像最多2张
if (editReferenceImages.length < 2) {
addEditReferenceImage(blobUrl);
}
// 如果没有画布图像,则设置为画布图像
if (!canvasImage) {
setCanvasImage(blobUrl);
}
} else if (selectedTool === 'mask') {
// 遮罩模式下,立即设置为画布图像
clearUploadedImages();
addUploadedImage(blobUrl);
setCanvasImage(blobUrl);
}
} catch (error) {
console.error('上传图像失败:', error);
}
}
};
const handleFileInputChange = async (event: React.ChangeEvent<HTMLInputElement>) => {
const file = event.target.files?.[0];
if (file) {
await handleFileUpload(file);
}
};
const handleDragOver = (event: React.DragEvent<HTMLDivElement>) => {
event.preventDefault();
setIsDragOver(true);
};
const handleDragLeave = () => {
setIsDragOver(false);
};
const handleDrop = async (event: React.DragEvent<HTMLDivElement>) => {
event.preventDefault();
setIsDragOver(false);
const file = event.dataTransfer.files?.[0];
if (file) {
await handleFileUpload(file);
}
};
const handleClearSession = () => {
setCurrentPrompt('');
clearUploadedImages();
clearEditReferenceImages();
clearBrushStrokes();
setCanvasImage(null);
setSeed(null);
setTemperature(0.7);
setShowClearConfirm(false);
};
const tools = [
{ id: 'generate', icon: Wand2, label: '生成', description: '从文本创建' },
{ id: 'edit', icon: Edit3, label: '编辑', description: '修改现有图像' },
{ id: 'mask', icon: MousePointer, label: '选择', description: '点击选择' },
] as const;
if (!showPromptPanel) {
return (
<div className="w-8 bg-white flex flex-col items-center justify-center rounded-l-xl overflow-hidden">
<button
onClick={() => setShowPromptPanel(true)}
className="w-6 h-16 bg-gray-100 hover:bg-gray-200 rounded-r-lg flex items-center justify-center transition-all duration-300 ease-in-out group"
title="显示提示面板"
>
<div className="flex flex-col space-y-1">
<div className="w-1 h-1 bg-gray-500 group-hover:bg-gray-400 rounded-full transition-colors duration-200"></div>
<div className="w-1 h-1 bg-gray-500 group-hover:bg-gray-400 rounded-full transition-colors duration-200"></div>
<div className="w-1 h-1 bg-gray-500 group-hover:bg-gray-400 rounded-full transition-colors duration-200"></div>
</div>
</button>
</div>
);
}
return (
<>
<div className="w-72 h-full bg-white p-5 flex flex-col overflow-y-auto space-y-5">
<div>
<div className="flex items-center justify-between mb-3">
<h3 className="text-xs font-semibold text-gray-500 uppercase tracking-wide"></h3>
<div className="flex items-center space-x-1">
<Button
variant="ghost"
size="icon"
onClick={() => setShowHintsModal(true)}
className="h-7 w-7 text-gray-400 hover:text-gray-600 hover:bg-gray-100 rounded-full card"
>
<HelpCircle className="h-4 w-4" />
</Button>
<Button
variant="ghost"
size="icon"
onClick={() => setShowPromptPanel(false)}
className="h-7 w-7 text-gray-400 hover:text-gray-600 hover:bg-gray-100 rounded-full card"
title="隐藏面板"
>
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
<line x1="18" y1="6" x2="6" y2="18"></line>
<line x1="6" y1="6" x2="18" y2="18"></line>
</svg>
</Button>
</div>
</div>
<div className="grid grid-cols-3 gap-2.5">
{tools.map((tool) => (
<button
key={tool.id}
onClick={() => setSelectedTool(tool.id)}
className={cn(
'flex flex-col items-center p-3 rounded-xl border transition-all duration-200 hover:scale-105',
selectedTool === tool.id
? 'bg-yellow-50 border-yellow-300 text-yellow-700 shadow-sm'
: 'bg-gray-50 border-gray-200 text-gray-500 hover:bg-yellow-50 hover:border-yellow-300 hover:text-yellow-700'
)}
>
<tool.icon className="h-5 w-5 mb-1.5" />
<span className="text-xs font-medium">{tool.label}</span>
</button>
))}
</div>
</div>
{/* 文件上传 */}
<div className="space-y-3">
<div
onDragOver={handleDragOver}
onDragLeave={handleDragLeave}
onDrop={handleDrop}
className={cn(
"border-2 border-dashed rounded-xl p-5 text-center transition-colors",
isDragOver
? "border-yellow-400 bg-yellow-400/10"
: "border-gray-300 hover:border-yellow-400"
)}
>
<label className="text-sm font-semibold text-gray-700 mb-2 block">
{selectedTool === 'generate' ? '参考图像' : selectedTool === 'edit' ? '样式参考' : '上传图像'}
</label>
{selectedTool === 'mask' && (
<p className="text-xs text-gray-500 mb-3">使</p>
)}
{selectedTool === 'generate' && (
<p className="text-xs text-gray-500 mb-3">2</p>
)}
{selectedTool === 'edit' && (
<p className="text-xs text-gray-500 mb-3">
{canvasImage ? '可选样式参考最多2张图像' : '上传要编辑的图像最多2张图像'}
</p>
)}
<input
ref={fileInputRef}
type="file"
accept="image/*"
onChange={handleFileInputChange}
className="hidden"
/>
<div className="flex flex-col items-center justify-center space-y-3">
<Upload className={cn("h-7 w-7", isDragOver ? "text-yellow-500" : "text-gray-400")} />
<div>
<p className={cn(
"text-sm font-medium",
isDragOver ? "text-yellow-700" : "text-gray-600"
)}>
{isDragOver ? "释放文件以上传" : "拖拽或点击上传"}
</p>
<p className="text-xs text-gray-500 mt-1">
JPG, PNG, GIF
</p>
</div>
<Button
variant="outline"
size="sm"
onClick={() => fileInputRef.current?.click()}
className="mt-1 card"
disabled={
(selectedTool === 'generate' && uploadedImages.length >= 2) ||
(selectedTool === 'edit' && editReferenceImages.length >= 2)
}
>
<Upload className="h-3.5 w-3.5 mr-1.5" />
</Button>
</div>
</div>
{/* Show uploaded images preview */}
{((selectedTool === 'generate' && uploadedImages.length > 0) ||
(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>
))}
</div>
)}
</div>
{/* 提示输入 */}
<div className="flex-grow space-y-3">
<label className="text-xs font-semibold text-gray-500 block uppercase tracking-wide">
{selectedTool === 'generate' ? '提示词' : '编辑指令'}
</label>
<Textarea
value={currentPrompt}
onChange={(e) => setCurrentPrompt(e.target.value)}
placeholder={
selectedTool === 'generate'
? '描述您想要创建的内容...'
: '描述您想要的修改...'
}
className="min-h-[180px] resize-none text-sm rounded-xl"
/>
{/* 常用提示词 */}
<PromptSuggestions
onWordSelect={(word) => {
setCurrentPrompt(currentPrompt ? `${currentPrompt}, ${word}` : word);
}}
minFrequency={3}
/>
{/* 提示质量指示器 */}
<button
onClick={() => setShowHintsModal(true)}
className="flex items-center text-xs hover:text-gray-700 transition-colors group"
>
{currentPrompt.length < 20 ? (
<HelpCircle className="h-4 w-4 mr-2 text-red-400 group-hover:text-red-500" />
) : (
<div className={cn(
'h-2.5 w-2.5 rounded-full mr-2',
currentPrompt.length < 50 ? 'bg-yellow-400' : 'bg-green-400'
)} />
)}
<span className="text-gray-500 group-hover:text-gray-700">
{currentPrompt.length < 20 ? '添加更多细节' :
currentPrompt.length < 50 ? '细节良好' : '细节优秀'}
</span>
</button>
</div>
{/* 生成按钮 */}
<div className="flex-shrink-0">
{isGenerating ? (
<div className="flex gap-3">
<Button
onClick={() => selectedTool === 'generate' ? cancelGeneration() : cancelEdit()}
className="flex-1 h-14 text-base font-semibold bg-red-500 hover:bg-red-600 rounded-xl card"
>
<div className="animate-spin rounded-full h-5 w-5 border-b-2 border-white mr-2" />
</Button>
</div>
) : (
<Button
onClick={handleGenerate}
disabled={!currentPrompt.trim()}
className="w-full h-14 text-base font-semibold rounded-xl shadow-md hover:shadow-lg transition-all card"
>
<Wand2 className="h-5 w-5 mr-2" />
{selectedTool === 'generate' ? '生成图像' : '应用编辑'}
</Button>
)}
</div>
{/* 高级控制 */}
<div className="pt-3 border-t border-gray-100 flex-shrink-0">
<button
onClick={() => setShowAdvanced(!showAdvanced)}
className="flex items-center text-sm text-gray-500 hover:text-gray-700 transition-colors duration-200"
>
{showAdvanced ? <ChevronDown className="h-4 w-4 mr-1.5" /> : <ChevronRight className="h-4 w-4 mr-1.5" />}
</button>
{showAdvanced && (
<div className="mt-4 space-y-4 animate-in slide-down duration-300">
{/* 创造力 */}
<div className="space-y-2">
<label className="text-sm text-gray-500 block flex justify-between">
<span className="font-medium"></span>
<span className="font-mono bg-gray-100 px-2 py-0.5 rounded">{temperature}</span>
</label>
<input
type="range"
min="0"
max="1"
step="0.1"
value={temperature}
onChange={(e) => setTemperature(parseFloat(e.target.value))}
className="w-full h-2 bg-gray-200 rounded-full appearance-none cursor-pointer slider"
/>
</div>
{/* 种子 */}
<div className="space-y-2">
<label className="text-sm text-gray-500 block font-medium">
()
</label>
<input
type="number"
value={seed || ''}
onChange={(e) => setSeed(e.target.value ? parseInt(e.target.value) : null)}
placeholder="随机"
className="w-full h-10 px-3 bg-gray-50 border border-gray-200 rounded-lg text-sm text-gray-700 focus:outline-none focus:ring-2 focus:ring-yellow-400 focus:border-transparent"
/>
</div>
</div>
)}
<button
onClick={() => setShowClearConfirm(!showClearConfirm)}
className="flex items-center text-sm text-gray-500 hover:text-red-500 transition-colors duration-200 mt-4"
>
<RotateCcw className="h-4 w-4 mr-2" />
</button>
{showClearConfirm && (
<div className="mt-3 p-4 bg-red-50 rounded-xl border border-red-100 animate-in slide-down duration-300">
<p className="text-sm text-red-700 mb-4">
</p>
<div className="flex space-x-3">
<Button
variant="destructive"
onClick={handleClearSession}
className="flex-1 h-10 text-sm font-semibold card text-gray-700"
>
</Button>
<Button
variant="outline"
onClick={() => setShowClearConfirm(false)}
className="flex-1 h-10 text-sm font-semibold border-gray-300 text-gray-700 hover:bg-gray-100 card"
>
</Button>
</div>
</div>
)}
</div>
</div>
{/* 提示提示模态框 */}
<PromptHints open={showHintsModal} onOpenChange={setShowHintsModal} />
</>
);
};
export default PromptComposer;

View File

@@ -0,0 +1,87 @@
import React from 'react';
import * as Dialog from '@radix-ui/react-dialog';
import { X } from 'lucide-react';
import { PromptHint } from '../types';
import { Button } from './ui/Button';
const promptHints: PromptHint[] = [
{
category: 'subject',
text: 'Be specific about the main subject',
example: '"A vintage red bicycle" vs "bicycle"'
},
{
category: 'scene',
text: 'Describe the environment and setting',
example: '"in a cobblestone alley during golden hour"'
},
{
category: 'action',
text: 'Include movement or activity',
example: '"cyclist pedaling through puddles"'
},
{
category: 'style',
text: 'Specify artistic style or mood',
example: '"cinematic photography, moody lighting"'
},
{
category: 'camera',
text: 'Add camera perspective details',
example: '"shot with 85mm lens, shallow depth of field"'
}
];
const categoryColors = {
subject: 'bg-blue-500/10 border-blue-500/30 text-blue-400',
scene: 'bg-green-500/10 border-green-500/30 text-green-400',
action: 'bg-purple-500/10 border-purple-500/30 text-purple-400',
style: 'bg-orange-500/10 border-orange-500/30 text-orange-400',
camera: 'bg-pink-500/10 border-pink-500/30 text-pink-400',
};
interface PromptHintsProps {
open: boolean;
onOpenChange: (open: boolean) => void;
}
export const PromptHints: React.FC<PromptHintsProps> = ({ open, onOpenChange }) => {
return (
<Dialog.Root open={open} onOpenChange={onOpenChange}>
<Dialog.Portal>
<Dialog.Overlay className="fixed inset-0 bg-black/30 z-50 animate-in fade-in duration-200" />
<Dialog.Content className="fixed inset-0 m-auto w-full max-w-md h-fit max-h-[80vh] overflow-y-auto bg-white border border-gray-200 rounded-lg p-6 z-50 animate-in scale-in duration-200">
<div className="flex items-center justify-between mb-4">
<Dialog.Title className="text-lg font-semibold text-gray-900">
</Dialog.Title>
<Dialog.Close asChild>
<Button variant="ghost" size="icon" className="h-6 w-6">
<X className="h-4 w-4" />
</Button>
</Dialog.Close>
</div>
<div className="space-y-4">
{promptHints.map((hint, index) => (
<div key={index} className="space-y-2">
<div className={`inline-block px-2 py-1 rounded text-xs border ${categoryColors[hint.category]}`}>
{hint.category}
</div>
<p className="text-sm text-gray-700">{hint.text}</p>
<p className="text-sm text-gray-500 italic">{hint.example}</p>
</div>
))}
<div className="p-4 bg-gray-100 rounded-lg border border-gray-200 mt-6">
<p className="text-sm text-gray-700">
<strong className="text-yellow-600">:</strong>
"用文字为我画一幅画"
</p>
</div>
</div>
</Dialog.Content>
</Dialog.Portal>
</Dialog.Root>
);
};

View File

@@ -0,0 +1,120 @@
import React, { useState, useEffect } from 'react';
import { Button } from './ui/Button';
import { useAppStore } from '../store/useAppStore';
import { cn } from '../utils/cn';
interface WordFrequency {
word: string;
count: number;
}
export const PromptSuggestions: React.FC<{
onWordSelect?: (word: string) => void;
minFrequency?: number;
}> = ({ onWordSelect, minFrequency = 3 }) => {
const { currentProject } = useAppStore();
const [frequentWords, setFrequentWords] = useState<WordFrequency[]>([]);
const [showAll, setShowAll] = useState(false);
// 从提示词中提取词语并统计频次
const extractWords = (text: string): string[] => {
// 移除标点符号并分割词语
return text
.toLowerCase()
.replace(/[^\w\s\u4e00-\u9fff]/g, ' ') // 保留中文字符
.split(/\s+/)
.filter(word => word.length > 1); // 过滤掉单字符
};
// 统计词语频次
const calculateWordFrequency = (): WordFrequency[] => {
const wordCount: Record<string, number> = {};
// 收集所有提示词
const allPrompts: string[] = [];
// 添加生成记录的提示词
if (currentProject?.generations) {
currentProject.generations.forEach(gen => {
if (gen.prompt) {
allPrompts.push(gen.prompt);
}
});
}
// 添加编辑记录的指令
if (currentProject?.edits) {
currentProject.edits.forEach(edit => {
if (edit.instruction) {
allPrompts.push(edit.instruction);
}
});
}
// 提取词语并统计频次
allPrompts.forEach(prompt => {
const words = extractWords(prompt);
words.forEach(word => {
wordCount[word] = (wordCount[word] || 0) + 1;
});
});
// 转换为数组并过滤
return Object.entries(wordCount)
.map(([word, count]) => ({ word, count }))
.filter(({ count }) => count >= minFrequency)
.sort((a, b) => b.count - a.count);
};
useEffect(() => {
setFrequentWords(calculateWordFrequency());
}, [currentProject, minFrequency]);
// 显示的词语数量
const displayWords = showAll ? frequentWords : frequentWords.slice(0, 20);
if (frequentWords.length === 0) {
return null;
}
return (
<div className="p-4 bg-gray-50 rounded-lg">
<div className="flex items-center justify-between mb-3">
<h3 className="text-sm font-medium text-gray-700"></h3>
{frequentWords.length > 20 && (
<Button
variant="ghost"
size="sm"
className="text-xs text-gray-500 hover:text-gray-700"
onClick={() => setShowAll(!showAll)}
>
{showAll ? '收起' : '展开'}
</Button>
)}
</div>
<div className="flex flex-wrap gap-2">
{displayWords.map(({ word, count }) => (
<button
key={word}
onClick={() => onWordSelect?.(word)}
className={cn(
"px-2 py-1 text-xs rounded-full border transition-all",
"bg-white hover:bg-yellow-50 border-gray-200 hover:border-yellow-300",
"text-gray-700 hover:text-gray-900"
)}
title={`出现频次: ${count}`}
>
{word}
</button>
))}
</div>
{frequentWords.length > 20 && (
<div className="mt-2 text-xs text-gray-500 text-center">
{frequentWords.length}
</div>
)}
</div>
);
};

119
v1/src/components/Toast.tsx Normal file
View File

@@ -0,0 +1,119 @@
import React, { useState, useRef, useEffect } from 'react';
import { cn } from '../utils/cn';
import { X, ChevronDown, ChevronUp } from 'lucide-react';
export interface ToastProps {
id: string;
message: string;
type: 'success' | 'error' | 'warning' | 'info';
details?: string;
onClose: (id: string) => void;
onHoverChange?: (hovered: boolean) => void;
}
export const Toast: React.FC<ToastProps> = ({ id, message, type, details, onClose, onHoverChange }) => {
const [showDetails, setShowDetails] = useState(false);
const [isHovered, setIsHovered] = useState(false);
const [isExiting, setIsExiting] = useState(false);
const hoverTimeoutRef = useRef<NodeJS.Timeout | null>(null);
const getTypeStyles = () => {
switch (type) {
case 'success':
return 'bg-green-500 text-white';
case 'error':
return 'bg-red-500 text-white';
case 'warning':
return 'bg-yellow-500 text-white';
case 'info':
return 'bg-blue-500 text-white';
default:
return 'bg-gray-500 text-white';
}
};
const handleMouseEnter = () => {
// Clear any existing timeout
if (hoverTimeoutRef.current) {
clearTimeout(hoverTimeoutRef.current);
hoverTimeoutRef.current = null;
}
setIsHovered(true);
onHoverChange?.(true);
};
const handleMouseLeave = () => {
// Set a timeout to mark as not hovered after 1 second
hoverTimeoutRef.current = setTimeout(() => {
setIsHovered(false);
onHoverChange?.(false);
}, 1000);
};
const handleClose = () => {
setIsExiting(true);
setTimeout(() => {
onClose(id);
}, 300); // Match the animation duration
};
// Cleanup timeout on unmount
useEffect(() => {
return () => {
if (hoverTimeoutRef.current) {
clearTimeout(hoverTimeoutRef.current);
}
};
}, []);
return (
<div
className={cn(
'flex flex-col rounded-lg shadow-lg min-w-[300px] max-w-md transition-all duration-300 transform',
getTypeStyles(),
isExiting ? 'animate-out slide-out-to-right duration-300' : 'animate-in slide-in-from-right duration-300'
)}
onMouseEnter={handleMouseEnter}
onMouseLeave={handleMouseLeave}
>
<div className="flex items-start justify-between p-4">
<div className="flex-1">
<span className="text-sm font-medium">{message}</span>
{details && (
<button
onClick={() => setShowDetails(!showDetails)}
className="mt-2 flex items-center text-xs opacity-80 hover:opacity-100 transition-opacity"
>
{showDetails ? (
<>
<ChevronUp className="h-3 w-3 mr-1" />
</>
) : (
<>
<ChevronDown className="h-3 w-3 mr-1" />
</>
)}
</button>
)}
</div>
<button
onClick={handleClose}
className="ml-4 hover:bg-black/10 rounded-full p-1 transition-colors"
>
<X className="h-4 w-4" />
</button>
</div>
{details && showDetails && (
<div className="px-4 pb-4">
<div className="text-xs opacity-90 whitespace-pre-wrap break-words max-h-32 overflow-y-auto">
{details}
</div>
</div>
)}
</div>
);
};

View File

@@ -0,0 +1,125 @@
import React, { createContext, useContext, useReducer, useState, useEffect, useRef } from 'react';
import { Toast } from './Toast';
type ToastType = 'success' | 'error' | 'warning' | 'info';
export interface ToastMessage {
id: string;
message: string;
type: ToastType;
duration?: number;
details?: string;
}
export interface ToastContextType {
addToast: (message: string, type: ToastType, duration?: number, details?: string) => void;
removeToast: (id: string) => void;
}
const ToastContext = createContext<ToastContextType | undefined>(undefined);
type ToastAction =
| { type: 'ADD_TOAST'; payload: ToastMessage }
| { type: 'REMOVE_TOAST'; payload: string }
| { type: 'SET_HOVERED_TOAST', payload: { id: string, hovered: boolean } };
const toastReducer = (state: ToastMessage[], action: ToastAction): ToastMessage[] => {
switch (action.type) {
case 'ADD_TOAST':
return [...state, { ...action.payload, hovered: false }];
case 'REMOVE_TOAST':
return state.filter(toast => toast.id !== action.payload);
case 'SET_HOVERED_TOAST':
return state.map(toast =>
toast.id === action.payload.id
? { ...toast, hovered: action.payload.hovered }
: toast
);
default:
return state;
}
};
export const ToastProvider: React.FC<{ children: React.ReactNode }> = ({ children }) => {
const [toasts, dispatch] = useReducer(toastReducer, []);
const hoverTimeouts = useRef<Record<string, NodeJS.Timeout>>({});
const addToast = (message: string, type: ToastType, duration: number = 5000, details?: string) => {
const id = Date.now().toString();
dispatch({ type: 'ADD_TOAST', payload: { id, message, type, duration, details } });
};
const removeToast = (id: string) => {
dispatch({ type: 'REMOVE_TOAST', payload: id });
};
const setToastHovered = (id: string, hovered: boolean) => {
dispatch({ type: 'SET_HOVERED_TOAST', payload: { id, hovered } });
};
// Auto remove toasts after duration, but respect hover state
useEffect(() => {
const timers = toasts.map(toast => {
// Clear any existing timeout for this toast
if (hoverTimeouts.current[toast.id]) {
clearTimeout(hoverTimeouts.current[toast.id]);
delete hoverTimeouts.current[toast.id];
}
// If toast is hovered, don't set a timer
if (toast.hovered) {
return null;
}
// If duration is 0, it's persistent
if (toast.duration === 0) {
return null;
}
// Set timeout to remove toast
const timeout = setTimeout(() => {
removeToast(toast.id);
}, toast.duration);
return { id: toast.id, timeout };
});
// Cleanup function
return () => {
timers.forEach(timer => {
if (timer) clearTimeout(timer.timeout);
});
};
}, [toasts]);
return (
<ToastContext.Provider value={{ addToast, removeToast }}>
{children}
<div className="fixed top-4 right-4 z-50 space-y-2">
{toasts.map(toast => (
<div
key={toast.id}
className="animate-in slide-in-from-right duration-300"
>
<Toast
id={toast.id}
message={toast.message}
type={toast.type}
details={toast.details}
onClose={removeToast}
onHoverChange={(hovered) => setToastHovered(toast.id, hovered)}
/>
</div>
))}
</div>
</ToastContext.Provider>
);
};
export const useToast = () => {
const context = useContext(ToastContext);
if (!context) {
throw new Error('useToast must be used within a ToastProvider');
}
return context;
};

View File

@@ -0,0 +1,60 @@
import React from 'react';
import { cva, type VariantProps } from 'class-variance-authority';
import { cn } from '../../utils/cn';
const buttonVariants = cva(
'inline-flex items-center justify-center rounded-lg text-sm font-medium transition-all duration-200 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-offset-2 disabled:pointer-events-none disabled:opacity-50',
{
variants: {
variant: {
default: 'bg-yellow-400 text-gray-900 hover:bg-yellow-300 focus-visible:ring-yellow-400',
secondary: 'bg-gray-100 text-gray-900 hover:bg-gray-200 focus-visible:ring-gray-300',
outline: 'border border-gray-300 bg-transparent text-gray-700 hover:bg-gray-100 hover:text-gray-900',
ghost: 'text-gray-500 hover:bg-gray-100 hover:text-gray-900',
destructive: 'bg-red-500 text-white hover:bg-red-600 focus-visible:ring-red-400',
},
size: {
default: 'h-10 px-4 py-2',
sm: 'h-8 px-3 text-xs',
lg: 'h-12 px-8',
icon: 'h-10 w-10',
},
},
defaultVariants: {
variant: 'default',
size: 'default',
},
}
);
export interface ButtonProps
extends React.ButtonHTMLAttributes<HTMLButtonElement>,
VariantProps<typeof buttonVariants> {}
export const Button = React.forwardRef<HTMLButtonElement, ButtonProps>(
({ className, variant, size, ...props }, ref) => {
const [isClicking, setIsClicking] = React.useState(false);
const handleClick = (e: React.MouseEvent<HTMLButtonElement>) => {
setIsClicking(true);
// Reset the clicking state after the animation completes
setTimeout(() => setIsClicking(false), 200);
// Call the original onClick handler if it exists
if (props.onClick) {
props.onClick(e);
}
};
return (
<button
className={cn(buttonVariants({ variant, size, className }), isClicking && 'animate-pulse-click')}
ref={ref}
{...props}
onClick={handleClick}
/>
);
}
);
Button.displayName = 'Button';

View File

@@ -0,0 +1,22 @@
import React from 'react';
import { cn } from '../../utils/cn';
export interface InputProps extends React.InputHTMLAttributes<HTMLInputElement> {}
export const Input = React.forwardRef<HTMLInputElement, InputProps>(
({ className, 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
)}
ref={ref}
{...props}
/>
);
}
);
Input.displayName = 'Input';

View File

@@ -0,0 +1,21 @@
import React from 'react';
import { cn } from '../../utils/cn';
export interface TextareaProps extends React.TextareaHTMLAttributes<HTMLTextAreaElement> {}
export const Textarea = React.forwardRef<HTMLTextAreaElement, TextareaProps>(
({ className, ...props }, ref) => {
return (
<textarea
className={cn(
'flex min-h-[80px] w-full rounded-lg border border-gray-300 bg-white px-3 py-2 text-sm text-gray-900 ring-offset-white 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 resize-none',
className
)}
ref={ref}
{...props}
/>
);
}
);
Textarea.displayName = 'Textarea';

View File

@@ -0,0 +1,557 @@
import React from 'react'
import { useMutation } from '@tanstack/react-query'
import { geminiService, GenerationRequest, EditRequest } from '../services/geminiService'
import { useAppStore } from '../store/useAppStore'
import { generateId } from '../utils/imageUtils'
import { Generation, Edit, Asset } from '../types'
import { useToast } from '../components/ToastContext'
import { uploadImages } from '../services/uploadService'
import { blobToBase64 } from '../utils/imageUtils'
export const useImageGeneration = () => {
const { addGeneration, setIsGenerating, setCanvasImage } = useAppStore()
const { addToast } = useToast()
// 创建中断标志引用
const isCancelledRef = React.useRef(false)
const generateMutation = useMutation({
mutationFn: async (request: GenerationRequest) => {
// 重置中断标志
isCancelledRef.current = false
// 将参考图像从base64转换为Blob如果需要
let blobReferenceImages: Blob[] | undefined;
if (request.referenceImages) {
blobReferenceImages = [];
for (const img of request.referenceImages) {
if (typeof img === 'string') {
// 如果是base64字符串转换为Blob
const byteString = atob(img);
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);
}
blobReferenceImages.push(new Blob([ab], { type: mimeString }));
} else {
// 如果已经是Blob直接使用
blobReferenceImages.push(img);
}
}
}
const blobRequest: GenerationRequest = {
...request,
referenceImages: blobReferenceImages
};
const result = await geminiService.generateImage(blobRequest)
// 检查是否已中断
if (isCancelledRef.current) {
throw new Error('生成已中断')
}
return result
},
onMutate: () => {
setIsGenerating(true)
},
onSuccess: async (result, request) => {
const { images, usageMetadata } = result;
if (images.length > 0) {
// 直接使用Blob并创建URL避免存储base64数据
const outputAssets: Asset[] = await Promise.all(images.map(async (blob, index) => {
// 使用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 (error) {
resolve(generateId().slice(0, 32));
}
});
return {
id: generateId(),
type: 'output',
url: blobUrl, // 存储Blob URL而不是base64
mime: 'image/png',
width: 1024, // 默认Gemini输出尺寸
height: 1024,
checksum // 使用生成的校验和
};
}));
// 获取accessToken
const accessToken = import.meta.env.VITE_ACCESS_TOKEN || '';
let uploadResults: any[] | undefined;
// 上传生成的图像和参考图像
if (accessToken) {
try {
// 上传生成的图像(跳过缓存,因为这些是新生成的图像)
const imageUrls = outputAssets.map(asset => asset.url);
const outputUploadResults = await uploadImages(imageUrls, accessToken, true);
// 上传参考图像(如果存在,使用缓存机制)
let referenceUploadResults: any[] = [];
if (request.referenceImages && request.referenceImages.length > 0) {
// 将参考图像也转换为Blob URL
const referenceUrls = await Promise.all(request.referenceImages.map(async (blob) => {
return useAppStore.getState().addBlob(blob);
}));
referenceUploadResults = await uploadImages(referenceUrls, accessToken, false);
}
// 合并上传结果
uploadResults = [...outputUploadResults, ...referenceUploadResults];
// 检查上传结果
const failedUploads = uploadResults.filter(r => !r.success);
if (failedUploads.length > 0) {
console.warn(`${failedUploads.length}张图像上传失败`);
addToast(`${failedUploads.length}张图像上传失败`, 'warning', 5000);
} else {
console.log(`${uploadResults.length}张图像全部上传成功`);
addToast('图像已成功上传', 'success', 3000);
}
} catch (error) {
console.error('上传图像时出错:', error);
addToast('图像上传失败', 'error', 5000);
uploadResults = undefined;
}
} else {
console.warn('未找到accessToken跳过上传');
}
// 显示Token消耗信息如果可用
if (usageMetadata?.totalTokenCount) {
addToast(`本次生成消耗 ${usageMetadata.totalTokenCount} Tokens`, 'info', 3000);
}
const generation: Generation = {
id: generateId(),
prompt: request.prompt,
parameters: {
aspectRatio: '1:1',
seed: request.seed,
temperature: request.temperature
},
sourceAssets: request.referenceImages ? await Promise.all(request.referenceImages.map(async (blob, index) => {
// 将参考图像转换为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 (error) {
resolve(generateId().slice(0, 32));
}
});
return {
id: generateId(),
type: 'original' as const,
url: blobUrl, // 存储Blob URL而不是base64
mime: blob.type || 'image/png',
width: 1024,
height: 1024,
checksum
};
})) : [],
outputAssets,
modelVersion: 'gemini-2.5-flash-image-preview',
timestamp: Date.now(),
uploadResults: uploadResults,
usageMetadata: usageMetadata // 保存usageMetadata到历史记录
};
addGeneration(generation);
setCanvasImage(outputAssets[0].url);
}
setIsGenerating(false);
},
onError: error => {
console.error('生成失败:', error)
const errorMessage = error instanceof Error ? error.message : '未知错误'
const errorDetails = error instanceof Error ? error.stack : undefined
addToast(`图像生成失败: ${errorMessage}`, 'error', 5000, errorDetails)
setIsGenerating(false)
},
})
const cancelGeneration = () => {
isCancelledRef.current = true
setIsGenerating(false)
addToast('生成已中断', 'info', 3000)
}
return {
generate: generateMutation.mutate,
isGenerating: generateMutation.isPending,
error: generateMutation.error,
cancelGeneration,
}
}
export const useImageEditing = () => {
const { addEdit, setIsGenerating, setCanvasImage, canvasImage, editReferenceImages, brushStrokes, selectedGenerationId, seed, temperature, uploadedImages } = useAppStore()
const { addToast } = useToast()
// 创建中断标志引用
const isCancelledRef = React.useRef(false)
const editMutation = useMutation({
mutationFn: async (instruction: string) => {
// 重置中断标志
isCancelledRef.current = false
// 如果可用,始终使用画布图像作为主要目标,否则使用第一张上传的图像
const sourceImage = canvasImage || uploadedImages[0]
if (!sourceImage) throw new Error('没有要编辑的图像')
// 将画布图像转换为Blob
let originalImageBlob: Blob;
if (sourceImage.startsWith('blob:')) {
// 从Blob URL获取Blob数据
const blob = useAppStore.getState().getBlob(sourceImage);
if (!blob) throw new Error('无法从Blob URL获取图像数据');
originalImageBlob = blob;
} else if (sourceImage.includes('base64,')) {
// 从base64数据创建Blob
const base64 = sourceImage.split('base64,')[1];
const byteString = atob(base64);
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);
}
originalImageBlob = new Blob([ab], { type: mimeString });
} else {
// 从URL获取Blob
const response = await fetch(sourceImage);
originalImageBlob = await response.blob();
}
// 获取用于样式指导的参考图像
let referenceImageBlobs: Blob[] = [];
for (const img of editReferenceImages) {
if (img.startsWith('blob:')) {
// 从Blob URL获取Blob数据
const blob = useAppStore.getState().getBlob(img);
if (blob) {
referenceImageBlobs.push(blob);
}
} else if (img.includes('base64,')) {
// 从base64数据创建Blob
const base64 = img.split('base64,')[1];
const byteString = atob(base64);
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);
}
referenceImageBlobs.push(new Blob([ab], { type: mimeString }));
} else {
// 从URL获取Blob
try {
const response = await fetch(img);
const blob = await response.blob();
referenceImageBlobs.push(blob);
} catch (error) {
console.warn('无法获取参考图像:', img, error);
}
}
}
let maskImageBlob: Blob | undefined;
let maskedReferenceImage: string | undefined;
// 如果存在画笔描边,则从描边创建遮罩
if (brushStrokes.length > 0) {
// 创建临时图像以获取实际尺寸
const tempImg = new Image()
tempImg.src = sourceImage
await new Promise<void>(resolve => {
tempImg.onload = () => resolve()
})
// 创建具有确切图像尺寸的遮罩画布
const canvas = document.createElement('canvas')
const ctx = canvas.getContext('2d')!
canvas.width = tempImg.width
canvas.height = tempImg.height
// 用黑色填充(未遮罩区域)
ctx.fillStyle = 'black'
ctx.fillRect(0, 0, canvas.width, canvas.height)
// 绘制白色描边(遮罩区域)
ctx.strokeStyle = 'white'
ctx.lineCap = 'round'
ctx.lineJoin = 'round'
brushStrokes.forEach(stroke => {
if (stroke.points.length >= 4) {
ctx.lineWidth = stroke.brushSize
ctx.beginPath()
ctx.moveTo(stroke.points[0], stroke.points[1])
for (let i = 2; i < stroke.points.length; i += 2) {
ctx.lineTo(stroke.points[i], stroke.points[i + 1])
}
ctx.stroke()
}
})
// 将遮罩转换为Blob
maskImageBlob = await new Promise<Blob>((resolve, reject) => {
canvas.toBlob((blob) => {
if (blob) {
resolve(blob);
} else {
reject(new Error('无法创建遮罩图像Blob'));
}
}, 'image/png');
});
// 创建遮罩参考图像(带遮罩叠加的原始图像)
const maskedCanvas = document.createElement('canvas')
const maskedCtx = maskedCanvas.getContext('2d')!
maskedCanvas.width = tempImg.width
maskedCanvas.height = tempImg.height
// 绘制原始图像
maskedCtx.drawImage(tempImg, 0, 0)
// 绘制带透明度的遮罩叠加
maskedCtx.globalCompositeOperation = 'source-over'
maskedCtx.globalAlpha = 0.4
maskedCtx.fillStyle = '#A855F7'
brushStrokes.forEach(stroke => {
if (stroke.points.length >= 4) {
maskedCtx.lineWidth = stroke.brushSize
maskedCtx.strokeStyle = '#A855F7'
maskedCtx.lineCap = 'round'
maskedCtx.lineJoin = 'round'
maskedCtx.beginPath()
maskedCtx.moveTo(stroke.points[0], stroke.points[1])
for (let i = 2; i < stroke.points.length; i += 2) {
maskedCtx.lineTo(stroke.points[i], stroke.points[i + 1])
}
maskedCtx.stroke()
}
})
maskedCtx.globalAlpha = 1
maskedCtx.globalCompositeOperation = 'source-over'
// 将遮罩参考图像转换为base64用于后续处理
const maskedDataUrl = maskedCanvas.toDataURL('image/png')
maskedReferenceImage = maskedDataUrl.split('base64,')[1]
// 将遮罩图像作为参考添加到模型中
referenceImageBlobs = [maskImageBlob, ...referenceImageBlobs];
}
const request: EditRequest = {
instruction,
originalImage: originalImageBlob,
referenceImages: referenceImageBlobs.length > 0 ? referenceImageBlobs : undefined,
maskImage: maskImageBlob,
temperature,
seed,
}
const result = await geminiService.editImage(request)
// 检查是否已中断
if (isCancelledRef.current) {
throw new Error('编辑已中断')
}
return { result, maskedReferenceImage }
},
onMutate: () => {
setIsGenerating(true)
},
onSuccess: async ({ result, maskedReferenceImage }, instruction) => {
const { images, usageMetadata } = result;
if (images.length > 0) {
// 直接使用Blob并创建URL避免存储base64数据
const outputAssets: Asset[] = await Promise.all(images.map(async (blob, index) => {
// 使用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 (error) {
resolve(generateId().slice(0, 32));
}
});
return {
id: generateId(),
type: 'output',
url: blobUrl, // 存储Blob URL而不是base64
mime: 'image/png',
width: 1024,
height: 1024,
checksum
};
}));
// 如果有遮罩参考图像则创建遮罩参考资产
const maskReferenceAsset: Asset | undefined = maskedReferenceImage ? await (async () => {
// 将base64转换为Blob
const byteString = atob(maskedReferenceImage);
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);
}
const blob = new Blob([ab], { type: mimeString });
// 使用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 (error) {
resolve(generateId().slice(0, 32));
}
});
return {
id: generateId(),
type: 'mask',
url: blobUrl, // 存储Blob URL而不是base64
mime: 'image/png',
width: 1024,
height: 1024,
checksum
};
})() : undefined;
// 获取accessToken
const accessToken = import.meta.env.VITE_ACCESS_TOKEN || '';
let uploadResults: any[] | undefined;
// 上传编辑后的图像
if (accessToken) {
try {
const imageUrls = outputAssets.map(asset => asset.url);
// 上传编辑后的图像(跳过缓存,因为这些是新生成的图像)
uploadResults = await uploadImages(imageUrls, accessToken, true);
// 检查上传结果
const failedUploads = uploadResults.filter(r => !r.success);
if (failedUploads.length > 0) {
console.warn(`${failedUploads.length}张编辑后的图像上传失败`);
addToast(`${failedUploads.length}张编辑后的图像上传失败`, 'warning', 5000);
} else {
console.log(`${uploadResults.length}张编辑后的图像全部上传成功`);
addToast('编辑后的图像已成功上传', 'success', 3000);
}
} catch (error) {
console.error('上传编辑后的图像时出错:', error);
addToast('编辑后的图像上传失败', 'error', 5000);
uploadResults = undefined;
}
} else {
console.warn('未找到accessToken跳过上传');
}
// 显示Token消耗信息如果可用
if (usageMetadata?.totalTokenCount) {
addToast(`本次编辑消耗 ${usageMetadata.totalTokenCount} Tokens`, 'info', 3000);
}
const edit: Edit = {
id: generateId(),
parentGenerationId: selectedGenerationId || '',
maskAssetId: brushStrokes.length > 0 ? generateId() : undefined,
maskReferenceAsset,
instruction,
outputAssets,
timestamp: Date.now(),
uploadResults: uploadResults,
parameters: {
seed: seed || undefined,
temperature: temperature
},
usageMetadata: usageMetadata // 保存usageMetadata到历史记录
};
addEdit(edit);
// 自动在画布中加载编辑后的图像
const { selectEdit, selectGeneration } = useAppStore.getState();
setCanvasImage(outputAssets[0].url);
selectEdit(edit.id);
selectGeneration(null);
}
setIsGenerating(false);
},
onError: error => {
console.error('编辑失败:', error)
const errorMessage = error instanceof Error ? error.message : '未知错误'
const errorDetails = error instanceof Error ? error.stack : undefined
addToast(`图像编辑失败: ${errorMessage}`, 'error', 5000, errorDetails)
setIsGenerating(false)
},
})
const cancelEdit = () => {
isCancelledRef.current = true
setIsGenerating(false)
addToast('编辑已中断', 'info', 3000)
}
return {
edit: editMutation.mutate,
isEditing: editMutation.isPending,
error: editMutation.error,
cancelEdit,
}
}

View File

@@ -0,0 +1,83 @@
import { useEffect, useRef, useState } from 'react';
import * as indexedDBService from '../services/indexedDBService';
export const useIndexedDBListener = () => {
const [generations, setGenerations] = useState<any[]>([]);
const [edits, setEdits] = useState<any[]>([]);
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
const intervalRef = useRef<NodeJS.Timeout | null>(null);
const isMountedRef = useRef(true);
const loadRecords = async () => {
if (!isMountedRef.current) return;
try {
setLoading(true);
const allGenerations = await indexedDBService.getAllGenerations();
const allEdits = await indexedDBService.getAllEdits();
if (isMountedRef.current) {
setGenerations(allGenerations);
setEdits(allEdits);
setError(null);
}
} catch (err) {
console.error('从IndexedDB加载记录失败:', err);
if (isMountedRef.current) {
setError('加载历史记录失败');
}
} finally {
if (isMountedRef.current) {
setLoading(false);
}
}
};
useEffect(() => {
// 标记组件已挂载
isMountedRef.current = true;
// 初始化数据库并加载记录
const initAndLoad = async () => {
try {
await indexedDBService.initDB();
if (isMountedRef.current) {
await loadRecords();
}
} catch (err) {
console.error('初始化IndexedDB失败:', err);
if (isMountedRef.current) {
setError('初始化数据库失败');
setLoading(false);
}
}
};
initAndLoad();
// 设置定时器定期检查新记录
intervalRef.current = setInterval(() => {
if (isMountedRef.current) {
loadRecords();
}
}, 3000); // 每3秒检查一次
// 清理函数
return () => {
// 标记组件已卸载
isMountedRef.current = false;
// 清除定时器
if (intervalRef.current) {
clearInterval(intervalRef.current);
intervalRef.current = null;
}
};
}, []);
const refresh = () => {
loadRecords();
};
return { generations, edits, loading, error, refresh };
};

View File

@@ -0,0 +1,125 @@
import { useEffect } from 'react';
import { useAppStore } from '../store/useAppStore';
import { useImageGeneration, useImageEditing } from '../hooks/useImageGeneration';
export const useKeyboardShortcuts = () => {
const {
setSelectedTool,
setShowHistory,
showHistory,
setShowPromptPanel,
showPromptPanel,
currentPrompt,
isGenerating,
selectedTool,
editReferenceImages,
canvasImage,
setCanvasImage,
temperature,
seed,
uploadedImages: generateUploadedImages
} = useAppStore();
const { generate } = useImageGeneration();
const { edit } = useImageEditing();
useEffect(() => {
const handleKeyDown = (event: KeyboardEvent) => {
// Ignore if user is typing in an input
if (event.target instanceof HTMLInputElement ||
event.target instanceof HTMLTextAreaElement) {
// Only handle Cmd/Ctrl + Enter for generation
if ((event.metaKey || event.ctrlKey) && event.key === 'Enter') {
event.preventDefault();
if (!isGenerating && currentPrompt.trim()) {
// 触发生成操作
if (selectedTool === 'generate') {
const referenceImages = generateUploadedImages
.filter(img => img.includes('base64,'))
.map(img => img.split('base64,')[1]);
generate({
prompt: currentPrompt,
referenceImages: referenceImages.length > 0 ? referenceImages : undefined,
temperature,
seed: seed !== null ? seed : undefined
});
} else if (selectedTool === 'edit' || selectedTool === 'mask') {
edit(currentPrompt);
}
}
}
return;
}
switch (event.key.toLowerCase()) {
case 'e':
event.preventDefault();
setSelectedTool('edit');
break;
case 'g':
event.preventDefault();
setSelectedTool('generate');
break;
case 'm':
event.preventDefault();
setSelectedTool('mask');
break;
case 'h':
event.preventDefault();
setShowHistory(!showHistory);
break;
case 'p':
event.preventDefault();
setShowPromptPanel(!showPromptPanel);
break;
case 'r':
if (event.shiftKey) {
event.preventDefault();
console.log('Re-roll variants');
}
break;
case 'enter':
// 如果按Enter键且有提示词则触发生成
if (currentPrompt.trim() && !isGenerating) {
event.preventDefault();
if (selectedTool === 'generate') {
const referenceImages = generateUploadedImages
.filter(img => img.includes('base64,'))
.map(img => img.split('base64,')[1]);
generate({
prompt: currentPrompt,
referenceImages: referenceImages.length > 0 ? referenceImages : undefined,
temperature,
seed: seed !== null ? seed : undefined
});
} else if (selectedTool === 'edit' || selectedTool === 'mask') {
edit(currentPrompt);
}
}
break;
}
};
window.addEventListener('keydown', handleKeyDown);
return () => window.removeEventListener('keydown', handleKeyDown);
}, [
setSelectedTool,
setShowHistory,
showHistory,
setShowPromptPanel,
showPromptPanel,
currentPrompt,
isGenerating,
selectedTool,
generateUploadedImages,
editReferenceImages,
canvasImage,
setCanvasImage,
temperature,
seed,
generate,
edit
]);
};

235
v1/src/index.css Normal file
View File

@@ -0,0 +1,235 @@
@import url('https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700&display=swap');
@tailwind base;
@tailwind components;
@tailwind utilities;
/* Toast animations */
@keyframes slide-in-from-top-full {
0% {
transform: translateY(-100%);
opacity: 0;
}
100% {
transform: translateY(0);
opacity: 1;
}
}
@keyframes slide-in-from-right {
0% {
transform: translateX(100%);
opacity: 0;
}
100% {
transform: translateX(0);
opacity: 1;
}
}
@keyframes slide-out-to-right {
0% {
transform: translateX(0);
opacity: 1;
}
100% {
transform: translateX(100%);
opacity: 0;
}
}
@keyframes slide-in-from-left {
0% {
transform: translateX(-100%);
opacity: 0;
}
100% {
transform: translateX(0);
opacity: 1;
}
}
@keyframes fade-in {
0% {
opacity: 0;
}
100% {
opacity: 1;
}
}
@keyframes scale-in {
0% {
transform: scale(0.95);
opacity: 0;
}
100% {
transform: scale(1);
opacity: 1;
}
}
@keyframes scale-out {
0% {
transform: scale(1);
opacity: 1;
}
100% {
transform: scale(0.95);
opacity: 0;
}
}
@keyframes slide-down {
0% {
transform: translateY(-10px);
opacity: 0;
}
100% {
transform: translateY(0);
opacity: 1;
}
}
@keyframes pulse-click {
0% {
transform: scale(1);
}
50% {
transform: scale(0.95);
}
100% {
transform: scale(1);
}
}
.animate-in {
animation-duration: 300ms;
animation-fill-mode: both;
}
.slide-in-from-top-full {
animation-name: slide-in-from-top-full;
}
.slide-in-from-right {
animation-name: slide-in-from-right;
}
.slide-out-to-right {
animation-name: slide-out-to-right;
}
.slide-in-from-left {
animation-name: slide-in-from-left;
}
.fade-in {
animation-name: fade-in;
}
.scale-in {
animation-name: scale-in;
}
.scale-out {
animation-name: scale-out;
}
* {
box-sizing: border-box;
}
body {
font-family: 'Inter', sans-serif;
font-feature-settings: 'cv02', 'cv03', 'cv04', 'cv11';
overflow: hidden;
background-color: #FFFFFF;
color: #212529;
}
/* Custom scrollbar for light theme */
::-webkit-scrollbar {
width: 6px;
height: 6px;
}
::-webkit-scrollbar-track {
background: #F8F9FA;
}
::-webkit-scrollbar-thumb {
background: #E9ECEF;
border-radius: 3px;
}
::-webkit-scrollbar-thumb:hover {
background: #DEE2E6;
}
/* Custom range slider styling */
.slider::-webkit-slider-thumb {
appearance: none;
height: 16px;
width: 16px;
border-radius: 50%;
background: #FDE047;
cursor: pointer;
border: 2px solid #FFFFFF;
}
.slider::-moz-range-thumb {
height: 16px;
width: 16px;
border-radius: 50%;
background: #FDE047;
cursor: pointer;
border: 2px solid #FFFFFF;
}
/* Marching ants animation */
@keyframes marching-ants {
0% { stroke-dashoffset: 0; }
100% { stroke-dashoffset: 10; }
}
.marching-ants {
stroke-dasharray: 5 5;
animation: marching-ants 0.5s linear infinite;
}
/* Focus styles for accessibility */
*:focus-visible {
outline: 2px solid #FDE047;
outline-offset: 2px;
}
/* Smooth transitions */
* {
transition-property: color, background-color, border-color, text-decoration-color, fill, stroke, opacity, box-shadow, transform, filter, backdrop-filter;
transition-duration: 200ms;
transition-timing-function: cubic-bezier(0.4, 0, 0.2, 1);
}
/* Animation classes */
.slide-down {
animation-name: slide-down;
}
.pulse-click {
animation-name: pulse-click;
}
/* Card styles */
.card {
@apply bg-white rounded-xl shadow-card border border-gray-100 overflow-hidden;
backdrop-filter: blur(10px);
-webkit-backdrop-filter: blur(10px);
}
.card-hover {
@apply shadow-card-hover;
}
.card-lg {
@apply shadow-card-lg;
}

10
v1/src/main.tsx Normal file
View File

@@ -0,0 +1,10 @@
import { StrictMode } from 'react';
import { createRoot } from 'react-dom/client';
import App from './App.tsx';
import './index.css';
createRoot(document.getElementById('root')!).render(
<StrictMode>
<App />
</StrictMode>
);

View File

@@ -0,0 +1,119 @@
import { get, set, del, keys } from 'idb-keyval';
import { Project, Generation, Asset } from '../types';
const CACHE_PREFIX = 'nano-banana';
const CACHE_VERSION = '1.0';
// 限制缓存项目数量
const MAX_CACHED_ITEMS = 50;
// 限制缓存最大年龄 (3天)
const MAX_CACHE_AGE = 3 * 24 * 60 * 60 * 1000;
export class CacheService {
private static getKey(type: string, id: string): string {
return `${CACHE_PREFIX}-${CACHE_VERSION}-${type}-${id}`;
}
// Project caching
static async saveProject(project: Project): Promise<void> {
// 在保存新项目之前,清理旧缓存
await this.clearOldCache();
await set(this.getKey('project', project.id), project);
}
static async getProject(id: string): Promise<Project | null> {
return (await get(this.getKey('project', id))) || null;
}
static async getAllProjects(): Promise<Project[]> {
const allKeys = await keys();
const projectKeys = allKeys.filter(key =>
typeof key === 'string' && key.includes(`${CACHE_PREFIX}-${CACHE_VERSION}-project-`)
);
const projects = await Promise.all(
projectKeys.map(key => get(key as string))
);
return projects.filter(Boolean) as Project[];
}
// Asset caching (for offline access)
static async cacheAsset(asset: Asset, data: Blob): Promise<void> {
// 在保存新资产之前,清理旧缓存
await this.clearOldCache();
await set(this.getKey('asset', asset.id), {
asset,
data,
cachedAt: Date.now()
});
}
static async getCachedAsset(assetId: string): Promise<{ asset: Asset; data: Blob } | null> {
const cached = await get(this.getKey('asset', assetId));
return cached || null;
}
// Generation metadata caching
static async cacheGeneration(generation: Generation): Promise<void> {
// 在保存新生成记录之前,清理旧缓存
await this.clearOldCache();
await set(this.getKey('generation', generation.id), generation);
}
static async getGeneration(id: string): Promise<Generation | null> {
return (await get(this.getKey('generation', id))) || null;
}
// Clear old cache entries
static async clearOldCache(maxAge: number = MAX_CACHE_AGE): Promise<void> {
const allKeys = await keys();
const now = Date.now();
// 收集需要删除的键
const keysToDelete: string[] = [];
const validCachedItems: Array<{key: string, cachedAt: number}> = [];
for (const key of allKeys) {
if (typeof key === 'string' && key.startsWith(CACHE_PREFIX)) {
const cached = await get(key);
if (cached?.cachedAt) {
// 检查是否过期
if ((now - cached.cachedAt) > maxAge) {
keysToDelete.push(key);
} else {
validCachedItems.push({key, cachedAt: cached.cachedAt});
}
}
}
}
// 如果有效项目数量超过限制,删除最旧的项目
if (validCachedItems.length > MAX_CACHED_ITEMS) {
// 按时间排序,最旧的在前面
validCachedItems.sort((a, b) => a.cachedAt - b.cachedAt);
// 计算需要删除的数量
const excessCount = validCachedItems.length - MAX_CACHED_ITEMS;
// 添加最旧的项目到删除列表
for (let i = 0; i < excessCount; i++) {
keysToDelete.push(validCachedItems[i].key);
}
}
// 执行删除
for (const key of keysToDelete) {
await del(key);
}
}
// 清空所有缓存
static async clearAllCache(): Promise<void> {
const allKeys = await keys();
const cacheKeys = allKeys.filter(key =>
typeof key === 'string' && key.startsWith(CACHE_PREFIX)
);
for (const key of cacheKeys) {
await del(key);
}
}
}

View File

@@ -0,0 +1,327 @@
import { GoogleGenAI } from '@google/genai'
// 注意:在生产环境中,这应该通过后端代理处理
const API_KEY = import.meta.env.VITE_GEMINI_API_KEY || 'demo-key'
const genAI = new GoogleGenAI({ apiKey: API_KEY })
export interface GenerationRequest {
prompt: string
referenceImages?: Blob[] // Blob数组
temperature?: number
seed?: number
}
export interface EditRequest {
instruction: string
originalImage: Blob // Blob
referenceImages?: Blob[] // Blob数组
maskImage?: Blob // Blob
temperature?: number
seed?: number
}
export interface UsageMetadata {
totalTokenCount?: number
promptTokenCount?: number
candidatesTokenCount?: number
}
export interface SegmentationRequest {
image: Blob // Blob
query: string // "像素(x,y)处的对象" 或 "红色汽车"
}
export class GeminiService {
// 将Blob转换为base64的辅助函数
private async blobToBase64(blob: Blob): Promise<string> {
return new Promise((resolve, reject) => {
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);
});
}
async generateImage(request: GenerationRequest): Promise<{ images: Blob[]; usageMetadata?: any }> {
try {
const contents: any[] = [{ 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))
);
base64Images.forEach(image => {
contents.push({
inlineData: {
mimeType: 'image/png',
data: image,
},
})
})
}
const response = await genAI.models.generateContent({
model: 'gemini-2.5-flash-image-preview',
contents,
})
// 检查是否有被禁止的内容
if (response.candidates && response.candidates.length > 0) {
const candidate = response.candidates[0]
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;
if (candidate.content && candidate.content.parts) {
for (const part of candidate.content.parts) {
if (part.inlineData) {
hasInlineData = true;
break;
}
}
}
// 如果没有inlineData则抛出错误
if (!hasInlineData && candidate.content && candidate.content.parts && candidate.content.parts.length > 0) {
throw new Error(candidate.content.parts[0].text || '生成失败:未返回图像数据');
}
}
}
const images: Blob[] = []
// 检查响应是否存在以及是否有内容
if (response.candidates && response.candidates.length > 0 && response.candidates[0].content && response.candidates[0].content.parts) {
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);
for (let i = 0; i < byteString.length; i++) {
ia[i] = byteString.charCodeAt(i);
}
const blob = new Blob([ab], { type: mimeString });
images.push(blob);
}
}
}
// 获取usageMetadata如果存在
const usageMetadata = response.usageMetadata
return { images, usageMetadata }
} catch (error) {
console.error('生成图像时出错:', error)
if (error instanceof Error && error.message) {
throw error
}
throw new Error(`生成图像失败: ${error instanceof Error ? error.message : '未知错误'}`)
}
}
async editImage(request: EditRequest): Promise<{ images: Blob[]; usageMetadata?: any }> {
try {
// 将Blob转换为base64以发送到API
const originalImageBase64 = await this.blobToBase64(request.originalImage);
const contents = [
{ text: this.buildEditPrompt(request) },
{
inlineData: {
mimeType: 'image/png',
data: originalImageBase64,
},
},
]
// 如果提供了参考图像则添加
if (request.referenceImages && request.referenceImages.length > 0) {
// 将Blob转换为base64以发送到API
const base64ReferenceImages = await Promise.all(
request.referenceImages.map(blob => this.blobToBase64(blob))
);
base64ReferenceImages.forEach(image => {
contents.push({
inlineData: {
mimeType: 'image/png',
data: image,
},
})
})
}
if (request.maskImage) {
// 将Blob转换为base64以发送到API
const maskImageBase64 = await this.blobToBase64(request.maskImage);
contents.push({
inlineData: {
mimeType: 'image/png',
data: maskImageBase64,
},
})
}
const response = await genAI.models.generateContent({
model: 'gemini-2.5-flash-image-preview',
contents,
})
// 检查是否有被禁止的内容
if (response.candidates && response.candidates.length > 0) {
const candidate = response.candidates[0]
if (candidate.finishReason === 'PROHIBITED_CONTENT') {
throw new Error('内容被禁止:您的请求包含不允许的内容。请尝试其他提示。')
}
// 检查finishReason为STOP但没有inlineData的情况
if (candidate.finishReason === 'STOP') {
// 检查是否有inlineData
let hasInlineData = false;
if (candidate.content && candidate.content.parts) {
for (const part of candidate.content.parts) {
if (part.inlineData) {
hasInlineData = true;
break;
}
}
}
// 如果没有inlineData则抛出错误
if (!hasInlineData && candidate.content && candidate.content.parts && candidate.content.parts.length > 0) {
throw new Error(candidate.content.parts[0].text || '编辑失败:未返回图像数据');
}
}
}
const images: Blob[] = []
// 检查响应是否存在以及是否有内容
if (response.candidates && response.candidates.length > 0 && response.candidates[0].content && response.candidates[0].content.parts) {
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);
for (let i = 0; i < byteString.length; i++) {
ia[i] = byteString.charCodeAt(i);
}
const blob = new Blob([ab], { type: mimeString });
images.push(blob);
}
}
}
// 获取usageMetadata如果存在
const usageMetadata = response.usageMetadata
return { images, usageMetadata }
} catch (error) {
console.error('编辑图像时出错:', error)
if (error instanceof Error && error.message) {
throw error
}
throw new Error(`编辑图像失败: ${error instanceof Error ? error.message : '未知错误'}`)
}
}
async segmentImage(request: SegmentationRequest): Promise<any> {
try {
// 将Blob转换为base64以发送到API
const imageBase64 = await this.blobToBase64(request.image);
const prompt = [
{
text: `分析此图像并为以下对象创建分割遮罩: ${request.query}
返回具有此确切结构的JSON对象:
{
"masks": [
{
"label": "分割对象的描述",
"box_2d": [x, y, width, height],
"mask": "base64编码的二进制遮罩图像"
}
]
}
仅分割请求的特定对象或区域。遮罩应该是二进制PNG其中白色像素(255)表示选定区域,黑色像素(0)表示背景。`,
},
{
inlineData: {
mimeType: 'image/png',
data: imageBase64,
},
},
]
const response = await genAI.models.generateContent({
model: 'gemini-2.5-flash-image-preview',
contents: prompt,
})
// 检查是否有被禁止的内容
if (response.candidates && response.candidates.length > 0) {
const candidate = response.candidates[0]
if (candidate.finishReason === 'PROHIBITED_CONTENT') {
throw new Error('内容被禁止:您的请求包含不允许的内容。请尝试其他提示。')
}
// 检查finishReason为STOP但没有inlineData的情况
if (candidate.finishReason === 'STOP') {
// 检查是否有inlineData
let hasInlineData = false;
if (candidate.content && candidate.content.parts) {
for (const part of candidate.content.parts) {
if (part.inlineData) {
hasInlineData = true;
break;
}
}
}
// 如果没有inlineData则抛出错误
if (!hasInlineData && candidate.content && candidate.content.parts && candidate.content.parts.length > 0) {
throw new Error(candidate.content.parts[0].text || '分割失败:未返回结果数据');
}
}
}
const responseText = response.candidates[0].content.parts[0].text
return JSON.parse(responseText)
} catch (error) {
console.error('分割图像时出错:', error)
if (error instanceof Error && error.message) {
throw error
}
throw new Error(`分割图像失败: ${error instanceof Error ? error.message : '未知错误'}`)
}
}
private buildEditPrompt(request: EditRequest): string {
const maskInstruction = request.maskImage ? '\n\n重要: 仅在遮罩图像显示白色像素(值255)的地方应用更改。完全不更改所有其他区域。精确遵守遮罩边界并在边缘保持无缝混合。' : ''
return `根据以下指令编辑此图像: ${request.instruction}
保持原始图像的光照、透视和整体构图。使更改看起来自然且无缝集成。${maskInstruction}
保持图像质量并确保编辑看起来专业且逼真。`
}
}
export const geminiService = new GeminiService()

View File

@@ -0,0 +1,447 @@
import { Generation, Edit } from '../types';
// 数据库配置
const DB_NAME = 'NanoBananaDB';
const DB_VERSION = 1;
const GENERATIONS_STORE = 'generations';
const EDITS_STORE = 'edits';
// 重试配置
const MAX_RETRIES = 3;
const RETRY_DELAY = 1000;
// IndexedDB实例
let db: IDBDatabase | null = null;
/**
* 初始化数据库
*/
export const initDB = (): Promise<void> => {
return new Promise((resolve, reject) => {
const request = indexedDB.open(DB_NAME, DB_VERSION);
request.onerror = () => {
console.error('数据库打开失败:', request.error);
reject(request.error);
};
request.onsuccess = () => {
db = request.result;
resolve();
};
request.onupgradeneeded = (event) => {
const db = (event.target as IDBOpenDBRequest).result;
// 创建生成记录存储
if (!db.objectStoreNames.contains(GENERATIONS_STORE)) {
const genStore = db.createObjectStore(GENERATIONS_STORE, { keyPath: 'id' });
genStore.createIndex('timestamp', 'timestamp', { unique: false });
}
// 创建编辑记录存储
if (!db.objectStoreNames.contains(EDITS_STORE)) {
const editStore = db.createObjectStore(EDITS_STORE, { keyPath: 'id' });
editStore.createIndex('timestamp', 'timestamp', { unique: false });
editStore.createIndex('parentGenerationId', 'parentGenerationId', { unique: false });
}
};
});
};
/**
* 获取数据库实例
*/
const getDB = (): IDBDatabase => {
if (!db) {
throw new Error('数据库未初始化');
}
return db;
};
/**
* 添加生成记录
*/
export const addGeneration = async (generation: Generation): Promise<void> => {
// 创建轻量级生成记录只存储必要的信息和上传后的URL
const lightweightGeneration = {
id: generation.id,
prompt: generation.prompt,
parameters: generation.parameters,
modelVersion: generation.modelVersion,
timestamp: generation.timestamp,
uploadResults: generation.uploadResults,
usageMetadata: generation.usageMetadata,
// 只存储上传后的URL不存储base64数据
sourceAssets: generation.sourceAssets.map(asset => {
const uploadedUrl = getUploadedAssetUrl(generation, asset.id, 'source');
return {
id: asset.id,
type: asset.type,
// 如果没有上传后的URL则不存储URL以避免base64数据
url: uploadedUrl || '',
mime: asset.mime,
width: asset.width,
height: asset.height,
checksum: asset.checksum
};
}),
outputAssets: generation.outputAssets.map(asset => {
const uploadedUrl = getUploadedAssetUrl(generation, asset.id, 'output');
return {
id: asset.id,
type: asset.type,
// 如果没有上传后的URL则不存储URL以避免base64数据
url: uploadedUrl || '',
mime: asset.mime,
width: asset.width,
height: asset.height,
checksum: asset.checksum
};
})
};
const db = getDB();
const transaction = db.transaction([GENERATIONS_STORE], 'readwrite');
const store = transaction.objectStore(GENERATIONS_STORE);
return new Promise((resolve, reject) => {
const request = store.add(lightweightGeneration);
request.onsuccess = () => resolve();
request.onerror = () => reject(request.error);
});
};
/**
* 添加编辑记录
*/
export const addEdit = async (edit: Edit): Promise<void> => {
// 创建轻量级编辑记录只存储必要的信息和上传后的URL
const lightweightEdit = {
id: edit.id,
parentGenerationId: edit.parentGenerationId,
maskAssetId: edit.maskAssetId,
instruction: edit.instruction,
timestamp: edit.timestamp,
uploadResults: edit.uploadResults,
parameters: edit.parameters,
usageMetadata: edit.usageMetadata,
// 只存储上传后的URL不存储base64数据
maskReferenceAsset: edit.maskReferenceAsset ? (() => {
const uploadedUrl = getUploadedAssetUrl(edit, edit.maskReferenceAsset.id, 'mask');
return {
id: edit.maskReferenceAsset.id,
type: edit.maskReferenceAsset.type,
// 如果没有上传后的URL则不存储URL以避免base64数据
url: uploadedUrl || '',
mime: edit.maskReferenceAsset.mime,
width: edit.maskReferenceAsset.width,
height: edit.maskReferenceAsset.height,
checksum: edit.maskReferenceAsset.checksum
};
})() : undefined,
outputAssets: edit.outputAssets.map(asset => {
const uploadedUrl = getUploadedAssetUrl(edit, asset.id, 'output');
return {
id: asset.id,
type: asset.type,
// 如果没有上传后的URL则不存储URL以避免base64数据
url: uploadedUrl || '',
mime: asset.mime,
width: asset.width,
height: asset.height,
checksum: asset.checksum
};
})
};
const db = getDB();
const transaction = db.transaction([EDITS_STORE], 'readwrite');
const store = transaction.objectStore(EDITS_STORE);
return new Promise((resolve, reject) => {
const request = store.add(lightweightEdit);
request.onsuccess = () => resolve();
request.onerror = () => reject(request.error);
});
};
/**
* 从uploadResults中获取资产的上传后URL
* 注意:这个函数需要根据资产在数组中的位置来匹配上传结果
* - 输出资产的索引与uploadResults中的索引相对应
* - 源资产参考图像的索引从outputAssets.length开始
*/
const getUploadedAssetUrl = (record: Generation | Edit, assetId: string, assetType: 'output' | 'source' | 'mask'): string | null => {
if (!record.uploadResults || record.uploadResults.length === 0) {
return null;
}
let assetIndex = -1;
// 根据资产类型确定在uploadResults中的索引
if (assetType === 'output') {
// 输出资产的索引与在outputAssets数组中的索引相同
assetIndex = record.outputAssets.findIndex(a => a.id === assetId);
} else if (assetType === 'source') {
// 源资产参考图像的索引从outputAssets.length开始
assetIndex = record.sourceAssets?.findIndex(a => a.id === assetId);
if (assetIndex >= 0) {
assetIndex += record.outputAssets.length;
}
} else if (assetType === 'mask') {
// 遮罩参考资产通常是第一个输出资产之后的第一个源资产
assetIndex = record.outputAssets.length;
}
// 检查索引是否有效并且对应的上传结果是否存在且成功
if (assetIndex >= 0 && assetIndex < record.uploadResults.length) {
const uploadResult = record.uploadResults[assetIndex];
if (uploadResult.success && uploadResult.url) {
return uploadResult.url;
}
}
return null;
};
/**
* 获取所有生成记录(按时间倒序)
*/
export const getAllGenerations = async (): Promise<Generation[]> => {
const db = getDB();
const transaction = db.transaction([GENERATIONS_STORE], 'readonly');
const store = transaction.objectStore(GENERATIONS_STORE);
const index = store.index('timestamp');
return new Promise((resolve, reject) => {
const request = index.getAll();
request.onsuccess = () => {
// 按时间倒序排列
const generations = request.result.sort((a, b) => b.timestamp - a.timestamp);
resolve(generations);
};
request.onerror = () => reject(request.error);
});
};
/**
* 获取所有编辑记录(按时间倒序)
*/
export const getAllEdits = async (): Promise<Edit[]> => {
const db = getDB();
const transaction = db.transaction([EDITS_STORE], 'readonly');
const store = transaction.objectStore(EDITS_STORE);
const index = store.index('timestamp');
return new Promise((resolve, reject) => {
const request = index.getAll();
request.onsuccess = () => {
// 按时间倒序排列
const edits = request.result.sort((a, b) => b.timestamp - a.timestamp);
resolve(edits);
};
request.onerror = () => reject(request.error);
});
};
/**
* 根据父生成ID获取编辑记录
*/
export const getEditsByParentGenerationId = async (parentGenerationId: string): Promise<Edit[]> => {
const db = getDB();
const transaction = db.transaction([EDITS_STORE], 'readonly');
const store = transaction.objectStore(EDITS_STORE);
const index = store.index('parentGenerationId');
return new Promise((resolve, reject) => {
const request = index.getAll(IDBKeyRange.only(parentGenerationId));
request.onsuccess = () => {
// 按时间倒序排列
const edits = request.result.sort((a, b) => b.timestamp - a.timestamp);
resolve(edits);
};
request.onerror = () => reject(request.error);
});
};
/**
* 删除最旧的记录以保持限制
*/
export const cleanupOldRecords = async (limit: number = 100): Promise<void> => {
const db = getDB();
// 清理生成记录
const genTransaction = db.transaction([GENERATIONS_STORE], 'readwrite');
const genStore = genTransaction.objectStore(GENERATIONS_STORE);
// 清理编辑记录
const editTransaction = db.transaction([EDITS_STORE], 'readwrite');
const editStore = editTransaction.objectStore(EDITS_STORE);
// 获取所有记录并按时间排序
const allGenerations = await getAllGenerations();
const allEdits = await getAllEdits();
// 计算需要删除的记录数量
if (allGenerations.length > limit) {
const toDelete = allGenerations.slice(limit);
for (const gen of toDelete) {
genStore.delete(gen.id);
}
}
if (allEdits.length > limit) {
const toDelete = allEdits.slice(limit);
for (const edit of toDelete) {
editStore.delete(edit.id);
}
}
};
/**
* 清理记录中的base64数据
*/
export const cleanupBase64Data = async (): Promise<void> => {
try {
// 获取所有生成记录
const generations = await getAllGenerations();
// 获取所有编辑记录
const edits = await getAllEdits();
const db = getDB();
// 更新生成记录
for (const generation of generations) {
// 检查是否有base64数据需要清理
let needsUpdate = false;
// 清理源资产中的base64数据
const cleanedSourceAssets = generation.sourceAssets.map((asset: any) => {
if (asset.url && asset.url.startsWith('data:')) {
needsUpdate = true;
return {
...asset,
url: '' // 移除base64数据
};
}
return asset;
});
// 清理输出资产中的base64数据
const cleanedOutputAssets = generation.outputAssets.map((asset: any) => {
if (asset.url && asset.url.startsWith('data:')) {
needsUpdate = true;
return {
...asset,
url: '' // 移除base64数据
};
}
return asset;
});
// 如果需要更新,则保存清理后的记录
if (needsUpdate) {
const cleanedGeneration = {
...generation,
sourceAssets: cleanedSourceAssets,
outputAssets: cleanedOutputAssets
};
const transaction = db.transaction([GENERATIONS_STORE], 'readwrite');
const store = transaction.objectStore(GENERATIONS_STORE);
await new Promise((resolve, reject) => {
const request = store.put(cleanedGeneration);
request.onsuccess = () => resolve(undefined);
request.onerror = () => reject(request.error);
});
}
}
// 更新编辑记录
for (const edit of edits) {
// 检查是否有base64数据需要清理
let needsUpdate = false;
// 清理遮罩参考资产中的base64数据
let cleanedMaskReferenceAsset = edit.maskReferenceAsset;
if (edit.maskReferenceAsset && edit.maskReferenceAsset.url && edit.maskReferenceAsset.url.startsWith('data:')) {
needsUpdate = true;
cleanedMaskReferenceAsset = {
...edit.maskReferenceAsset,
url: '' // 移除base64数据
};
}
// 清理输出资产中的base64数据
const cleanedOutputAssets = edit.outputAssets.map((asset: any) => {
if (asset.url && asset.url.startsWith('data:')) {
needsUpdate = true;
return {
...asset,
url: '' // 移除base64数据
};
}
return asset;
});
// 如果需要更新,则保存清理后的记录
if (needsUpdate) {
const cleanedEdit = {
...edit,
maskReferenceAsset: cleanedMaskReferenceAsset,
outputAssets: cleanedOutputAssets
};
const transaction = db.transaction([EDITS_STORE], 'readwrite');
const store = transaction.objectStore(EDITS_STORE);
await new Promise((resolve, reject) => {
const request = store.put(cleanedEdit);
request.onsuccess = () => resolve(undefined);
request.onerror = () => reject(request.error);
});
}
}
console.log('IndexedDB中的base64数据清理完成');
} catch (error) {
console.error('清理IndexedDB中的base64数据时出错:', error);
}
};
/**
* 清空所有记录
*/
export const clearAllRecords = async (): Promise<void> => {
const db = getDB();
const genTransaction = db.transaction([GENERATIONS_STORE], 'readwrite');
const genStore = genTransaction.objectStore(GENERATIONS_STORE);
const editTransaction = db.transaction([EDITS_STORE], 'readwrite');
const editStore = editTransaction.objectStore(EDITS_STORE);
return Promise.all([
new Promise<void>((resolve, reject) => {
const request = genStore.clear();
request.onsuccess = () => resolve();
request.onerror = () => reject(request.error);
}),
new Promise<void>((resolve, reject) => {
const request = editStore.clear();
request.onsuccess = () => resolve();
request.onerror = () => reject(request.error);
})
]).then(() => undefined);
};
/**
* 关闭数据库连接
*/
export const closeDB = (): void => {
if (db) {
db.close();
db = null;
}
};

View File

@@ -0,0 +1,290 @@
// src/services/uploadService.ts
import { UploadResult } from '../types'
// 上传接口URL
const UPLOAD_URL = import.meta.env.VITE_UPLOAD_API
// 创建一个Map来缓存已上传的图像
const uploadCache = new Map<string, UploadResult>()
// 缓存配置
const MAX_CACHE_SIZE = 20 // 减少最大缓存条目数
const CACHE_EXPIRY_TIME = 15 * 60 * 1000 // 缓存过期时间15分钟
/**
* 清理过期的缓存条目
*/
function cleanupExpiredCache(): void {
const now = Date.now()
let deletedCount = 0
uploadCache.forEach((value, key) => {
if (now - value.timestamp > CACHE_EXPIRY_TIME) {
uploadCache.delete(key)
deletedCount++
}
})
if (deletedCount > 0) {
console.log(`清除了 ${deletedCount} 个过期的缓存条目`)
}
}
/**
* 检查并维护缓存大小
*/
function maintainCacheSize(): void {
// 如果缓存大小超过限制,删除最旧的条目
if (uploadCache.size >= MAX_CACHE_SIZE) {
// 获取所有条目并按时间排序
const entries = Array.from(uploadCache.entries())
entries.sort((a, b) => a[1].timestamp - b[1].timestamp)
// 删除最旧的条目,直到缓存大小在限制内
const deleteCount = Math.max(1, Math.floor(MAX_CACHE_SIZE * 0.2)) // 删除20%的条目
for (let i = 0; i < deleteCount && uploadCache.size >= MAX_CACHE_SIZE; i++) {
uploadCache.delete(entries[i][0])
}
console.log(`缓存已满,删除了 ${deleteCount} 个最旧的条目`)
}
}
/**
* 生成图像的唯一标识符
* @param imageData - 图像数据可以是base64或Blob URL
* @returns 图像的唯一标识符
*/
function getImageHash(imageData: string): string {
// 对于Blob URL我们需要获取实际的数据来生成哈希
if (imageData.startsWith('blob:')) {
// 对于Blob URL我们使用URL本身作为标识符的一部分
// 这不是完美的解决方案,但对于大多数情况足够了
try {
return btoa(imageData).slice(0, 32)
} catch (e) {
// 如果btoa失败例如包含非Latin1字符使用encodeURIComponent
return btoa(encodeURIComponent(imageData)).slice(0, 32)
}
}
// 对于base64数据使用简单的哈希函数生成图像标识符
let hash = 0
for (let i = 0; i < imageData.length; i++) {
const char = imageData.charCodeAt(i)
hash = (hash << 5) - hash + char
hash = hash & hash // 转换为32位整数
}
return hash.toString()
}
/**
* 从Blob URL获取Blob数据
* @param blobUrl - Blob URL
* @returns Blob对象
*/
async function getBlobFromUrl(blobUrl: string): Promise<Blob> {
try {
// 从AppStore获取Blob
const { useAppStore } = await import('../store/useAppStore')
const blob = useAppStore.getState().getBlob(blobUrl)
if (!blob) {
throw new Error('无法从AppStore获取BlobBlob可能已被清理');
}
return blob;
} catch (error) {
console.error('从AppStore获取Blob时出错:', error);
throw new Error('无法从Blob URL获取图像数据');
}
}
/**
* 将图像数据上传到指定接口
* @param imageData - 图像数据可以是base64、Blob URL或Blob对象
* @param accessToken - 访问令牌
* @param skipCache - 是否跳过缓存检查
* @returns 上传结果
*/
export const uploadImage = async (imageData: string | Blob, accessToken: string, skipCache: boolean = false): Promise<{ success: boolean; url?: string; error?: string }> => {
// 检查缓存中是否已有该图像的上传结果
const imageHash = typeof imageData === 'string' ? getImageHash(imageData) : 'blob-' + Date.now()
if (!skipCache && typeof imageData === 'string' && uploadCache.has(imageHash)) {
const cachedResult = uploadCache.get(imageHash)!
// 检查缓存是否过期
if (Date.now() - cachedResult.timestamp < CACHE_EXPIRY_TIME) {
console.log('从缓存中获取上传结果')
// 确保返回的数据结构与新上传的结果一致
return {
success: cachedResult.success,
url: cachedResult.url,
error: cachedResult.error
}
} else {
// 缓存过期,删除它
uploadCache.delete(imageHash)
}
}
try {
let blob: Blob
if (typeof imageData === 'string') {
if (imageData.startsWith('blob:')) {
// 从Blob URL获取Blob数据
blob = await getBlobFromUrl(imageData)
} else if (imageData.includes('base64,')) {
// 从base64数据创建Blob
const base64Data = imageData.split('base64,')[1]
const byteString = atob(base64Data)
// 从base64数据中提取MIME类型
const mimeMatch = imageData.match(/^data:([a-zA-Z0-9]+\/[a-zA-Z0-9-.+]+).*,.*/)
const mimeString = mimeMatch ? mimeMatch[1] : 'image/png' // 默认MIME类型
const ab = new ArrayBuffer(byteString.length)
const ia = new Uint8Array(ab)
for (let i = 0; i < byteString.length; i++) {
ia[i] = byteString.charCodeAt(i)
}
blob = new Blob([ab], { type: mimeString })
} else {
// 从URL获取Blob
const response = await fetch(imageData)
blob = await response.blob()
}
} else {
// 如果已经是Blob对象直接使用
blob = imageData
}
// 创建FormData对象
const formData = new FormData()
formData.append('file', blob, 'generated-image.png')
// 发送POST请求
const response = await fetch(UPLOAD_URL, {
method: 'POST',
headers: {
accessToken: accessToken,
// 添加其他可能需要的头部
},
body: formData,
})
// 记录响应状态以帮助调试
console.log('上传响应状态:', response.status, response.statusText)
if (!response.ok) {
const errorText = await response.text()
console.error('上传失败响应内容:', errorText)
throw new Error(`上传失败: ${response.status} ${response.statusText} - ${errorText}`)
}
const result = await response.json()
console.log('上传响应结果:', result)
// 根据返回格式处理结果: {"code": 200,"msg": "上传成功","data": "9ecbaa0a0.jpg"}
if (result.code === 200) {
// 使用环境变量中的VITE_UPLOAD_ASSET_URL作为前缀
const uploadAssetUrl = import.meta.env.VITE_UPLOAD_ASSET_URL || ''
const fullUrl = uploadAssetUrl ? `${uploadAssetUrl}/${result.data}` : result.data
// 清理过期缓存
cleanupExpiredCache()
// 维护缓存大小
maintainCacheSize()
// 将上传结果存储到缓存中
const uploadResult = { success: true, url: fullUrl, error: undefined }
if (typeof imageData === 'string') {
uploadCache.set(imageHash, {
...uploadResult,
timestamp: Date.now(),
})
}
return { success: true, url: fullUrl, error: undefined }
} else {
throw new Error(`上传失败: ${result.msg}`)
}
} catch (error) {
console.error('上传图像时出错:', error)
const errorMessage = error instanceof Error ? error.message : String(error)
const errorResult = { success: false, error: errorMessage }
// 清理过期缓存
cleanupExpiredCache()
// 维护缓存大小(即使是失败的结果也缓存,但时间较短)
maintainCacheSize()
// 将失败的上传结果也存储到缓存中(可选)
if (typeof imageData === 'string') {
uploadCache.set(imageHash, {
...errorResult,
timestamp: Date.now(),
})
}
return { success: false, error: errorMessage }
}
}
/**
* 上传多个图像
* @param imageDatas - 图像数据数组可以是base64、Blob URL或Blob对象
* @param accessToken - 访问令牌
* @param skipCache - 是否跳过缓存检查
* @returns 上传结果数组
*/
export const uploadImages = async (imageDatas: (string | Blob)[], accessToken: string, skipCache: boolean = false): Promise<UploadResult[]> => {
try {
const results: UploadResult[] = []
for (let i = 0; i < imageDatas.length; i++) {
const imageData = imageDatas[i]
try {
const uploadResult = await uploadImage(imageData, accessToken, skipCache)
const result: UploadResult = {
success: uploadResult.success,
url: uploadResult.url,
error: uploadResult.error,
timestamp: Date.now(),
}
results.push(result)
console.log(`${i + 1}张图像上传${uploadResult.success ? '成功' : '失败'}:`, uploadResult)
} catch (error) {
const result: UploadResult = {
success: false,
error: error instanceof Error ? error.message : String(error),
timestamp: Date.now(),
}
results.push(result)
console.error(`${i + 1}张图像上传失败:`, error)
}
}
// 检查是否有任何上传失败
const failedUploads = results.filter(r => !r.success)
if (failedUploads.length > 0) {
console.warn(`${failedUploads.length}张图像上传失败`)
} else {
console.log(`所有${results.length}张图像上传成功`)
}
return results
} catch (error) {
console.error('批量上传图像时出错:', error)
throw error
}
}
/**
* 清除上传缓存
*/
export const clearUploadCache = (): void => {
uploadCache.clear()
console.log('上传缓存已清除')
}

762
v1/src/store/useAppStore.ts Normal file
View File

@@ -0,0 +1,762 @@
import { create } from 'zustand';
import { devtools, persist } from 'zustand/middleware';
import { Project, Generation, Edit, SegmentationMask, BrushStroke, UploadResult } from '../types';
import { generateId } from '../utils/imageUtils';
import * as indexedDBService from '../services/indexedDBService';
// 定义不包含图像数据的轻量级项目结构
interface LightweightProject {
id: string;
title: string;
generations: Array<{
id: string;
prompt: string;
parameters: Generation['parameters'];
sourceAssets: Array<{
id: string;
type: 'original';
mime: string;
width: number;
height: number;
checksum: string;
// 存储Blob URL而不是base64数据
blobUrl: string;
}>;
// 存储输出资产的Blob URL
outputAssetsBlobUrls: string[];
modelVersion: string;
timestamp: number;
uploadResults?: UploadResult[];
usageMetadata?: Generation['usageMetadata'];
}>;
edits: Array<{
id: string;
parentGenerationId: string;
maskAssetId?: string;
// 存储遮罩参考资产的Blob URL
maskReferenceAssetBlobUrl?: string;
instruction: string;
// 存储输出资产的Blob URL
outputAssetsBlobUrls: string[];
timestamp: number;
uploadResults?: UploadResult[];
parameters?: Edit['parameters'];
usageMetadata?: Edit['usageMetadata'];
}>;
createdAt: number;
updatedAt: number;
}
interface AppState {
// 当前项目(轻量级版本,不包含实际图像数据)
currentProject: LightweightProject | null;
// 画布状态
canvasImage: string | null;
canvasZoom: number;
canvasPan: { x: number; y: number };
// 上传状态
uploadedImages: string[];
editReferenceImages: string[];
// 用于绘制遮罩的画笔描边
brushStrokes: BrushStroke[];
brushSize: number;
showMasks: boolean;
// 生成状态
isGenerating: boolean;
currentPrompt: string;
temperature: number;
seed: number | null;
// 历史记录和变体
selectedGenerationId: string | null;
selectedEditId: string | null;
showHistory: boolean;
// 面板可见性
showPromptPanel: boolean;
// UI状态
selectedTool: 'generate' | 'edit' | 'mask';
// 存储Blob对象的Map
blobStore: Map<string, Blob>;
// 操作
setCurrentProject: (project: LightweightProject | null) => void;
setCanvasImage: (url: string | null) => void;
setCanvasZoom: (zoom: number) => void;
setCanvasPan: (pan: { x: number; y: number }) => void;
addUploadedImage: (url: string) => void;
removeUploadedImage: (index: number) => void;
clearUploadedImages: () => void;
addEditReferenceImage: (url: string) => void;
removeEditReferenceImage: (index: number) => void;
clearEditReferenceImages: () => void;
addBrushStroke: (stroke: BrushStroke) => void;
clearBrushStrokes: () => void;
setBrushSize: (size: number) => void;
setShowMasks: (show: boolean) => void;
setIsGenerating: (generating: boolean) => void;
setCurrentPrompt: (prompt: string) => void;
setTemperature: (temp: number) => void;
setSeed: (seed: number | null) => void;
addGeneration: (generation: Generation) => void;
addEdit: (edit: Edit) => void;
removeGeneration: (id: string) => void;
removeEdit: (id: string) => void;
selectGeneration: (id: string | null) => void;
selectEdit: (id: string | null) => void;
setShowHistory: (show: boolean) => void;
setShowPromptPanel: (show: boolean) => void;
setSelectedTool: (tool: 'generate' | 'edit' | 'mask') => void;
// Blob存储操作
addBlob: (blob: Blob) => string;
getBlob: (url: string) => Blob | undefined;
cleanupOldHistory: () => void;
// Blob URL清理操作
revokeBlobUrls: (urls: string[]) => void;
cleanupAllBlobUrls: () => void;
// 定期清理Blob URL
scheduleBlobCleanup: () => void;
}
// 限制历史记录数量
const MAX_HISTORY_ITEMS = 50;
export const useAppStore = create<AppState>()(
devtools(
persist(
(set, get) => ({
// 初始状态
currentProject: null,
canvasImage: null,
canvasZoom: 1,
canvasPan: { x: 0, y: 0 },
uploadedImages: [],
editReferenceImages: [],
brushStrokes: [],
brushSize: 20,
showMasks: true,
isGenerating: false,
currentPrompt: '',
temperature: 1,
seed: null,
selectedGenerationId: null,
selectedEditId: null,
showHistory: true,
showPromptPanel: true,
selectedTool: 'generate',
// Blob存储不在持久化中保存
blobStore: new Map(),
// 操作
setCurrentProject: (project) => set({ currentProject: project }),
setCanvasImage: (url) => set({ canvasImage: url }),
setCanvasZoom: (zoom) => set({ canvasZoom: zoom }),
setCanvasPan: (pan) => set({ canvasPan: pan }),
addUploadedImage: (url) => set((state) => ({
uploadedImages: [...state.uploadedImages, url]
})),
removeUploadedImage: (index) => set((state) => ({
uploadedImages: state.uploadedImages.filter((_, i) => i !== index)
})),
clearUploadedImages: () => set({ uploadedImages: [] }),
addEditReferenceImage: (url) => set((state) => ({
editReferenceImages: [...state.editReferenceImages, url]
})),
removeEditReferenceImage: (index) => set((state) => ({
editReferenceImages: state.editReferenceImages.filter((_, i) => i !== index)
})),
clearEditReferenceImages: () => set({ editReferenceImages: [] }),
addBrushStroke: (stroke) => set((state) => ({
brushStrokes: [...state.brushStrokes, stroke]
})),
clearBrushStrokes: () => set({ brushStrokes: [] }),
setBrushSize: (size) => set({ brushSize: size }),
setShowMasks: (show) => set({ showMasks: show }),
setIsGenerating: (generating) => set({ isGenerating: generating }),
setCurrentPrompt: (prompt) => set({ currentPrompt: prompt }),
setTemperature: (temp) => set({ temperature: temp }),
setSeed: (seed) => set({ seed: seed }),
// 添加Blob到存储并返回URL
addBlob: (blob: Blob) => {
const url = URL.createObjectURL(blob);
set((state) => {
const newBlobStore = new Map(state.blobStore);
newBlobStore.set(url, blob);
return { blobStore: newBlobStore };
});
return url;
},
// 从存储中获取Blob
getBlob: (url: string) => {
const state = get();
return state.blobStore.get(url);
},
addGeneration: (generation) => {
// 保存到IndexedDB
indexedDBService.addGeneration(generation).catch(err => {
console.error('保存生成记录到IndexedDB失败:', err);
});
set((state) => {
// 将base64图像数据转换为Blob并存储
const sourceAssets = generation.sourceAssets.map(asset => {
if (asset.url.startsWith('data:')) {
// 从base64创建Blob
const base64 = asset.url.split(',')[1];
const byteString = atob(base64);
const mimeString = asset.url.split(',')[0].split(':')[1].split(';')[0];
const ab = new ArrayBuffer(byteString.length);
const ia = new Uint8Array(ab);
for (let i = 0; i < byteString.length; i++) {
ia[i] = byteString.charCodeAt(i);
}
const blob = new Blob([ab], { type: mimeString });
const blobUrl = URL.createObjectURL(blob);
// 存储Blob对象
set((innerState) => {
const newBlobStore = new Map(innerState.blobStore);
newBlobStore.set(blobUrl, blob);
return { blobStore: newBlobStore };
});
return {
id: asset.id,
type: asset.type,
mime: asset.mime,
width: asset.width,
height: asset.height,
checksum: asset.checksum,
blobUrl
};
} else if (asset.url.startsWith('blob:')) {
// 如果已经是Blob URL直接使用
// 同时确保存储在blobStore中
set((innerState) => {
const blob = innerState.blobStore.get(asset.url);
if (blob) {
const newBlobStore = new Map(innerState.blobStore);
newBlobStore.set(asset.url, blob);
return { blobStore: newBlobStore };
}
return innerState;
});
return {
id: asset.id,
type: asset.type,
mime: asset.mime,
width: asset.width,
height: asset.height,
checksum: asset.checksum,
blobUrl: asset.url
};
}
// 对于其他URL类型直接使用URL
return {
id: asset.id,
type: asset.type,
mime: asset.mime,
width: asset.width,
height: asset.height,
checksum: asset.checksum,
blobUrl: asset.url
};
});
// 将输出资产转换为Blob URL
const outputAssetsBlobUrls = generation.outputAssets.map(asset => {
if (asset.url.startsWith('data:')) {
// 从base64创建Blob
const base64 = asset.url.split(',')[1];
const byteString = atob(base64);
const mimeString = asset.url.split(',')[0].split(':')[1].split(';')[0];
const ab = new ArrayBuffer(byteString.length);
const ia = new Uint8Array(ab);
for (let i = 0; i < byteString.length; i++) {
ia[i] = byteString.charCodeAt(i);
}
const blob = new Blob([ab], { type: mimeString });
const blobUrl = URL.createObjectURL(blob);
// 存储Blob对象
set((innerState) => {
const newBlobStore = new Map(innerState.blobStore);
newBlobStore.set(blobUrl, blob);
return { blobStore: newBlobStore };
});
return blobUrl;
} else if (asset.url.startsWith('blob:')) {
// 如果已经是Blob URL直接使用
return asset.url;
}
// 对于其他URL类型直接使用
return asset.url;
});
// 创建轻量级生成记录
const lightweightGeneration = {
id: generation.id,
prompt: generation.prompt,
parameters: generation.parameters,
sourceAssets,
outputAssetsBlobUrls,
modelVersion: generation.modelVersion,
timestamp: generation.timestamp,
uploadResults: generation.uploadResults,
usageMetadata: generation.usageMetadata
};
const updatedProject = state.currentProject ? {
...state.currentProject,
generations: [...state.currentProject.generations, lightweightGeneration],
updatedAt: Date.now()
} : {
// 如果没有项目,创建一个新项目包含此生成记录
id: generateId(),
title: '未命名项目',
generations: [lightweightGeneration],
edits: [],
createdAt: Date.now(),
updatedAt: Date.now()
};
// 清理旧记录以保持在限制内
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);
});
}
return {
currentProject: updatedProject
};
});
},
addEdit: (edit) => {
// 保存到IndexedDB
indexedDBService.addEdit(edit).catch(err => {
console.error('保存编辑记录到IndexedDB失败:', err);
});
set((state) => {
// 将遮罩参考资产转换为Blob URL如果存在
let maskReferenceAssetBlobUrl: string | undefined;
if (edit.maskReferenceAsset && edit.maskReferenceAsset.url.startsWith('data:')) {
const base64 = edit.maskReferenceAsset.url.split(',')[1];
const byteString = atob(base64);
const mimeString = edit.maskReferenceAsset.url.split(',')[0].split(':')[1].split(';')[0];
const ab = new ArrayBuffer(byteString.length);
const ia = new Uint8Array(ab);
for (let i = 0; i < byteString.length; i++) {
ia[i] = byteString.charCodeAt(i);
}
const blob = new Blob([ab], { type: mimeString });
maskReferenceAssetBlobUrl = URL.createObjectURL(blob);
// 存储Blob对象
set((innerState) => {
const newBlobStore = new Map(innerState.blobStore);
newBlobStore.set(maskReferenceAssetBlobUrl!, blob);
return { blobStore: newBlobStore };
});
} else if (edit.maskReferenceAsset) {
maskReferenceAssetBlobUrl = edit.maskReferenceAsset.url;
}
// 将输出资产转换为Blob URL
const outputAssetsBlobUrls = edit.outputAssets.map(asset => {
if (asset.url.startsWith('data:')) {
// 从base64创建Blob
const base64 = asset.url.split(',')[1];
const byteString = atob(base64);
const mimeString = asset.url.split(',')[0].split(':')[1].split(';')[0];
const ab = new ArrayBuffer(byteString.length);
const ia = new Uint8Array(ab);
for (let i = 0; i < byteString.length; i++) {
ia[i] = byteString.charCodeAt(i);
}
const blob = new Blob([ab], { type: mimeString });
const blobUrl = URL.createObjectURL(blob);
// 存储Blob对象
set((innerState) => {
const newBlobStore = new Map(innerState.blobStore);
newBlobStore.set(blobUrl, blob);
return { blobStore: newBlobStore };
});
return blobUrl;
} else if (asset.url.startsWith('blob:')) {
// 如果已经是Blob URL直接使用
return asset.url;
}
// 对于其他URL类型直接使用
return asset.url;
});
// 创建轻量级编辑记录
const lightweightEdit = {
id: edit.id,
parentGenerationId: edit.parentGenerationId,
maskAssetId: edit.maskAssetId,
maskReferenceAssetBlobUrl,
instruction: edit.instruction,
outputAssetsBlobUrls,
timestamp: edit.timestamp,
uploadResults: edit.uploadResults,
parameters: edit.parameters,
usageMetadata: edit.usageMetadata
};
if (!state.currentProject) return {};
const updatedProject = {
...state.currentProject,
edits: [...state.currentProject.edits, lightweightEdit],
updatedAt: Date.now()
};
// 清理旧记录以保持在限制内
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;
});
}
// 清理数组
updatedProject.edits.splice(0, updatedProject.edits.length - MAX_HISTORY_ITEMS);
// 同时清理IndexedDB中的旧记录
indexedDBService.cleanupOldRecords(MAX_HISTORY_ITEMS).catch(err => {
console.error('清理IndexedDB旧记录失败:', err);
});
}
return {
currentProject: updatedProject
};
});
},
selectGeneration: (id) => set({ selectedGenerationId: id }),
selectEdit: (id) => set({ selectedEditId: id }),
setShowHistory: (show) => set({ showHistory: show }),
setShowPromptPanel: (show) => set({ showPromptPanel: show }),
setSelectedTool: (tool) => set({ selectedTool: tool }),
// 删除生成记录
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;
});
}
}
// 从项目中移除生成记录
const updatedProject = {
...state.currentProject,
generations: state.currentProject.generations.filter(gen => gen.id !== id),
updatedAt: Date.now()
};
return {
currentProject: updatedProject
};
}),
// 删除编辑记录
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;
});
}
}
// 从项目中移除编辑记录
const updatedProject = {
...state.currentProject,
edits: state.currentProject.edits.filter(edit => edit.id !== id),
updatedAt: Date.now()
};
return {
currentProject: updatedProject
};
}),
// 清理旧的历史记录
cleanupOldHistory: () => set((state) => {
if (!state.currentProject) return {};
const generations = [...state.currentProject.generations];
const edits = [...state.currentProject.edits];
// 收集需要释放的Blob URLs
const urlsToRevoke: string[] = [];
// 如果生成记录超过限制,只保留最新的记录
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);
});
return {
currentProject: {
...state.currentProject,
generations,
edits,
updatedAt: Date.now()
}
};
}),
// 释放指定的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 };
}
});
return state;
}),
// 释放所有Blob URLs
cleanupAllBlobUrls: () => set((state) => {
state.blobStore.forEach((_, url) => {
URL.revokeObjectURL(url);
});
return { ...state, blobStore: new Map() };
}),
// 定期清理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);
// 如果Blob没有被使用则清理它
if (!isUsedInProject && !isUsedInCanvas && !isUsedInUploads && !isUsedInEdits) {
URL.revokeObjectURL(url);
const newBlobStore = new Map(state.blobStore);
newBlobStore.delete(url);
set({ blobStore: newBlobStore });
}
});
}
}),
{
name: 'nano-banana-store',
partialize: (state) => ({
currentProject: state.currentProject,
// 我们只持久化轻量级项目数据不包含Blob对象
})
}
)
)
);

91
v1/src/types/index.ts Normal file
View File

@@ -0,0 +1,91 @@
export interface Asset {
id: string;
type: 'original' | 'mask' | 'output';
url: string;
mime: string;
width: number;
height: number;
checksum: string;
}
export interface UploadResult {
success: boolean;
url?: string;
error?: string;
timestamp: number;
}
export interface Generation {
id: string;
prompt: string;
parameters: {
seed?: number;
temperature?: number;
aspectRatio?: string;
};
sourceAssets: Asset[];
outputAssets: Asset[];
modelVersion: string;
timestamp: number;
costEstimate?: number;
uploadResults?: UploadResult[];
usageMetadata?: {
totalTokenCount?: number;
promptTokenCount?: number;
candidatesTokenCount?: number;
};
}
export interface Edit {
id: string;
parentGenerationId: string;
maskAssetId?: string;
maskReferenceAsset?: Asset;
instruction: string;
outputAssets: Asset[];
timestamp: number;
uploadResults?: UploadResult[];
parameters?: {
seed?: number;
temperature?: number;
};
usageMetadata?: {
totalTokenCount?: number;
promptTokenCount?: number;
candidatesTokenCount?: number;
};
}
export interface Project {
id: string;
title: string;
generations: Generation[];
edits: Edit[];
createdAt: number;
updatedAt: number;
}
export interface SegmentationMask {
id: string;
imageData: ImageData;
bounds: {
x: number;
y: number;
width: number;
height: number;
};
feather: number;
}
export interface BrushStroke {
id: string;
points: number[];
brushSize: number;
color: string;
}
export interface PromptHint {
category: 'subject' | 'scene' | 'action' | 'style' | 'camera';
text: string;
example: string;
}

6
v1/src/utils/cn.ts Normal file
View File

@@ -0,0 +1,6 @@
import { clsx, type ClassValue } from 'clsx';
import { twMerge } from 'tailwind-merge';
export function cn(...inputs: ClassValue[]) {
return twMerge(clsx(inputs));
}

146
v1/src/utils/imageUtils.ts Normal file
View File

@@ -0,0 +1,146 @@
export function base64ToBlob(base64: string, mimeType: string = 'image/png'): Blob {
const byteCharacters = atob(base64);
const byteNumbers = new Array(byteCharacters.length);
for (let i = 0; i < byteCharacters.length; i++) {
byteNumbers[i] = byteCharacters.charCodeAt(i);
}
const byteArray = new Uint8Array(byteNumbers);
return new Blob([byteArray], { type: mimeType });
}
export function blobToBase64(blob: Blob): Promise<string> {
return new Promise((resolve, reject) => {
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);
});
}
// 将URL转换为Blob
export async function urlToBlob(url: string): Promise<Blob> {
const response = await fetch(url);
return await response.blob();
}
export function createImageFromBase64(base64: string): Promise<HTMLImageElement> {
return new Promise((resolve, reject) => {
const img = new Image();
img.onload = () => resolve(img);
img.onerror = reject;
img.src = `data:image/png;base64,${base64}`;
});
}
export function resizeImageToFit(
image: HTMLImageElement,
maxWidth: number,
maxHeight: number
): { width: number; height: number } {
const ratio = Math.min(maxWidth / image.width, maxHeight / image.height);
return {
width: image.width * ratio,
height: image.height * ratio
};
}
export function generateId(): string {
return `${Date.now()}-${Math.random().toString(36).substr(2, 9)}`;
}
export function downloadImage(imageData: string, filename: string): void {
if (imageData.startsWith('blob:')) {
// 对于Blob URL我们需要获取实际的Blob数据
fetch(imageData)
.then(response => response.blob())
.then(blob => {
const url = URL.createObjectURL(blob);
const a = document.createElement('a');
a.href = url;
a.download = filename;
document.body.appendChild(a);
a.click();
document.body.removeChild(a);
URL.revokeObjectURL(url);
});
} else if (imageData.startsWith('data:')) {
// 对于数据URL直接下载
const a = document.createElement('a');
a.href = imageData;
a.download = filename;
document.body.appendChild(a);
a.click();
document.body.removeChild(a);
} else {
// 对于其他URL获取并转换为blob
fetch(imageData)
.then(response => response.blob())
.then(blob => {
const url = URL.createObjectURL(blob);
const a = document.createElement('a');
a.href = url;
a.download = filename;
document.body.appendChild(a);
a.click();
document.body.removeChild(a);
URL.revokeObjectURL(url);
});
}
}
// 优化的图像压缩函数
export async function compressImage(blob: Blob, quality: number = 0.8): Promise<Blob> {
return new Promise((resolve, reject) => {
const canvas = document.createElement('canvas');
const ctx = canvas.getContext('2d');
if (!ctx) {
reject(new Error('无法获取canvas上下文'));
return;
}
const img = new Image();
img.onload = () => {
// 设置canvas尺寸
canvas.width = img.width;
canvas.height = img.height;
// 绘制图像
ctx.drawImage(img, 0, 0);
// 转换为Blob
canvas.toBlob(
(compressedBlob) => {
if (compressedBlob) {
resolve(compressedBlob);
} else {
reject(new Error('图像压缩失败'));
}
},
'image/jpeg',
quality
);
};
img.onerror = reject;
// 将Blob转换为URL以便加载到图像中
const url = URL.createObjectURL(blob);
img.src = url;
// 清理URL
img.onload = () => {
URL.revokeObjectURL(url);
// 调用原始的onload处理程序
if (img.onload) {
(img.onload as any).call(img);
}
};
});
}

1
v1/src/vite-env.d.ts vendored Normal file
View File

@@ -0,0 +1 @@
/// <reference types="vite/client" />