You've already forked Nano-Banana-AI-Image-Editor
初始化提交
This commit is contained in:
142
IFLOW.md
Normal file
142
IFLOW.md
Normal 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
|
||||
- 移动端适配需要特别关注界面布局和交互
|
||||
@@ -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
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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);
|
||||
};
|
||||
|
||||
/**
|
||||
* 清空所有记录
|
||||
*/
|
||||
|
||||
@@ -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, {
|
||||
|
||||
@@ -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
164
v1/src/App.tsx
Normal 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;
|
||||
36
v1/src/components/Header.tsx
Normal file
36
v1/src/components/Header.tsx
Normal 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} />
|
||||
</>
|
||||
);
|
||||
};
|
||||
1117
v1/src/components/HistoryPanel.tsx
Normal file
1117
v1/src/components/HistoryPanel.tsx
Normal file
File diff suppressed because it is too large
Load Diff
629
v1/src/components/ImageCanvas.tsx
Normal file
629
v1/src/components/ImageCanvas.tsx
Normal 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>
|
||||
);
|
||||
};
|
||||
54
v1/src/components/ImagePreviewModal.tsx
Normal file
54
v1/src/components/ImagePreviewModal.tsx
Normal 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>
|
||||
);
|
||||
};
|
||||
124
v1/src/components/InfoModal.tsx
Normal file
124
v1/src/components/InfoModal.tsx
Normal 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>
|
||||
);
|
||||
};
|
||||
36
v1/src/components/MaskOverlay.tsx
Normal file
36
v1/src/components/MaskOverlay.tsx
Normal 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>
|
||||
);
|
||||
};
|
||||
483
v1/src/components/PromptComposer.tsx
Normal file
483
v1/src/components/PromptComposer.tsx
Normal 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;
|
||||
87
v1/src/components/PromptHints.tsx
Normal file
87
v1/src/components/PromptHints.tsx
Normal 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>
|
||||
);
|
||||
};
|
||||
120
v1/src/components/PromptSuggestions.tsx
Normal file
120
v1/src/components/PromptSuggestions.tsx
Normal 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
119
v1/src/components/Toast.tsx
Normal 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>
|
||||
);
|
||||
};
|
||||
125
v1/src/components/ToastContext.tsx
Normal file
125
v1/src/components/ToastContext.tsx
Normal 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;
|
||||
};
|
||||
60
v1/src/components/ui/Button.tsx
Normal file
60
v1/src/components/ui/Button.tsx
Normal 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';
|
||||
22
v1/src/components/ui/Input.tsx
Normal file
22
v1/src/components/ui/Input.tsx
Normal 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';
|
||||
21
v1/src/components/ui/Textarea.tsx
Normal file
21
v1/src/components/ui/Textarea.tsx
Normal 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';
|
||||
557
v1/src/hooks/useImageGeneration.ts
Normal file
557
v1/src/hooks/useImageGeneration.ts
Normal 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,
|
||||
}
|
||||
}
|
||||
83
v1/src/hooks/useIndexedDBListener.ts
Normal file
83
v1/src/hooks/useIndexedDBListener.ts
Normal 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 };
|
||||
};
|
||||
125
v1/src/hooks/useKeyboardShortcuts.ts
Normal file
125
v1/src/hooks/useKeyboardShortcuts.ts
Normal 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
235
v1/src/index.css
Normal 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
10
v1/src/main.tsx
Normal 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>
|
||||
);
|
||||
119
v1/src/services/cacheService.ts
Normal file
119
v1/src/services/cacheService.ts
Normal 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);
|
||||
}
|
||||
}
|
||||
}
|
||||
327
v1/src/services/geminiService.ts
Normal file
327
v1/src/services/geminiService.ts
Normal 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()
|
||||
447
v1/src/services/indexedDBService.ts
Normal file
447
v1/src/services/indexedDBService.ts
Normal 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;
|
||||
}
|
||||
};
|
||||
290
v1/src/services/uploadService.ts
Normal file
290
v1/src/services/uploadService.ts
Normal 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获取Blob,Blob可能已被清理');
|
||||
}
|
||||
|
||||
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
762
v1/src/store/useAppStore.ts
Normal 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
91
v1/src/types/index.ts
Normal 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
6
v1/src/utils/cn.ts
Normal 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
146
v1/src/utils/imageUtils.ts
Normal 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
1
v1/src/vite-env.d.ts
vendored
Normal file
@@ -0,0 +1 @@
|
||||
/// <reference types="vite/client" />
|
||||
Reference in New Issue
Block a user