6 Commits

Author SHA1 Message Date
3ba2a0cbd5 Merge branch 'futrue' 2025-09-22 22:42:40 +08:00
d4f9735f88 分支合并 2025-09-19 23:46:38 +08:00
yuantao
4b5b1a5eba 新增 历史记录删除功能 2025-09-19 18:40:43 +08:00
yuantao
eae15ced5a 新增常用提示词功能 2025-09-19 17:25:46 +08:00
yuantao
70684b2ddf 优化 调整了历史记录预览窗口的实现 2025-09-19 17:00:51 +08:00
yuantao
9a5e4d8041 添加了IFLOW描述文件;
优化 调整了历史记录悬浮窗的显示位置;
2025-09-19 16:31:09 +08:00
46 changed files with 4968 additions and 7933 deletions

View File

@@ -4,8 +4,5 @@ VITE_ACCESS_TOKEN=your_access_token_here
# Gemini API密钥
VITE_GEMINI_API_KEY=your_gemini_api_key_here
# 上传接口
VITE_UPLOAD_API=
# 远程资源路径
VITE_UPLOAD_ASSET_URL=''

2
.gitignore vendored
View File

@@ -23,5 +23,3 @@ dist-ssr
*.sln
*.sw?
.env
release

2
.npmrc
View File

@@ -1,2 +0,0 @@
ELECTRON_MIRROR=https://npmmirror.com/mirrors/electron/
ELECTRON_BUILDER_BINARIES_MIRROR=https://npmmirror.com/mirrors/electron-builder-binaries/

297
IFLOW.md
View File

@@ -1,201 +1,142 @@
# Nano Banana AI 图像编辑器 - iFlow 上下
# Nano Banana AI Image Editor - iFlow 文
## 项目概述
这是一个基于 React 和 TypeScript 的 AI 图像生成与编辑应用,名为 Nano Banana AI Image Editor。它利用 Google Gemini 2.5 Flash Image 模型,提供文本到图像生成和基于自然语言的图像编辑功能。该应用具有现代化的用户界面,支持交互式画布、区域选择和历史记录管理
Nano Banana AI Image Editor 是一个基于 React 的 AI 图像编辑工具,用户可以通过直观的界面与 Google Gemini AI 模型进行交互,实现图像生成和编辑功能
主要技术栈包括:
- **前端框架**: React 18, TypeScript
- **状态管理**: Zustand (应用状态), React Query (服务端状态)
- **UI 库**: Tailwind CSS
- **画布库**: Konva.js (react-konva)
- **AI 集成**: Google Generative AI SDK (Gemini)
- **数据存储**: IndexedDB (通过 idb-keyval)
- **构建工具**: Vite
- **桌面应用**: Electron
## 技术栈
项目结构遵循标准的 React 应用组织方式,主要源代码位于 `src/` 目录下。该项目同时支持Web和桌面应用Electron
- **核心框架**: 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 (备用画布库)
## 构建和运行
### 开发环境
1. **安装依赖**:
```bash
npm install
```
2. **配置环境变量**:
- 复制 `.env.example` 为 `.env`
- 在 `.env` 文件中设置 `VITE_GEMINI_API_KEY` (必需)
- 可选设置 `VITE_ACCESS_TOKEN` 和 `VITE_UPLOAD_ASSET_URL` 以启用图像上传功能
3. **启动开发服务器**:
```bash
# 启动Web开发服务器
npm run dev
# 启动Electron开发环境
npm run electron:dev
```
访问 `http://localhost:5173` 查看Web应用。
### 构建和部署
- **构建生产版本**:
```bash
# 构建Web版本
npm run build
# 构建Electron桌面应用
npm run electron:build
```
- **预览生产构建**:
```bash
npm run preview
```
### 测试
- **运行测试**:
```bash
npm run test
```
- **运行测试并监听变化**:
```bash
npm run test:watch
```
### 代码质量
- **运行 ESLint**:
```bash
npm run lint
```
## 开发约定
## 代码风格和命名规范
### 代码风格
- 使用 TypeScript 严格模式 (strict: true)
- 函数式组件为主,使用 React Hooks
- 组件文件使用 .tsx 扩展名
- 工具函数文件使用 .ts 扩展名
- 使用 ESLint 进行代码检查
- 启用严格的 TypeScript 编译选项
- 使用 TypeScript 进行类型安全检查
- 使用 ESLint 进行代码规范检查
- 使用 Prettier 进行代码格式化 (通过 ESLint 配置集成)
- 组件文件使用 `.tsx` 扩展名
- 工具函数文件使用 `.ts` 扩展名
### 命名规范
- 组件文件和组件名使用 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
## 项目结构
```
src/
├── components/ # React 组件
│ ├── ui/ # 可重用的 UI 组件
│ │ ├── Button.tsx
│ │ ├── Input.tsx
│ │ ── Textarea.tsx
│ ├── CustomTitleBar.tsx # 自定义标题栏用于Electron应用
│ ├── Header.tsx # 应用头部和导航
── PromptComposer.tsx # 提示输入和工具选择
│ ├── ImageCanvas.tsx # 使用 Konva 的交互式画布
│ ├── HistoryPanel.tsx # 生成历史和变体
│ ├── InfoModal.tsx # 关于模态框和链接
── Toast.tsx # 消息提示组件
── ToastContext.tsx # 消息提示上下文
├── services/ # 外部服务集成
│ ├── geminiService.ts # Gemini API 客户端
│ ├── indexedDBService.ts # IndexedDB 数据库服务
── cacheService.ts # IndexedDB 缓存层(未使用)
│ ├── referenceImageService.ts # 参考图像处理(未使用)
│ └── uploadService.ts # 图像上传服务(未使用)
├── store/ # Zustand 状态管理
└── useAppStore.ts # 全局应用状态
├── hooks/ # 自定义 React 钩子
│ ├── useImageGeneration.ts # 生成和编辑逻辑
│ ├── useKeyboardShortcuts.ts # 键盘导航
── useIndexedDBListener.ts # IndexedDB监听器未使用
├── utils/ # 工具函数
│ ├── cn.ts # 类名工具
│ └── imageUtils.ts # 图像处理助手
├── types/ # TypeScript 类型定义
│ └── index.ts # 核心类型定义
└── __tests__/ # 测试文件
├── ImageCanvas.test.tsx
├── PromptComposer.test.tsx
├── useAppStore.test.ts
└── useImageGeneration.test.ts
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 # 项目说明
```
### 组件开发
## 核心功能模块
- 组件应保持较小的体积建议小于200行
- 使用函数式组件React Hooks
- 组件应具有明确的接口Props
- 尽可能使用 TypeScript 进行类型定义
### 1. 图像画布 (ImageCanvas)
- 使用 Konva react-konva 实现图像显示和编辑
- 支持图像缩放、平移
- 实现画笔工具进行遮罩绘制
- 支持图像下载功能
### 状态管理
### 2. 提示词编辑 (PromptComposer)
- 用户输入提示词生成图像
- 提供提示词建议功能
- 集成 AI 模型参数调整 (如风格、质量等)
- 全局状态使用 Zustand 管理 (`src/store/useAppStore.ts`)
- 服务端状态使用 React Query 管理
- 组件内部状态使用 React 的 useState 和 useReducer
### 3. 历史记录 (HistoryPanel)
- 显示生成的图像历史
- 支持历史图像的查看和重新编辑
- 使用 IndexedDB 存储历史数据
### 测试
### 4. 状态管理 (useAppStore)
- 使用 Zustand 管理全局状态
- 存储画布状态、用户设置、历史记录等
- 提供状态操作方法
- 使用 Jest 和 React Testing Library 进行测试
- 测试文件放在 `src/__tests__` 目录下
- 测试文件名应与被测试文件名对应,加上 `.test.tsx` 或 `.test.ts` 后缀
### 5. AI 服务 (geminiService)
- 集成 Google Gemini AI 模型
- 实现图像生成和编辑功能
- 处理与 AI 模型的交互
### 贡献指南
## 开发环境配置
1. 遵循既定的代码风格和项目结构
2. 保持组件在 200 行以内
3. 维护类型安全,严格使用 TypeScript 和正确定义
4. 彻底测试,确保键盘导航和可访问性
5. 记录更改,更新 README 并添加内联注释
6. 遵守 AGPL-3.0 许可证
1. 安装依赖: `npm install`
2. 启动开发服务器: `npm run dev`
3. 构建生产版本: `npm run build`
4. 代码检查: `npm run lint`
## 项目特性
## 注意事
### AI 图像生成与编辑
- 文本到图像生成:使用 Google Gemini 2.5 Flash Image 模型从描述性提示创建图像
- 对话式编辑:使用自然语言指令修改现有图像
- 区域感知选择:通过绘制遮罩来针对特定区域进行编辑
### 用户界面
- 交互式画布:支持平滑缩放、平移和导航大图像
- 画笔工具:可变画笔尺寸以实现精确的遮罩绘制
- 响应式设计:在所有设备上都能完美运行
- 键盘快捷键:使用热键提高工作效率
### 数据管理
- 生成历史:跟踪所有创作和编辑记录
- 变体比较:生成并并排比较多个版本
- 离线缓存:使用 IndexedDB 存储以实现离线资产访问
- 项目管理:有序存储所有生成的内容
### 部署选项
- Web 应用:标准的 Web 应用程序,可在浏览器中运行
- 桌面应用:使用 Electron 构建的桌面应用程序,支持 Windows、macOS 和 Linux
## 依赖包更新
项目已清理未使用的依赖包,当前依赖包括:
### 运行时依赖
- `@google/genai`: Google Generative AI SDK
- `@radix-ui/react-dialog`: React 对话框组件
- `@tanstack/react-query`: 服务端状态管理
- `class-variance-authority`, `clsx`, `tailwind-merge`: 类名工具
- `idb-keyval`: IndexedDB 封装库
- `konva`, `react-konva`: Canvas 图形库
- `lucide-react`: 图标库
- `react`, `react-dom`: React 核心库
- `react-day-picker`: 日期选择器组件
- `zustand`: 客户端状态管理
### 开发依赖
- `@eslint/js`, `eslint`, `typescript-eslint`: 代码质量工具
- `@testing-library/*`: 测试工具
- `electron-builder`: Electron 应用构建工具
- `typescript`: TypeScript 编译器
- `vite`: 构建工具
- 项目使用 IndexedDB 存储图像数据,需要注意存储空间管理
- AI 功能需要配置 Google API 密钥
- 图像处理功能依赖浏览器 Canvas API
- 移动端适配需要特别关注界面布局和交互

View File

@@ -169,7 +169,6 @@ npm run dev # 启动开发服务器
npm run build # 构建生产版本
npm run preview # 预览生产构建
npm run lint # 运行 ESLint
npm run test # 运行测试
```
### 生产考虑

View File

@@ -1,54 +0,0 @@
# 应用图标
此目录包含用于打包桌面应用程序的图标文件。
## Windows 平台
Windows 平台需要 ICO 格式的图标文件,命名为 `icon.ico`
### 如何创建 ICO 文件
有几种方法可以创建 ICO 文件:
#### 方法1使用 Python 脚本(推荐)
项目根目录下提供了一个 Python 脚本 `convert_svg_to_ico.py`,可以直接将 SVG 转换为 ICO 格式:
1. 确保已安装 Python 3 和必要的依赖包:
```
pip install Pillow cairosvg
```
2. 在 Windows 上,您可能还需要安装 GTK3 运行时环境:
- 下载并安装 GTK3: https://github.com/tschoonj/GTK-for-Windows-Runtime-Environment-Installer
3. 运行转换脚本:
```
npm run electron:icon
```
或直接运行:
```
python convert_svg_to_ico.py
```
脚本会自动将 `public/favicon.svg` 转换为 `build/icon.ico`,并包含多个尺寸。
#### 方法2使用在线转换工具
- [ConvertICO](https://convertico.com/)
- [Online-Convert](https://image.online-convert.com/convert-to-ico)
- [CloudConvert](https://cloudconvert.com/png-to-ico)
#### 方法3使用图像编辑软件
使用支持 ICO 格式的图像编辑软件(如 GIMP、Photoshop 插件等)手动创建。
### 图标尺寸要求
Windows 应用程序图标应包含多个尺寸:
- 16x16
- 32x32
- 48x48
- 256x256
确保在生成 ICO 文件时包含这些尺寸。

Binary file not shown.

Before

Width:  |  Height:  |  Size: 37 KiB

View File

@@ -1,14 +0,0 @@
import { copyFileSync, existsSync, mkdirSync } from 'fs';
import { resolve } from 'path';
const source = resolve('./public/favicon.svg');
const dest = resolve('./dist/favicon.svg');
// 确保 dist 目录存在
if (!existsSync('./dist')) {
mkdirSync('./dist');
}
// 复制文件
copyFileSync(source, dest);
console.log('favicon.svg copied successfully');

View File

@@ -1,57 +0,0 @@
const { spawn } = require('child_process');
const path = require('path');
const fs = require('fs');
// 先编译 TypeScript 文件
const tsc = spawn('npx', ['tsc', '-p', 'tsconfig.electron.json'], {
stdio: 'inherit',
shell: true
});
tsc.on('close', (code) => {
if (code === 0) {
// 编译成功后复制 preload.js 文件
const sourcePreload = path.join(__dirname, 'preload.js');
const destPreload = path.join(__dirname, 'dist', 'preload.js');
try {
fs.copyFileSync(sourcePreload, destPreload);
console.log('preload.js copied successfully');
} catch (err) {
console.error('Failed to copy preload.js:', err);
}
// 编译成功后运行 Electron 应用
const electron = require('electron');
const appPath = path.join(__dirname, 'dist', 'main.js');
// 启动 Vite 开发服务器
const vite = spawn('npm', ['run', 'dev'], {
stdio: 'inherit',
shell: true,
env: {
...process.env,
VITE_DEV_SERVER_URL: 'http://localhost:5173',
},
});
// 等待一段时间让 Vite 服务器启动,然后启动 Electron
setTimeout(() => {
const child = spawn(electron, [appPath], {
stdio: 'inherit',
env: {
...process.env,
NODE_ENV: 'development',
VITE_DEV_SERVER_URL: 'http://localhost:5173',
},
});
child.on('close', (code) => {
process.exit(code || 0);
});
}, 5000); // 等待 5 秒钟让 Vite 服务器启动
} else {
console.error('TypeScript compilation failed');
process.exit(code || 1);
}
});

View File

@@ -1,3 +0,0 @@
// Electron 入口文件
// 使用 require 而不是 import 来避免 ES 模块问题
require('./dist/main.js');

View File

@@ -1,54 +0,0 @@
import { app, BrowserWindow } from 'electron';
import * as path from 'path';
const createWindow = () => {
// Create the browser window.
const mainWindow = new BrowserWindow({
width: 1200,
height: 800,
webPreferences: {
preload: path.join(__dirname, 'preload.js'),
nodeIntegration: false,
contextIsolation: true,
},
});
// and load the index.html of the app.
if (process.env.VITE_DEV_SERVER_URL) {
mainWindow.loadURL(process.env.VITE_DEV_SERVER_URL);
} else {
mainWindow.loadFile(path.join(__dirname, '../dist/index.html'));
}
// Open the DevTools.
if (process.env.VITE_DEV_SERVER_URL) {
mainWindow.webContents.openDevTools();
}
};
// This method will be called when Electron has finished
// initialization and is ready to create browser windows.
// Some APIs can only be used after this event occurs.
app.whenReady().then(() => {
createWindow();
// On OS X it's common to re-create a window in the app when the
// dock icon is clicked and there are no other windows open.
app.on('activate', () => {
if (BrowserWindow.getAllWindows().length === 0) {
createWindow();
}
});
});
// Quit when all windows are closed, except on macOS. There, it's common
// for applications and their menu bar to stay active until the user quits
// explicitly with Cmd + Q.
app.on('window-all-closed', () => {
if (process.platform !== 'darwin') {
app.quit();
}
});
// In this file you can include the rest of your app's specific main process
// code. You can also put them in separate files and import them here.

View File

@@ -1,113 +0,0 @@
import { app, BrowserWindow, ipcMain, globalShortcut } from 'electron';
import * as path from 'path';
let mainWindow: BrowserWindow | null = null;
const createWindow = () => {
// Create the browser window.
mainWindow = new BrowserWindow({
width: 1600,
height: 900,
frame: false, // 隐藏默认的窗口框架
icon: path.join(__dirname, '../../build/icon.ico'), // 设置应用图标
webPreferences: {
preload: path.join(__dirname, 'preload.js'),
nodeIntegration: false,
contextIsolation: true,
devTools: process.env.NODE_ENV === 'development', // 仅在开发环境中启用开发者工具
},
});
// and load the index.html of the app.
if (process.env.VITE_DEV_SERVER_URL) {
mainWindow.loadURL(process.env.VITE_DEV_SERVER_URL);
// 仅在开发环境中打开开发者工具
mainWindow.webContents.openDevTools();
} else {
// 使用绝对路径从项目根目录的 dist 文件夹加载
const indexPath = path.join(__dirname, '../../dist/index.html');
console.log('Loading index.html from:', indexPath);
mainWindow.loadFile(indexPath);
// 在生产环境中确保关闭开发者工具
mainWindow.webContents.closeDevTools();
}
};
// IPC 处理程序
ipcMain.handle('window-minimize', () => {
if (mainWindow) {
mainWindow.minimize();
}
});
ipcMain.handle('window-maximize', () => {
if (mainWindow) {
if (mainWindow.isMaximized()) {
mainWindow.unmaximize();
} else {
mainWindow.maximize();
}
}
});
ipcMain.handle('window-close', () => {
if (mainWindow) {
mainWindow.close();
}
});
ipcMain.handle('window-is-maximized', () => {
return mainWindow ? mainWindow.isMaximized() : false;
});
// This method will be called when Electron has finished
// initialization and is ready to create browser windows.
// Some APIs can only be used after this event occurs.
app.whenReady().then(() => {
createWindow();
// 在生产环境中禁用开发者工具快捷键
if (process.env.NODE_ENV !== 'development') {
// 注册全局快捷键拦截
globalShortcut.register('Control+Shift+I', () => {
// 拦截快捷键,不执行任何操作
console.log('Developer tools shortcut intercepted in production');
});
globalShortcut.register('Control+Shift+J', () => {
// 拦截快捷键,不执行任何操作
console.log('Developer tools console shortcut intercepted in production');
});
globalShortcut.register('F12', () => {
// 拦截快捷键,不执行任何操作
console.log('Developer tools F12 shortcut intercepted in production');
});
}
// On OS X it's common to re-create a window in the app when the
// dock icon is clicked and there are no other windows open.
app.on('activate', () => {
if (BrowserWindow.getAllWindows().length === 0) {
createWindow();
}
});
});
// Quit when all windows are closed, except on macOS. There, it's common
// for applications and their menu bar to stay active until the user quits
// explicitly with Cmd + Q.
app.on('window-all-closed', () => {
if (process.platform !== 'darwin') {
app.quit();
}
});
// 在应用退出时取消注册所有全局快捷键
app.on('will-quit', () => {
// 取消注册所有快捷键
globalShortcut.unregisterAll();
});
// In this file you can include the rest of your app's specific main process
// code. You can also put them in separate files and import them here.

View File

@@ -1,3 +0,0 @@
{
"type": "commonjs"
}

View File

@@ -1,20 +0,0 @@
const { contextBridge, ipcRenderer, remote } = require('electron');
// As an example, we expose a function to the renderer process
// that shows a dialog
contextBridge.exposeInMainWorld('electronAPI', {
showDialog: () => ipcRenderer.invoke('show-dialog'),
});
// Custom APIs for renderer
const api = {};
// 窗口控制 API
contextBridge.exposeInMainWorld('electron', {
minimize: () => ipcRenderer.invoke('window-minimize'),
maximize: () => ipcRenderer.invoke('window-maximize'),
close: () => ipcRenderer.invoke('window-close'),
isMaximized: () => ipcRenderer.invoke('window-is-maximized'),
});
module.exports = api;

View File

@@ -1,56 +0,0 @@
const { spawn } = require('child_process');
const path = require('path');
const fs = require('fs');
// 先编译 TypeScript 文件
const tsc = spawn('npx', ['tsc', '-p', 'tsconfig.electron.json'], {
stdio: 'inherit',
shell: true
});
tsc.on('close', (code) => {
if (code === 0) {
// 编译成功后复制 preload.js 文件
const sourcePreload = path.join(__dirname, 'preload.js');
const destPreload = path.join(__dirname, 'dist', 'preload.js');
try {
fs.copyFileSync(sourcePreload, destPreload);
console.log('preload.js copied successfully');
} catch (err) {
console.error('Failed to copy preload.js:', err);
}
// 编译成功后运行 Electron 应用
const electron = require('electron');
const appPath = path.join(__dirname, 'dist', 'main.js');
// 先确保应用已构建
const build = spawn('npm', ['run', 'build'], {
stdio: 'inherit',
shell: true
});
build.on('close', (buildCode) => {
if (buildCode === 0) {
const child = spawn(electron, [appPath], {
stdio: 'inherit',
env: {
...process.env,
NODE_ENV: 'production'
}
});
child.on('close', (code) => {
process.exit(code || 0);
});
} else {
console.error('Build failed');
process.exit(buildCode || 1);
}
});
} else {
console.error('TypeScript compilation failed');
process.exit(code || 1);
}
});

View File

@@ -47,7 +47,7 @@
-->
<meta charset="UTF-8" />
<link rel="icon" type="image/svg+xml" href="/favicon.svg" />
<link rel="icon" type="image/svg+xml" href="data:image/svg+xml,<svg xmlns=%22http://www.w3.org/2000/svg%22 viewBox=%220 0 100 100%22><text y=%22.9em%22 font-size=%2290%22>🍌</text></svg>" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Nano Banana AI 图像编辑器 - AI 图像生成器和编辑器</title>
<meta name="description" content="由 Gemini 2.5 Flash Image 提供支持的专业 AI 图像生成和对话式编辑。使用自然语言提示创建、编辑和增强图像。" />

View File

@@ -1,30 +0,0 @@
export default {
preset: 'ts-jest',
testEnvironment: 'jsdom',
setupFilesAfterEnv: ['<rootDir>/src/__tests__/setup.ts'],
moduleNameMapper: {
'\\.(css|less|scss|sass)$': 'identity-obj-proxy',
'^@/(.*)$': '<rootDir>/src/$1'
},
collectCoverageFrom: [
'src/**/*.{ts,tsx}',
'!src/**/*.d.ts',
'!src/main.tsx',
'!src/vite-env.d.ts'
],
testMatch: [
'<rootDir>/src/__tests__/**/*.{ts,tsx}',
'<rootDir>/src/**/__tests__/**/*.{ts,tsx}',
'<rootDir>/src/**/*.{spec,test}.{ts,tsx}'
],
transform: {
'^.+\\.(ts|tsx)$': ['ts-jest', {
tsconfig: 'tsconfig.test.json',
diagnostics: {
warnOnly: true
}
}]
},
// Mock import.meta for tests
setupFiles: ['<rootDir>/src/__tests__/importMetaMock.js']
};

9068
package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

@@ -8,30 +8,24 @@
"type": "git",
"url": "https://git.pandorastudio.cn/yuantao/Nano-Banana-AI-Image-Editor.git"
},
"author": {
"name": "潘哆呐科技",
"email": "work@pandorastudio.cn"
},
"main": "electron/index.js",
"scripts": {
"dev": "vite",
"build": "vite build && node copy-favicon.js",
"build": "vite build",
"lint": "eslint .",
"preview": "vite preview",
"test": "jest",
"test:watch": "jest --watch",
"electron:dev": "node electron/dev-runner.js",
"electron:build": "cross-env NODE_ENV=production vite build && electron-builder --win --x64",
"electron:preview": "node electron/prod-runner.js",
"electron:compile": "tsc -p tsconfig.electron.json",
"postinstall": "echo Skipping electron-builder install-app-deps"
"test:watch": "jest --watch"
},
"dependencies": {
"@google/genai": "^1.16.0",
"@radix-ui/react-dialog": "^1.1.15",
"@radix-ui/react-select": "^2.2.6",
"@radix-ui/react-slider": "^1.3.6",
"@radix-ui/react-switch": "^1.2.6",
"@tanstack/react-query": "^5.85.5",
"class-variance-authority": "^0.7.1",
"clsx": "^2.1.1",
"fabric": "^6.7.1",
"idb-keyval": "^6.2.2",
"konva": "^9.3.22",
"lucide-react": "^0.344.0",
@@ -46,51 +40,23 @@
"@eslint/js": "^9.9.1",
"@testing-library/jest-dom": "^6.8.0",
"@testing-library/react": "^16.3.0",
"@testing-library/user-event": "^14.6.1",
"@types/jest": "^30.0.0",
"@types/react": "^18.3.5",
"@types/react-dom": "^18.3.0",
"@vitejs/plugin-react": "^5.0.4",
"autoprefixer": "^10.4.21",
"cross-env": "^7.0.3",
"electron": "^38.2.1",
"electron-builder": "^26.0.12",
"@vitejs/plugin-react": "^4.3.1",
"autoprefixer": "^10.4.18",
"eslint": "^9.9.1",
"eslint-plugin-react-hooks": "^5.1.0-rc.0",
"eslint-plugin-react-refresh": "^0.4.11",
"globals": "^15.9.0",
"identity-obj-proxy": "^3.0.0",
"jest": "^30.2.0",
"jest-environment-jsdom": "^30.2.0",
"postcss": "^8.5.6",
"tailwindcss": "^3.4.15",
"ts-jest": "^29.4.6",
"jest-environment-jsdom": "^30.1.2",
"postcss": "^8.4.35",
"tailwindcss": "^3.4.1",
"ts-jest": "^29.4.3",
"typescript": "^5.5.3",
"typescript-eslint": "^8.3.0",
"vite": "^5.4.2"
},
"build": {
"appId": "com.nanobanana.editor",
"productName": "Nano Banana AI Image Editor",
"npmRebuild": false,
"electronDownload": {
"mirror": "https://npmmirror.com/mirrors/electron/",
"customDir": "38.2.1",
"customFilename": "electron-v38.2.1-win32-x64.zip"
},
"directories": {
"output": "release",
"buildResources": "build"
},
"files": [
"dist/**/*",
"electron/**/*",
"package.json"
],
"win": {
"target": [
"nsis",
"zip"
],
"icon": "build/icon.ico"
}
}
}

View File

@@ -1,4 +0,0 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 100 100">
<circle cx="50" cy="50" r="45" fill="#FDE047"/>
<text x="50" y="70" font-size="60" text-anchor="middle" fill="#000">🍌</text>
</svg>

Before

Width:  |  Height:  |  Size: 201 B

View File

@@ -1,7 +1,7 @@
import React, { useEffect, useState } from 'react';
import React, { useEffect, useState, useReducer } from 'react';
import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
import { cn } from './utils/cn';
import { CustomTitleBar } from './components/CustomTitleBar';
import { Header } from './components/Header';
import { PromptComposer } from './components/PromptComposer';
import { ImageCanvas } from './components/ImageCanvas';
import { HistoryPanel } from './components/HistoryPanel';
@@ -27,30 +27,6 @@ function AppContent() {
const [previewPosition, setPreviewPosition] = useState<{x: number, y: number} | null>(null);
const [isPreviewVisible, setIsPreviewVisible] = useState(false);
// 在挂载时检查localStorage中的设置
useEffect(() => {
// 检查是否有存储在localStorage中的API密钥
const savedGeminiApiKey = localStorage.getItem('VITE_GEMINI_API_KEY');
if (savedGeminiApiKey) {
// 这里可以添加任何需要在应用启动时执行的逻辑
console.log('使用localStorage中的Gemini API密钥');
}
// 检查是否有存储在localStorage中的访问令牌
const savedAccessToken = localStorage.getItem('VITE_ACCESS_TOKEN');
if (savedAccessToken) {
// 这里可以添加任何需要在应用启动时执行的逻辑
console.log('使用localStorage中的访问令牌');
}
// 检查是否有存储在localStorage中的上传URL
const savedUploadAssetUrl = localStorage.getItem('VITE_UPLOAD_ASSET_URL');
if (savedUploadAssetUrl) {
// 这里可以添加任何需要在应用启动时执行的逻辑
console.log('使用localStorage中的上传资源URL');
}
}, []);
// 在挂载时初始化IndexedDB并清理base64数据
useEffect(() => {
const init = async () => {
@@ -115,7 +91,9 @@ function AppContent() {
return (
<div className="h-screen bg-gray-50 text-gray-900 flex flex-col font-sans">
<CustomTitleBar />
<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")}>

View File

@@ -1,140 +0,0 @@
import { render, screen } from '@testing-library/react';
import '@testing-library/jest-dom';
import React from 'react';
import { ImageCanvas } from '../components/ImageCanvas';
import { useAppStore } from '../store/useAppStore';
// Mock Konva components
jest.mock('react-konva', () => ({
Stage: React.forwardRef(({ children, ...props }: { children: React.ReactNode; [key: string]: unknown }, ref: React.Ref<HTMLDivElement>) => (
<div data-testid="konva-stage" ref={ref} {...props}>
{children}
</div>
)),
Layer: ({ children }: { children: React.ReactNode }) => <div data-testid="konva-layer">{children}</div>,
Image: () => <div data-testid="konva-image" />,
Line: () => <div data-testid="konva-line" />
}));
// Mock Lucide icons
jest.mock('lucide-react', () => ({
ZoomIn: () => <div data-testid="zoom-in-icon" />,
ZoomOut: () => <div data-testid="zoom-out-icon" />,
RotateCcw: () => <div data-testid="rotate-icon" />,
Download: () => <div data-testid="download-icon" />
}));
// Mock the ToastContext
jest.mock('../components/ToastContext', () => ({
useToast: () => ({
addToast: jest.fn()
})
}));
describe('ImageCanvas', () => {
beforeEach(() => {
// Reset the store
const store = useAppStore as unknown as { setState: (state: unknown) => void };
store.setState({
canvasImage: null,
canvasZoom: 1,
canvasPan: { x: 0, y: 0 },
brushStrokes: [],
showMasks: true,
selectedTool: 'generate',
isGenerating: false,
isContinuousGenerating: false,
retryCount: 0,
brushSize: 20,
showHistory: true,
showPromptPanel: true
});
});
describe('rendering', () => {
it('should render empty state when no image', () => {
render(<ImageCanvas />);
// Check that the empty state is displayed
expect(screen.getByText('Nano Banana AI')).toBeInTheDocument();
expect(screen.getByText('在提示框中描述您想要创建的内容')).toBeInTheDocument();
});
it('should render generation overlay when generating', () => {
// Set the store to generating state
const store = useAppStore as unknown as { setState: (state: unknown) => void };
store.setState({
isGenerating: true
});
render(<ImageCanvas />);
// Check that the generation overlay is displayed
expect(screen.getByText('正在创建图像...')).toBeInTheDocument();
});
it('should show retry count during continuous generation', () => {
// Set the store to continuous generation state
const store = useAppStore as unknown as { setState: (state: unknown) => void };
store.setState({
isGenerating: true,
isContinuousGenerating: true,
retryCount: 3
});
render(<ImageCanvas />);
// Check that the retry count is displayed
expect(screen.getByText('重试次数: 3')).toBeInTheDocument();
});
it('should render canvas controls when image is present', () => {
// Set the store to have an image and not generating
const store = useAppStore as unknown as { setState: (state: unknown) => void };
store.setState({
canvasImage: 'test-image-url',
isGenerating: false
});
render(<ImageCanvas />);
// Check that the control buttons are rendered
// Note: In test environment, these might not render due to mock limitations
// We'll just check that the component renders without error
expect(screen.getByTestId('konva-stage')).toBeInTheDocument();
});
});
describe('continuous generation display', () => {
it('should display retry count in generation overlay', () => {
// Set the store to continuous generation state
const store = useAppStore as unknown as { setState: (state: unknown) => void };
store.setState({
isGenerating: true,
isContinuousGenerating: true,
retryCount: 7
});
render(<ImageCanvas />);
// Check that the retry count is displayed in the overlay
expect(screen.getByText('重试次数: 7')).toBeInTheDocument();
});
it('should not display retry count when not in continuous mode', () => {
// Set the store to regular generation state
const store = useAppStore as unknown as { setState: (state: unknown) => void };
store.setState({
isGenerating: true,
isContinuousGenerating: false,
retryCount: 5
});
render(<ImageCanvas />);
// Check that the generation message is displayed but not the retry count
expect(screen.getByText('正在创建图像...')).toBeInTheDocument();
expect(screen.queryByText('重试次数:')).not.toBeInTheDocument();
});
});
});

View File

@@ -1,220 +0,0 @@
import { render, screen, fireEvent } from '@testing-library/react';
import '@testing-library/jest-dom';
import { PromptComposer } from '../components/PromptComposer';
import { useAppStore } from '../store/useAppStore';
// Mock the useImageGeneration hook
jest.mock('../hooks/useImageGeneration', () => ({
useImageGeneration: () => ({
generate: jest.fn(),
generateAsync: jest.fn(),
cancelGeneration: jest.fn(),
isGenerating: false,
error: null
}),
useImageEditing: () => ({
edit: jest.fn(),
cancelEdit: jest.fn(),
isEditing: false,
error: null
})
}));
// Mock the referenceImageService
jest.mock('../services/referenceImageService', () => ({
initReferenceImageDB: jest.fn(),
saveReferenceImage: jest.fn(),
getReferenceImage: jest.fn(),
deleteReferenceImage: jest.fn(),
clearAllReferenceImages: jest.fn()
}));
// Mock the imageUtils
jest.mock('../utils/imageUtils', () => ({
urlToBlob: jest.fn(),
generateId: () => 'test-id'
}));
// Mock the ToastContext
jest.mock('../components/ToastContext', () => ({
useToast: () => ({
addToast: jest.fn()
})
}));
// Mock child components
jest.mock('../components/PromptHints', () => ({
PromptHints: () => <div data-testid="prompt-hints-modal" />
}));
jest.mock('../components/PromptSuggestions', () => ({
PromptSuggestions: ({ onWordSelect }: { onWordSelect: (word: string) => void }) => (
<div data-testid="prompt-suggestions">
<button onClick={() => onWordSelect('test suggestion')}>Test Suggestion</button>
</div>
)
}));
// Mock Lucide icons
jest.mock('lucide-react', () => ({
Upload: () => <div data-testid="upload-icon" />,
Wand2: () => <div data-testid="wand-icon" />,
Edit3: () => <div data-testid="edit-icon" />,
MousePointer: () => <div data-testid="pointer-icon" />,
HelpCircle: () => <div data-testid="help-icon" />,
ChevronDown: () => <div data-testid="chevron-down-icon" />,
ChevronRight: () => <div data-testid="chevron-right-icon" />,
RotateCcw: () => <div data-testid="rotate-icon" />,
Download: () => <div data-testid="download-icon" />,
ZoomIn: () => <div data-testid="zoom-in-icon" />,
ZoomOut: () => <div data-testid="zoom-out-icon" />
}));
describe('PromptComposer', () => {
beforeEach(() => {
// Reset all mocks
jest.clearAllMocks();
// Reset the store
const store = useAppStore as unknown as { setState: (state: unknown) => void };
store.setState({
currentPrompt: '',
selectedTool: 'generate',
temperature: 1,
seed: null,
isGenerating: false,
isContinuousGenerating: false,
retryCount: 0,
uploadedImages: [],
editReferenceImages: [],
canvasImage: null,
showPromptPanel: true,
brushStrokes: [],
showHistory: true,
showMasks: true,
selectedGenerationId: null,
selectedEditId: null,
currentProject: null
});
});
describe('rendering', () => {
it('should render prompt composer panel', () => {
render(<PromptComposer />);
// Check that the main components are rendered
expect(screen.getByText('模式')).toBeInTheDocument();
expect(screen.getByText('生成')).toBeInTheDocument();
expect(screen.getByText('编辑')).toBeInTheDocument();
expect(screen.getByText('选择')).toBeInTheDocument();
expect(screen.getByText('参考图像')).toBeInTheDocument();
expect(screen.getByText('提示词')).toBeInTheDocument();
});
it('should render continuous generation button', () => {
render(<PromptComposer />);
// Check that the continuous generation button is rendered
expect(screen.getByText('连续')).toBeInTheDocument();
});
it('should show retry count during continuous generation', () => {
// Set the store to continuous generation state
const store = useAppStore as unknown as { setState: (state: unknown) => void };
store.setState({
isContinuousGenerating: true,
retryCount: 3
});
render(<PromptComposer />);
// Check that the retry count is displayed
expect(screen.getByText('重试: 3')).toBeInTheDocument();
});
});
describe('user interactions', () => {
it('should update prompt text', () => {
render(<PromptComposer />);
const textarea = screen.getByPlaceholderText('描述您想要创建的内容...');
fireEvent.change(textarea, { target: { value: 'A beautiful landscape' } });
expect(textarea).toHaveValue('A beautiful landscape');
});
it('should switch between tools', () => {
render(<PromptComposer />);
// Click on the edit tool
const editButton = screen.getByText('编辑');
fireEvent.click(editButton);
// Check that the prompt placeholder changed
expect(screen.getByPlaceholderText('描述您想要的修改...')).toBeInTheDocument();
});
it('should handle continuous generation button click', () => {
render(<PromptComposer />);
// Fill in a prompt
const textarea = screen.getByPlaceholderText('描述您想要创建的内容...');
fireEvent.change(textarea, { target: { value: 'A beautiful landscape' } });
// Check that the generate button is present
const generateButton = screen.getByText('生成图像');
expect(generateButton).toBeInTheDocument();
// Verify that the component renders without error
expect(textarea).toHaveValue('A beautiful landscape');
});
it('should show/hide prompt suggestions', () => {
render(<PromptComposer />);
// Initially prompt suggestions should be visible
expect(screen.getByTestId('prompt-suggestions')).toBeInTheDocument();
// Click to hide suggestions
const toggleButton = screen.getByText('常用提示词');
fireEvent.click(toggleButton);
// In Jest environment, we can't fully test the visibility toggle,
// but we can verify the button exists and can be clicked
expect(toggleButton).toBeInTheDocument();
});
});
describe('continuous generation UI', () => {
it('should show interrupt button during continuous generation', () => {
// Set the store to continuous generation state
const store = useAppStore as unknown as { setState: (state: unknown) => void };
store.setState({
isContinuousGenerating: true,
retryCount: 2
});
render(<PromptComposer />);
// Check that the interrupt button is displayed
expect(screen.getByText('中断')).toBeInTheDocument();
// Check that the retry count is displayed
expect(screen.getByText('重试: 2')).toBeInTheDocument();
});
it('should show retry count in the generation overlay', () => {
// This test would be better implemented in the ImageCanvas component tests
// but we can at least verify the state management here
const store = useAppStore as unknown as { setState: (state: unknown) => void };
store.setState({
isContinuousGenerating: true,
retryCount: 5
});
const state = store.getState();
expect(state.isContinuousGenerating).toBe(true);
expect(state.retryCount).toBe(5);
});
});
});

View File

@@ -1,16 +0,0 @@
// Mock import.meta for tests
const mockImportMetaEnv = {
VITE_ACCESS_TOKEN: 'test-token'
};
// Mock import.meta globally
global.import = {
meta: {
env: mockImportMetaEnv
}
};
// Also attach to window for browser-like environment
if (typeof window !== 'undefined') {
window.import = global.import;
}

View File

@@ -1,8 +0,0 @@
import '@testing-library/jest-dom';
// Add a simple test to avoid the "no tests" error
describe('Setup', () => {
it('should setup test environment', () => {
expect(true).toBe(true);
});
});

View File

@@ -1,253 +0,0 @@
// Create a simple mock store for testing
const createMockStore = (initialState: unknown = {}) => {
let state: unknown = {
isGenerating: false,
isContinuousGenerating: false,
retryCount: 0,
currentPrompt: '',
temperature: 1,
seed: null,
uploadedImages: [],
editReferenceImages: [],
canvasImage: null,
canvasZoom: 1,
canvasPan: { x: 0, y: 0 },
brushStrokes: [],
brushSize: 20,
showMasks: true,
selectedGenerationId: null,
selectedEditId: null,
showHistory: true,
showPromptPanel: true,
selectedTool: 'generate',
blobStore: new Map(),
currentProject: null,
...initialState
};
const store = {
getState: () => state,
setState: (newState: unknown) => {
if (typeof newState === 'function') {
state = { ...state, ...newState(state) };
} else {
state = { ...state, ...newState };
}
},
subscribe: () => () => {},
destroy: () => {}
};
// Add all the methods that the real store has
store.setCurrentProject = (project: unknown) => store.setState({ currentProject: project });
store.setCanvasImage = (url: string | null) => store.setState({ canvasImage: url });
store.setCanvasZoom = (zoom: number) => store.setState({ canvasZoom: zoom });
store.setCanvasPan = (pan: { x: number; y: number }) => store.setState({ canvasPan: pan });
store.addUploadedImage = (url: string) => store.setState((state: unknown) => ({
uploadedImages: [...(state as { uploadedImages: string[] }).uploadedImages, url]
}));
store.removeUploadedImage = (index: number) => store.setState((state: unknown) => ({
uploadedImages: (state as { uploadedImages: string[] }).uploadedImages.filter((_: unknown, i: number) => i !== index)
}));
store.reorderUploadedImage = (fromIndex: number, toIndex: number) => store.setState((state: unknown) => {
const currentState = state as { uploadedImages: string[] };
const newUploadedImages = [...currentState.uploadedImages];
const [movedItem] = newUploadedImages.splice(fromIndex, 1);
newUploadedImages.splice(toIndex, 0, movedItem);
return { uploadedImages: newUploadedImages };
});
store.clearUploadedImages = () => store.setState({ uploadedImages: [] });
store.addEditReferenceImage = (url: string) => store.setState((state: unknown) => ({
editReferenceImages: [...(state as { editReferenceImages: string[] }).editReferenceImages, url]
}));
store.removeEditReferenceImage = (index: number) => store.setState((state: unknown) => ({
editReferenceImages: (state as { editReferenceImages: string[] }).editReferenceImages.filter((_: unknown, i: number) => i !== index)
}));
store.clearEditReferenceImages = () => store.setState({ editReferenceImages: [] });
store.addBrushStroke = (stroke: unknown) => store.setState((state: unknown) => ({
brushStrokes: [...(state as { brushStrokes: unknown[] }).brushStrokes, stroke]
}));
store.clearBrushStrokes = () => store.setState({ brushStrokes: [] });
store.setBrushSize = (size: number) => store.setState({ brushSize: size });
store.setShowMasks = (show: boolean) => store.setState({ showMasks: show });
store.setIsGenerating = (generating: boolean) => store.setState({ isGenerating: generating });
store.setIsContinuousGenerating = (generating: boolean) => store.setState({ isContinuousGenerating: generating });
store.setRetryCount = (count: number) => store.setState({ retryCount: count });
store.setCurrentPrompt = (prompt: string) => store.setState({ currentPrompt: prompt });
store.setTemperature = (temp: number) => store.setState({ temperature: temp });
store.setSeed = (seed: number | null) => store.setState({ seed: seed });
store.addGeneration = () => {};
store.addEdit = () => {};
store.removeGeneration = () => {};
store.removeEdit = () => {};
store.selectGeneration = (id: string | null) => store.setState({ selectedGenerationId: id });
store.selectEdit = (id: string | null) => store.setState({ selectedEditId: id });
store.setShowHistory = (show: boolean) => store.setState({ showHistory: show });
store.setShowPromptPanel = (show: boolean) => store.setState({ showPromptPanel: show });
store.setSelectedTool = (tool: 'generate' | 'edit' | 'mask') => store.setState({ selectedTool: tool });
store.addBlob = (blob: Blob) => {
const url = URL.createObjectURL(blob);
store.setState((state: unknown) => {
const currentState = state as { blobStore: Map<string, Blob> };
const newBlobStore = new Map(currentState.blobStore);
newBlobStore.set(url, blob);
return { blobStore: newBlobStore };
});
return url;
};
store.getBlob = (url: string) => {
const currentState = store.getState();
return currentState.blobStore.get(url);
};
store.cleanupOldHistory = () => {};
store.revokeBlobUrls = () => {};
store.cleanupAllBlobUrls = () => {};
store.scheduleBlobCleanup = () => {};
return store;
};
// Mock the entire module
jest.mock('../store/useAppStore', () => {
const mockStore = createMockStore();
return {
useAppStore: mockStore
};
});
describe('useAppStore', () => {
let store: unknown;
beforeEach(() => {
// Create a fresh store for each test
store = createMockStore();
});
describe('continuous generation state', () => {
it('should initialize with correct default values', () => {
expect(store.getState().isContinuousGenerating).toBe(false);
expect(store.getState().retryCount).toBe(0);
});
it('should set continuous generating state', () => {
store.setIsContinuousGenerating(true);
expect(store.getState().isContinuousGenerating).toBe(true);
store.setIsContinuousGenerating(false);
expect(store.getState().isContinuousGenerating).toBe(false);
});
it('should update retry count', () => {
store.setRetryCount(5);
expect(store.getState().retryCount).toBe(5);
store.setRetryCount(10);
expect(store.getState().retryCount).toBe(10);
});
});
describe('prompt composer functionality', () => {
it('should set current prompt', () => {
const testPrompt = 'A beautiful landscape with mountains';
store.setCurrentPrompt(testPrompt);
expect(store.getState().currentPrompt).toBe(testPrompt);
});
it('should set temperature', () => {
store.setTemperature(0.7);
expect(store.getState().temperature).toBe(0.7);
});
it('should set seed', () => {
store.setSeed(12345);
expect(store.getState().seed).toBe(12345);
store.setSeed(null);
expect(store.getState().seed).toBeNull();
});
});
describe('image handling', () => {
it('should add uploaded images', () => {
const imageUrl = 'indexeddb://test-image-1';
store.addUploadedImage(imageUrl);
expect(store.getState().uploadedImages).toContain(imageUrl);
expect(store.getState().uploadedImages.length).toBe(1);
});
it('should remove uploaded images', () => {
const imageUrl1 = 'indexeddb://test-image-1';
const imageUrl2 = 'indexeddb://test-image-2';
store.addUploadedImage(imageUrl1);
store.addUploadedImage(imageUrl2);
store.removeUploadedImage(0);
expect(store.getState().uploadedImages).not.toContain(imageUrl1);
expect(store.getState().uploadedImages).toContain(imageUrl2);
expect(store.getState().uploadedImages.length).toBe(1);
});
it('should clear uploaded images', () => {
const imageUrl1 = 'indexeddb://test-image-1';
const imageUrl2 = 'indexeddb://test-image-2';
store.addUploadedImage(imageUrl1);
store.addUploadedImage(imageUrl2);
store.clearUploadedImages();
expect(store.getState().uploadedImages.length).toBe(0);
});
});
describe('canvas state', () => {
it('should set canvas image', () => {
const imageUrl = 'blob:http://localhost/test-blob-url';
store.setCanvasImage(imageUrl);
expect(store.getState().canvasImage).toBe(imageUrl);
});
it('should set canvas zoom', () => {
store.setCanvasZoom(1.5);
expect(store.getState().canvasZoom).toBe(1.5);
});
it('should set canvas pan', () => {
const pan = { x: 100, y: 50 };
store.setCanvasPan(pan);
expect(store.getState().canvasPan).toEqual(pan);
});
});
describe('brush strokes', () => {
it('should add brush strokes', () => {
const stroke = {
id: 'stroke-1',
points: [0, 0, 10, 10],
brushSize: 20
};
store.addBrushStroke(stroke);
expect(store.getState().brushStrokes).toContainEqual(stroke);
expect(store.getState().brushStrokes.length).toBe(1);
});
it('should clear brush strokes', () => {
const stroke = {
id: 'stroke-1',
points: [0, 0, 10, 10],
brushSize: 20
};
store.addBrushStroke(stroke);
store.clearBrushStrokes();
expect(store.getState().brushStrokes.length).toBe(0);
});
});
});

View File

@@ -1,29 +0,0 @@
// Mock the geminiService
jest.mock('../services/geminiService', () => ({
geminiService: {
generateImage: jest.fn(),
editImage: jest.fn()
}
}));
// Mock the ToastContext
jest.mock('../components/ToastContext', () => ({
useToast: () => ({
addToast: jest.fn()
})
}));
// Mock the uploadService
jest.mock('../services/uploadService', () => ({
uploadImages: jest.fn()
}));
// Mock the imageUtils
jest.mock('../utils/imageUtils', () => ({
generateId: () => 'test-id',
blobToBase64: jest.fn()
}));
describe('useImageGeneration', () => {
// Tests here
});

View File

@@ -1,119 +0,0 @@
import React, { useState, useEffect } from 'react';
import { Button } from './ui/Button';
import { HelpCircle, Minus, Square, X, Settings } from 'lucide-react';
import { InfoModal } from './InfoModal';
import { SettingsModal } from './SettingsModal';
export const CustomTitleBar: React.FC = () => {
const [showInfoModal, setShowInfoModal] = useState(false);
const [showSettingsModal, setShowSettingsModal] = useState(false);
const [isMaximized, setIsMaximized] = useState(false);
// 检查窗口是否最大化
useEffect(() => {
const checkMaximized = () => {
if (window.electron && window.electron.isMaximized) {
window.electron.isMaximized().then(setIsMaximized);
}
};
checkMaximized();
// 监听窗口状态变化
const handleResize = () => {
checkMaximized();
};
window.addEventListener('resize', handleResize);
return () => window.removeEventListener('resize', handleResize);
}, []);
const minimizeWindow = () => {
if (window.electron && window.electron.minimize) {
window.electron.minimize();
}
};
const maximizeWindow = () => {
if (window.electron && window.electron.maximize) {
window.electron.maximize();
}
};
const closeWindow = () => {
if (window.electron && window.electron.close) {
window.electron.close();
}
};
return (
<>
<header className="h-12 bg-white flex items-center justify-between px-3 rounded-t-xl border-b border-gray-200 draggable">
<div className="flex items-center space-x-2 non-draggable">
<div className="flex items-center justify-center w-8 h-8 rounded-lg bg-yellow-50">
{/* 使用应用的主要图标 */}
<img src="./favicon.svg" alt="App Icon" className="w-6 h-6" />
</div>
<h1 className="text-base font-medium text-gray-800 hidden sm:block">
Nano Banana AI
</h1>
</div>
<div className="flex items-center space-x-1 non-draggable">
<Button
variant="ghost"
size="icon"
onClick={() => setShowSettingsModal(true)}
className="h-7 w-7 text-gray-500 hover:text-gray-700 hover:bg-gray-100 rounded-full card non-draggable"
>
<Settings className="h-4 w-4" />
</Button>
<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 non-draggable"
>
<HelpCircle className="h-4 w-4" />
</Button>
{/* 窗口控制按钮 - 仅在 Electron 环境中显示 */}
{window.electron && (
<>
<Button
variant="ghost"
size="icon"
onClick={minimizeWindow}
className="h-7 w-7 text-gray-500 hover:text-gray-700 hover:bg-gray-100 rounded-full card non-draggable"
>
<Minus className="h-4 w-4" />
</Button>
<Button
variant="ghost"
size="icon"
onClick={maximizeWindow}
className="h-7 w-7 text-gray-500 hover:text-gray-700 hover:bg-gray-100 rounded-full card non-draggable"
>
<Square className="h-4 w-4" />
</Button>
<Button
variant="ghost"
size="icon"
onClick={closeWindow}
className="h-7 w-7 text-gray-500 hover:text-gray-700 hover:bg-red-100 rounded-full card non-draggable"
>
<X className="h-4 w-4" />
</Button>
</>
)}
</div>
</header>
<InfoModal open={showInfoModal} onOpenChange={setShowInfoModal} />
<SettingsModal open={showSettingsModal} onOpenChange={setShowSettingsModal} />
</>
);
};

View File

@@ -1,12 +1,10 @@
import React, { useState } from 'react';
import { Button } from './ui/Button';
import { HelpCircle, Settings } from 'lucide-react';
import { HelpCircle } from 'lucide-react';
import { InfoModal } from './InfoModal';
import { SettingsModal } from './SettingsModal';
export const Header: React.FC = () => {
const [showInfoModal, setShowInfoModal] = useState(false);
const [showSettingsModal, setShowSettingsModal] = useState(false);
return (
<>
@@ -21,14 +19,6 @@ export const Header: React.FC = () => {
</div>
<div className="flex items-center space-x-1">
<Button
variant="ghost"
size="icon"
onClick={() => setShowSettingsModal(true)}
className="h-7 w-7 text-gray-500 hover:text-gray-700 hover:bg-gray-100 rounded-full card"
>
<Settings className="h-4 w-4" />
</Button>
<Button
variant="ghost"
size="icon"
@@ -41,7 +31,6 @@ export const Header: React.FC = () => {
</header>
<InfoModal open={showInfoModal} onOpenChange={setShowInfoModal} />
<SettingsModal open={showSettingsModal} onOpenChange={setShowSettingsModal} />
</>
);
};

View File

@@ -1,7 +1,7 @@
import React, { useState, useEffect, useCallback, useMemo } from 'react';
import { useAppStore } from '../store/useAppStore';
import { Button } from './ui/Button';
import { History, Download, Image as ImageIcon, Trash2 } from 'lucide-react';
import { History, Download, Trash2, Image as ImageIcon } from 'lucide-react';
import { cn } from '../utils/cn';
import { ImagePreviewModal } from './ImagePreviewModal';
import { useIndexedDBListener } from '../hooks/useIndexedDBListener';
@@ -200,7 +200,7 @@ export const HistoryPanel: React.FC<{
}, [displayGenerations, displayEdits, getBlob, decodedImages]);
// 获取上传后的图片链接
const getUploadedImageUrl = (generationOrEdit: Generation | Edit, index: number) => {
const getUploadedImageUrl = (generationOrEdit: any, index: number) => {
if (generationOrEdit.uploadResults && generationOrEdit.uploadResults[index]) {
const uploadResult = generationOrEdit.uploadResults[index];
if (uploadResult.success && uploadResult.url) {
@@ -271,18 +271,47 @@ export const HistoryPanel: React.FC<{
);
}
// 监听鼠标离开窗口事件,确保悬浮预览正确关闭
useEffect(() => {
const handleMouseLeave = (e: MouseEvent) => {
// 当鼠标离开浏览器窗口时,关闭悬浮预览
if (e.relatedTarget === null) {
setHoveredImage(null);
if (setPreviewPosition) {
setPreviewPosition(null);
}
}
};
const handleBlur = () => {
// 当窗口失去焦点时,关闭悬浮预览
setHoveredImage(null);
if (setPreviewPosition) {
setPreviewPosition(null);
}
};
window.addEventListener('mouseleave', handleMouseLeave);
window.addEventListener('blur', handleBlur);
return () => {
window.removeEventListener('mouseleave', handleMouseLeave);
window.removeEventListener('blur', handleBlur);
};
}, [setHoveredImage, setPreviewPosition]);
if (!showHistory) {
return (
<div className="w-8 bg-white flex flex-col items-center justify-center rounded-r-xl">
<div className="w-8 bg-white flex flex-col items-center justify-center rounded-r-xl overflow-hidden">
<button
onClick={() => setShowHistory(true)}
className="w-6 h-16 bg-gray-100 hover:bg-gray-200 rounded-l-lg flex items-center justify-center transition-colors group"
className="w-6 h-16 bg-gray-100 hover:bg-gray-200 rounded-l-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"></div>
<div className="w-1 h-1 bg-gray-500 group-hover:bg-gray-400 rounded-full"></div>
<div className="w-1 h-1 bg-gray-500 group-hover:bg-gray-400 rounded-full"></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 className="w-1 h-1 bg-gray-500 group-hover:bg-gray-400 rounded-full transition-colors duration-200"></div>
</div>
</button>
</div>
@@ -537,8 +566,6 @@ export const HistoryPanel: React.FC<{
from: today,
to: today
});
// 重置分页到第一页
setCurrentPage(1);
}}
>
@@ -725,34 +752,50 @@ export const HistoryPanel: React.FC<{
console.error('图像加载失败:', error);
// 如果是Blob URL失效尝试重新获取
if (imageUrl.startsWith('blob:')) {
const blob = getBlob(imageUrl);
if (blob) {
console.log('从AppStore找到Blob尝试重新创建URL...');
// 重新创建Blob URL
const newUrl = URL.createObjectURL(blob);
// 更新显示
setHoveredImage({
url: newUrl,
title: `生成记录 G${globalIndex + 1}`,
width: 0,
height: 0
});
// 传递鼠标位置信息给App组件
if (setPreviewPosition) {
setPreviewPosition({ x: e.clientX, y: e.clientY });
}
// 预加载新URL
const newImg = new Image();
newImg.onload = () => {
import('../store/useAppStore').then((module) => {
const useAppStore = module.useAppStore;
const blob = useAppStore.getState().getBlob(imageUrl);
if (blob) {
console.log('从AppStore找到Blob尝试重新创建URL...');
// 重新创建Blob URL
const newUrl = URL.createObjectURL(blob);
// 更新显示
setHoveredImage({
url: newUrl,
title: `生成记录 G${globalIndex + 1}`,
width: newImg.width,
height: newImg.height
width: 0,
height: 0
});
};
newImg.src = newUrl;
} else {
// 传递鼠标位置信息给App组件
if (setPreviewPosition) {
setPreviewPosition({ x: e.clientX, y: e.clientY });
}
// 预加载新URL
const newImg = new Image();
newImg.onload = () => {
setHoveredImage({
url: newUrl,
title: `生成记录 G${globalIndex + 1}`,
width: newImg.width,
height: newImg.height
});
};
newImg.src = newUrl;
} else {
// 即使图像加载失败,也显示预览
setHoveredImage({
url: imageUrl,
title: `生成记录 G${globalIndex + 1}`,
width: 0,
height: 0
});
// 传递鼠标位置信息给App组件
if (setPreviewPosition) {
setPreviewPosition({ x: e.clientX, y: e.clientY });
}
}
}).catch(err => {
console.error('导入AppStore时出错:', err);
// 即使图像加载失败,也显示预览
setHoveredImage({
url: imageUrl,
@@ -764,7 +807,7 @@ export const HistoryPanel: React.FC<{
if (setPreviewPosition) {
setPreviewPosition({ x: e.clientX, y: e.clientY });
}
}
});
} else {
// 即使图像加载失败,也显示预览
setHoveredImage({
@@ -1340,16 +1383,18 @@ export const HistoryPanel: React.FC<{
const img = new Image();
img.onerror = () => {
// Blob URL可能已失效尝试重新创建
// 直接使用已导入的useAppStore
const blob = useAppStore.getState().getBlob(displayUrl);
if (blob) {
const newUrl = URL.createObjectURL(blob);
// 更新显示
const imgElement = document.querySelector(`img[src="${displayUrl}"]`);
if (imgElement) {
imgElement.src = newUrl;
import('../store/useAppStore').then((module) => {
const useAppStore = module.useAppStore;
const blob = useAppStore.getState().getBlob(displayUrl);
if (blob) {
const newUrl = URL.createObjectURL(blob);
// 更新显示
const imgElement = document.querySelector(`img[src="${displayUrl}"]`);
if (imgElement) {
imgElement.src = newUrl;
}
}
}
});
};
img.src = displayUrl;
}
@@ -1376,12 +1421,14 @@ export const HistoryPanel: React.FC<{
onError={(e) => {
// 如果图像加载失败尝试重新创建Blob URL
if (displayUrl.startsWith('blob:')) {
// 直接使用已导入的useAppStore
const blob = useAppStore.getState().getBlob(displayUrl);
if (blob) {
const newUrl = URL.createObjectURL(blob);
(e.target as HTMLImageElement).src = newUrl;
}
import('../store/useAppStore').then((module) => {
const useAppStore = module.useAppStore;
const blob = useAppStore.getState().getBlob(displayUrl);
if (blob) {
const newUrl = URL.createObjectURL(blob);
(e.target as HTMLImageElement).src = newUrl;
}
});
}
}}
/>
@@ -1426,16 +1473,18 @@ export const HistoryPanel: React.FC<{
const img = new Image();
img.onerror = () => {
// Blob URL可能已失效尝试重新创建
// 直接使用已导入的useAppStore
const blob = useAppStore.getState().getBlob(displayUrl);
if (blob) {
const newUrl = URL.createObjectURL(blob);
// 更新显示
const imgElement = document.querySelector(`img[src="${displayUrl}"]`);
if (imgElement) {
imgElement.src = newUrl;
import('../store/useAppStore').then((module) => {
const useAppStore = module.useAppStore;
const blob = useAppStore.getState().getBlob(displayUrl);
if (blob) {
const newUrl = URL.createObjectURL(blob);
// 更新显示
const imgElement = document.querySelector(`img[src="${displayUrl}"]`);
if (imgElement) {
imgElement.src = newUrl;
}
}
}
});
};
img.src = displayUrl;
}
@@ -1462,12 +1511,14 @@ export const HistoryPanel: React.FC<{
onError={(e) => {
// 如果图像加载失败尝试重新创建Blob URL
if (displayUrl.startsWith('blob:')) {
// 直接使用已导入的useAppStore
const blob = useAppStore.getState().getBlob(displayUrl);
if (blob) {
const newUrl = URL.createObjectURL(blob);
(e.target as HTMLImageElement).src = newUrl;
}
import('../store/useAppStore').then((module) => {
const useAppStore = module.useAppStore;
const blob = useAppStore.getState().getBlob(displayUrl);
if (blob) {
const newUrl = URL.createObjectURL(blob);
(e.target as HTMLImageElement).src = newUrl;
}
});
}
}}
/>
@@ -1566,16 +1617,18 @@ export const HistoryPanel: React.FC<{
const img = new Image();
img.onerror = () => {
// Blob URL可能已失效尝试重新创建
// 直接使用已导入的useAppStore
const blob = useAppStore.getState().getBlob(displayUrl);
if (blob) {
const newUrl = URL.createObjectURL(blob);
// 更新显示
const imgElement = document.querySelector(`img[src="${displayUrl}"]`);
if (imgElement) {
imgElement.src = newUrl;
import('../store/useAppStore').then((module) => {
const useAppStore = module.useAppStore;
const blob = useAppStore.getState().getBlob(displayUrl);
if (blob) {
const newUrl = URL.createObjectURL(blob);
// 更新显示
const imgElement = document.querySelector(`img[src="${displayUrl}"]`);
if (imgElement) {
imgElement.src = newUrl;
}
}
}
});
};
img.src = displayUrl;
}
@@ -1602,12 +1655,14 @@ export const HistoryPanel: React.FC<{
onError={(e) => {
// 如果图像加载失败尝试重新创建Blob URL
if (displayUrl.startsWith('blob:')) {
// 直接使用已导入的useAppStore
const blob = useAppStore.getState().getBlob(displayUrl);
if (blob) {
const newUrl = URL.createObjectURL(blob);
(e.target as HTMLImageElement).src = newUrl;
}
import('../store/useAppStore').then((module) => {
const useAppStore = module.useAppStore;
const blob = useAppStore.getState().getBlob(displayUrl);
if (blob) {
const newUrl = URL.createObjectURL(blob);
(e.target as HTMLImageElement).src = newUrl;
}
});
}
}}
/>
@@ -1652,16 +1707,18 @@ export const HistoryPanel: React.FC<{
const img = new Image();
img.onerror = () => {
// Blob URL可能已失效尝试重新创建
// 直接使用已导入的useAppStore
const blob = useAppStore.getState().getBlob(displayUrl);
if (blob) {
const newUrl = URL.createObjectURL(blob);
// 更新显示
const imgElement = document.querySelector(`img[src="${displayUrl}"]`);
if (imgElement) {
imgElement.src = newUrl;
import('../store/useAppStore').then((module) => {
const useAppStore = module.useAppStore;
const blob = useAppStore.getState().getBlob(displayUrl);
if (blob) {
const newUrl = URL.createObjectURL(blob);
// 更新显示
const imgElement = document.querySelector(`img[src="${displayUrl}"]`);
if (imgElement) {
imgElement.src = newUrl;
}
}
}
});
};
img.src = displayUrl;
}
@@ -1688,12 +1745,14 @@ export const HistoryPanel: React.FC<{
onError={(e) => {
// 如果图像加载失败尝试重新创建Blob URL
if (displayUrl.startsWith('blob:')) {
// 直接使用已导入的useAppStore
const blob = useAppStore.getState().getBlob(displayUrl);
if (blob) {
const newUrl = URL.createObjectURL(blob);
(e.target as HTMLImageElement).src = newUrl;
}
import('../store/useAppStore').then((module) => {
const useAppStore = module.useAppStore;
const blob = useAppStore.getState().getBlob(displayUrl);
if (blob) {
const newUrl = URL.createObjectURL(blob);
(e.target as HTMLImageElement).src = newUrl;
}
});
}
}}
/>
@@ -1744,16 +1803,18 @@ export const HistoryPanel: React.FC<{
const img = new Image();
img.onerror = () => {
// Blob URL可能已失效尝试重新创建
// 直接使用已导入的useAppStore
const blob = useAppStore.getState().getBlob(displayUrl);
if (blob) {
const newUrl = URL.createObjectURL(blob);
// 更新显示
const imgElement = document.querySelector(`img[src="${displayUrl}"]`);
if (imgElement) {
imgElement.src = newUrl;
import('../store/useAppStore').then((module) => {
const useAppStore = module.useAppStore;
const blob = useAppStore.getState().getBlob(displayUrl);
if (blob) {
const newUrl = URL.createObjectURL(blob);
// 更新显示
const imgElement = document.querySelector(`img[src="${displayUrl}"]`);
if (imgElement) {
imgElement.src = newUrl;
}
}
}
});
};
img.src = displayUrl;
}
@@ -1780,12 +1841,14 @@ export const HistoryPanel: React.FC<{
onError={(e) => {
// 如果图像加载失败尝试重新创建Blob URL
if (displayUrl.startsWith('blob:')) {
// 直接使用已导入的useAppStore
const blob = useAppStore.getState().getBlob(displayUrl);
if (blob) {
const newUrl = URL.createObjectURL(blob);
(e.target as HTMLImageElement).src = newUrl;
}
import('../store/useAppStore').then((module) => {
const useAppStore = module.useAppStore;
const blob = useAppStore.getState().getBlob(displayUrl);
if (blob) {
const newUrl = URL.createObjectURL(blob);
(e.target as HTMLImageElement).src = newUrl;
}
});
}
}}
/>

View File

@@ -1,17 +1,14 @@
import React, { useRef, useEffect, useState, useCallback } from 'react';
import { Stage, Layer, Image as KonvaImage, Line } from 'react-konva';
import type { KonvaEventObject } from 'konva/lib/Node';
import type { Stage as StageType } from 'konva/lib/Stage';
import { useAppStore } from '../store/useAppStore';
import { Button } from './ui/Button';
import { ZoomIn, ZoomOut, RotateCcw, Download } from 'lucide-react';
import { downloadImage } from '../utils/imageUtils';
export const ImageCanvas: React.FC = () => {
const {
canvasImage,
canvasZoom,
// canvasPan,
canvasPan,
setCanvasZoom,
setCanvasPan,
brushStrokes,
@@ -19,14 +16,12 @@ export const ImageCanvas: React.FC = () => {
showMasks,
selectedTool,
isGenerating,
isContinuousGenerating,
retryCount,
brushSize,
showHistory,
showPromptPanel
} = useAppStore();
const stageRef = useRef<StageType>(null);
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);
@@ -147,10 +142,13 @@ export const ImageCanvas: React.FC = () => {
const newUrl = URL.createObjectURL(blob);
console.log('从IndexedDB创建新的Blob URL:', newUrl);
// 更新canvasImage为新的URL
// 检查是否已取消
if (!isCancelled) {
useAppStore.getState().setCanvasImage(newUrl);
}
import('../store/useAppStore').then((storeModule) => {
const useAppStore = storeModule.useAppStore;
// 检查是否已取消
if (!isCancelled) {
useAppStore.getState().setCanvasImage(newUrl);
}
});
} else {
console.error('IndexedDB中未找到图像');
}
@@ -177,20 +175,22 @@ export const ImageCanvas: React.FC = () => {
console.log('正在检查Blob URL是否有效...');
// 尝试从AppStore重新获取Blob并创建新的URL
const blob = useAppStore.getState().getBlob(canvasImage);
if (blob) {
// 检查是否已取消
if (isCancelled) {
return;
}
import('../store/useAppStore').then((module) => {
const useAppStore = module.useAppStore;
const blob = useAppStore.getState().getBlob(canvasImage);
if (blob) {
// 检查是否已取消
if (isCancelled) {
return;
}
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.log('从AppStore找到Blob尝试重新创建URL...');
// 重新创建Blob URL并重试加载
const newUrl = URL.createObjectURL(blob);
console.log('创建新的Blob URL:', newUrl);
// 更新canvasImage为新的URL
useAppStore.getState().setCanvasImage(newUrl);
} else {
// 检查是否已取消
if (isCancelled) {
return;
@@ -220,7 +220,14 @@ export const ImageCanvas: React.FC = () => {
console.error('检查Blob URL时出错:', fetchErr);
});
}
}).catch(err => {
// 检查是否已取消
if (isCancelled) {
return;
}
console.error('导入AppStore时出错:', err);
});
}
};
@@ -289,16 +296,14 @@ export const ImageCanvas: React.FC = () => {
return () => container.removeEventListener('wheel', handleWheel);
}, [canvasZoom, handleZoom]);
const handleMouseDown = (e: KonvaEventObject<MouseEvent>) => {
const handleMouseDown = (e: Konva.KonvaEventObject<MouseEvent>) => {
if (selectedTool !== 'mask' || !image) return;
setIsDrawing(true);
const stage = e.target.getStage();
if (!stage) return;
// 使用 Konva 的 getRelativePointerPosition 获取准确坐标
const relativePos = stage.getRelativePointerPosition();
if (!relativePos) return;
// 计算图像在舞台上的边界
const imageX = (stageSize.width / canvasZoom - image.width) / 2;
@@ -314,15 +319,13 @@ export const ImageCanvas: React.FC = () => {
}
};
const handleMouseMove = (e: KonvaEventObject<MouseEvent>) => {
const handleMouseMove = (e: Konva.KonvaEventObject<MouseEvent>) => {
if (!isDrawing || selectedTool !== 'mask' || !image) return;
const stage = e.target.getStage();
if (!stage) return;
// 使用 Konva 的 getRelativePointerPosition 获取准确坐标
const relativePos = stage.getRelativePointerPosition();
if (!relativePos) return;
// 计算图像在舞台上的边界
const imageX = (stageSize.width / canvasZoom - image.width) / 2;
@@ -350,7 +353,6 @@ export const ImageCanvas: React.FC = () => {
id: `stroke-${Date.now()}`,
points: currentStroke,
brushSize,
color: '#A855F7',
});
setCurrentStroke([]);
};
@@ -381,7 +383,7 @@ export const ImageCanvas: React.FC = () => {
}
};
const handleDownload = async () => {
const handleDownload = () => {
// 首先尝试从当前选中的生成记录或编辑记录中获取上传后的URL
const { selectedGenerationId, selectedEditId, currentProject } = useAppStore.getState();
@@ -398,11 +400,38 @@ export const ImageCanvas: React.FC = () => {
// 下载第一个上传结果(通常是生成的图像)
const uploadResult = selectedRecord.uploadResults[0];
if (uploadResult.success && uploadResult.url) {
try {
await downloadImage(uploadResult.url, `nano-banana-${Date.now()}.png`);
} catch (error) {
console.error('下载图像失败:', error);
}
// 使用fetch获取图像数据并创建Blob URL以确保正确下载
// 添加更多缓存控制头以绕过CDN缓存
fetch(uploadResult.url, {
headers: {
'Cache-Control': 'no-cache, no-store, must-revalidate',
'Pragma': 'no-cache',
'Expires': '0'
}
})
.then(response => response.blob())
.then(blob => {
const url = URL.createObjectURL(blob);
const link = document.createElement('a');
link.href = url;
link.download = `nano-banana-${Date.now()}.png`;
document.body.appendChild(link);
link.click();
document.body.removeChild(link);
URL.revokeObjectURL(url);
})
.catch(error => {
console.error('下载图像失败:', error);
// 如果fetch失败回退到直接使用a标签
const link = document.createElement('a');
link.href = uploadResult.url;
link.download = `nano-banana-${Date.now()}.png`;
link.target = '_blank';
document.body.appendChild(link);
link.click();
document.body.removeChild(link);
});
// 立即返回
return;
}
@@ -429,15 +458,86 @@ export const ImageCanvas: React.FC = () => {
// 如果Konva下载失败回退到下载原始图像
if (canvasImage) {
try {
await downloadImage(canvasImage, `nano-banana-${Date.now()}.png`);
} catch (error) {
console.error('下载图像失败:', error);
// 处理不同类型的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获取图像数据并创建Blob URL以确保正确下载
// 添加更多缓存控制头以绕过CDN缓存
fetch(canvasImage, {
headers: {
'Cache-Control': 'no-cache, no-store, must-revalidate',
'Pragma': 'no-cache',
'Expires': '0'
}
})
.then(response => response.blob())
.then(blob => {
const url = URL.createObjectURL(blob);
const link = document.createElement('a');
link.href = url;
link.download = `nano-banana-${Date.now()}.png`;
document.body.appendChild(link);
link.click();
document.body.removeChild(link);
URL.revokeObjectURL(url);
})
.catch(error => {
console.error('下载图像失败:', error);
// 如果fetch失败回退到直接使用a标签
const link = document.createElement('a');
link.href = canvasImage;
link.download = `nano-banana-${Date.now()}.png`;
link.target = '_blank';
document.body.appendChild(link);
link.click();
document.body.removeChild(link);
});
} else {
// 普通URL格式
// 使用fetch获取图像数据并创建Blob URL以确保正确下载
// 添加更多缓存控制头以绕过CDN缓存
fetch(canvasImage, {
headers: {
'Cache-Control': 'no-cache, no-store, must-revalidate',
'Pragma': 'no-cache',
'Expires': '0'
}
})
.then(response => response.blob())
.then(blob => {
const url = URL.createObjectURL(blob);
const link = document.createElement('a');
link.href = url;
link.download = `nano-banana-${Date.now()}.png`;
document.body.appendChild(link);
link.click();
document.body.removeChild(link);
URL.revokeObjectURL(url);
})
.catch(error => {
console.error('下载图像失败:', error);
// 如果fetch失败回退到直接使用a标签
const link = document.createElement('a');
link.href = canvasImage;
link.download = `nano-banana-${Date.now()}.png`;
link.target = '_blank';
document.body.appendChild(link);
link.click();
document.body.removeChild(link);
});
}
}
}
}
};;
};
return (
<div className="flex flex-col h-full">
@@ -471,12 +571,6 @@ export const ImageCanvas: React.FC = () => {
<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>
{/* 显示重试次数 */}
{isContinuousGenerating && (
<p className="text-gray-500 text-xs mt-2">
: {retryCount}
</p>
)}
</div>
</div>
)}
@@ -498,8 +592,8 @@ export const ImageCanvas: React.FC = () => {
}
}}
onMouseDown={handleMouseDown}
onMouseMove={handleMouseMove}
onMouseUp={handleMouseUp}
onMousemove={handleMouseMove}
onMouseup={handleMouseUp}
style={{
cursor: selectedTool === 'mask' ? 'crosshair' : 'default',
zIndex: 10

View File

@@ -85,16 +85,12 @@ const ImagePreview: React.FC<{
onDragStart={(e) => onDragStart && onDragStart(e, index)}
onDragOver={(e) => {
e.preventDefault();
if (onDragOver) {
onDragOver(e, index);
}
onDragOver && onDragOver(e, index);
}}
onDragEnd={(e) => onDragEnd && onDragEnd(e)}
onDrop={(e) => {
e.preventDefault();
if (onDrop) {
onDrop(e, index);
}
onDrop && onDrop(e, index);
}}
>
<img
@@ -130,8 +126,6 @@ export const PromptComposer: React.FC = () => {
seed,
setSeed,
isGenerating,
isContinuousGenerating,
retryCount,
uploadedImages,
addUploadedImage,
removeUploadedImage,
@@ -145,15 +139,11 @@ export const PromptComposer: React.FC = () => {
setCanvasImage,
showPromptPanel,
setShowPromptPanel,
clearBrushStrokes,
setIsContinuousGenerating,
setRetryCount
clearBrushStrokes
} = useAppStore();
const { generate, generateAsync, cancelGeneration } = useImageGeneration();
const { generate, cancelGeneration } = useImageGeneration();
const { edit, cancelEdit } = useImageEditing();
// 连续生成状态已在AppStore中管理
const [showAdvanced, setShowAdvanced] = useState(false);
const [showPromptSuggestions, setShowPromptSuggestions] = useState(true);
const [showClearConfirm, setShowClearConfirm] = useState(false);
@@ -258,141 +248,6 @@ export const PromptComposer: React.FC = () => {
}
};
const handleContinuousGenerate = async () => {
if (!currentPrompt.trim()) return;
// 重置重试计数
setRetryCount(0);
setIsContinuousGenerating(true);
// 将上传的图像转换为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('indexeddb://')) {
// 从IndexedDB获取参考图像
const imageId = img.replace('indexeddb://', '');
try {
const blob = await referenceImageService.getReferenceImage(imageId);
if (blob) {
referenceImageBlobs.push(blob);
} else {
console.warn('无法从IndexedDB获取参考图像:', imageId);
// 如果无法获取图像,尝试重新上传
console.log('尝试重新处理参考图像...');
}
} catch (error) {
console.warn('无法从IndexedDB获取参考图像:', imageId, error);
// 如果无法获取图像,尝试重新上传
console.log('尝试重新处理参考图像...');
}
} else if (img.startsWith('blob:')) {
// 从Blob URL获取Blob
const { getBlob } = useAppStore.getState();
const blob = getBlob(img);
if (blob) {
referenceImageBlobs.push(blob);
} else {
// 如果在AppStore中找不到Blob尝试重新创建
try {
const response = await fetch(img);
if (response.ok) {
const blob = await response.blob();
referenceImageBlobs.push(blob);
} else {
console.warn('无法重新获取参考图像:', img);
}
} catch (error) {
console.warn('无法重新获取参考图像:', img, error);
}
}
} else {
// 从URL获取Blob
try {
const blob = await urlToBlob(img);
referenceImageBlobs.push(blob);
} catch (error) {
console.warn('无法获取参考图像:', img, error);
}
}
}
// 过滤掉无效的Blob只保留有效的参考图像
const validBlobs = referenceImageBlobs.filter(blob => blob.size > 0);
// 开始连续生成循环
const generateWithRetry = async () => {
try {
// 在开始前检查是否仍在连续生成模式
if (!useAppStore.getState().isContinuousGenerating) {
console.log('连续生成已取消');
return;
}
// 即使没有参考图像也继续生成,因为提示文本是必需的
await new Promise<void>((resolve, reject) => {
// 使用mutateAsync来等待结果
generateAsync({
prompt: currentPrompt,
referenceImages: validBlobs.length > 0 ? validBlobs : undefined,
temperature,
seed: seed !== null ? seed : undefined
}).then(() => {
// 生成成功,停止连续生成
setIsContinuousGenerating(false);
resolve();
}).catch((error) => {
// 检查是否是因为中断导致的错误
if (error.message === '生成已中断' || error.message === '生成已取消') {
console.log('生成被用户中断');
setIsContinuousGenerating(false);
resolve(); // 不再重试
return;
}
// 生成失败,增加重试计数并继续
const newCount = useAppStore.getState().retryCount + 1;
setRetryCount(newCount);
console.log(`生成失败,重试次数: ${newCount}`);
reject(error);
});
});
} catch (error) {
console.error('生成失败:', error);
// 如果仍在连续生成模式下,继续重试
if (useAppStore.getState().isContinuousGenerating) {
console.log('生成失败,正在重试...');
// 再次检查连续生成状态
setTimeout(() => {
if (useAppStore.getState().isContinuousGenerating) {
generateWithRetry();
}
}, 1000); // 1秒后重试
}
}
};
// 启动连续生成
generateWithRetry();
};
// 取消连续生成
const cancelContinuousGeneration = () => {
setIsContinuousGenerating(false);
// 立即调用 cancelGeneration 来中断当前请求
cancelGeneration();
};
const handleFileUpload = async (file: File) => {
if (file && file.type.startsWith('image/')) {
try {
@@ -474,7 +329,7 @@ export const PromptComposer: React.FC = () => {
e.dataTransfer.setData('text/plain', index.toString());
};
const handleDragOverPreview = (e: React.DragEvent<HTMLDivElement>) => {
const handleDragOverPreview = (e: React.DragEvent<HTMLDivElement>, index: number) => {
e.preventDefault();
e.dataTransfer.dropEffect = 'move';
};
@@ -521,16 +376,16 @@ export const PromptComposer: React.FC = () => {
if (!showPromptPanel) {
return (
<div className="w-8 bg-white flex flex-col items-center justify-center rounded-l-xl">
<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"></div>
<div className="w-1 h-1 bg-gray-500 group-hover:bg-gray-400 rounded-full"></div>
<div className="w-1 h-1 bg-gray-500 group-hover:bg-gray-400 rounded-full"></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 className="w-1 h-1 bg-gray-500 group-hover:bg-gray-400 rounded-full transition-colors duration-200"></div>
</div>
</button>
</div>
@@ -711,45 +566,25 @@ export const PromptComposer: React.FC = () => {
{/* 生成按钮 */}
<div className="flex-shrink-0">
{isGenerating || isContinuousGenerating ? (
{isGenerating ? (
<div className="flex gap-3">
<Button
onClick={() => selectedTool === 'generate' ? cancelContinuousGeneration() : cancelEdit()}
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>
{isContinuousGenerating && (
<div className="flex items-center justify-center bg-yellow-100 text-yellow-800 rounded-lg px-3 py-2 text-sm font-medium">
<span>: {retryCount}</span>
</div>
)}
</div>
) : (
<div className="flex gap-2">
<Button
onClick={handleGenerate}
disabled={!currentPrompt.trim()}
className="flex-1 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>
{selectedTool === 'generate' && (
<Button
onClick={handleContinuousGenerate}
disabled={!currentPrompt.trim()}
className="h-14 px-3 text-sm font-semibold rounded-xl shadow-md hover:shadow-lg transition-all card bg-purple-500 hover:bg-purple-600"
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" className="mr-1">
<path d="M17 3a2.85 2.83 0 1 1 4 4L7.5 20.5 2 22l1.5-5.5Z"></path>
</svg>
</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>

View File

@@ -1,223 +0,0 @@
import React, { useState, useEffect } from 'react';
import * as Dialog from '@radix-ui/react-dialog';
import { X, Settings } from 'lucide-react';
import { Button } from './ui/Button';
interface SettingsModalProps {
open: boolean;
onOpenChange: (open: boolean) => void;
}
export const SettingsModal: React.FC<SettingsModalProps> = ({ open, onOpenChange }) => {
const [accessToken, setAccessToken] = useState('');
const [geminiApiKey, setGeminiApiKey] = useState('');
const [uploadApiUrl, setUploadApiUrl] = useState('');
const [uploadAssetUrl, setUploadAssetUrl] = useState('');
const [modelName, setModelName] = useState('');
// 组件挂载时从localStorage加载设置
useEffect(() => {
if (open) {
const savedAccessToken = localStorage.getItem('VITE_ACCESS_TOKEN') || '';
const savedGeminiApiKey = localStorage.getItem('VITE_GEMINI_API_KEY') || '';
const savedUploadApiUrl = localStorage.getItem('VITE_UPLOAD_API') || '';
const savedUploadAssetUrl = localStorage.getItem('VITE_UPLOAD_ASSET_URL') || '';
const savedModelName = localStorage.getItem('VITE_GEMINI_MODEL_NAME') || 'gemini-2.5-flash-image-preview';
setAccessToken(savedAccessToken);
setGeminiApiKey(savedGeminiApiKey);
setUploadApiUrl(savedUploadApiUrl);
setUploadAssetUrl(savedUploadAssetUrl);
setModelName(savedModelName);
}
}, [open]);
const handleSave = () => {
// 保存到localStorage
if (accessToken) {
localStorage.setItem('VITE_ACCESS_TOKEN', accessToken);
} else {
localStorage.removeItem('VITE_ACCESS_TOKEN');
}
if (geminiApiKey) {
localStorage.setItem('VITE_GEMINI_API_KEY', geminiApiKey);
} else {
localStorage.removeItem('VITE_GEMINI_API_KEY');
}
if (uploadApiUrl) {
localStorage.setItem('VITE_UPLOAD_API', uploadApiUrl);
} else {
localStorage.removeItem('VITE_UPLOAD_API');
}
if (uploadAssetUrl) {
localStorage.setItem('VITE_UPLOAD_ASSET_URL', uploadAssetUrl);
} else {
localStorage.removeItem('VITE_UPLOAD_ASSET_URL');
}
if (modelName) {
localStorage.setItem('VITE_GEMINI_MODEL_NAME', modelName);
} else {
localStorage.removeItem('VITE_GEMINI_MODEL_NAME');
}
// 显示保存成功的提示
alert('设置已保存!');
// 关闭模态框
onOpenChange(false);
};
const handleReset = () => {
// 重置为默认值(环境变量中的值)
const defaultAccessToken = import.meta.env.VITE_ACCESS_TOKEN || '';
const defaultGeminiApiKey = import.meta.env.VITE_GEMINI_API_KEY || '';
const defaultUploadApiUrl = import.meta.env.VITE_UPLOAD_API || '';
const defaultUploadAssetUrl = import.meta.env.VITE_UPLOAD_ASSET_URL || '';
const defaultModelName = import.meta.env.VITE_GEMINI_MODEL_NAME || 'gemini-2.5-flash-image-preview';
setAccessToken(defaultAccessToken);
setGeminiApiKey(defaultGeminiApiKey);
setUploadApiUrl(defaultUploadApiUrl);
setUploadAssetUrl(defaultUploadAssetUrl);
setModelName(defaultModelName);
};
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-md 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">
</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-4">
<div className="space-y-2">
<label htmlFor="accessToken" className="text-sm font-medium text-gray-700">
访
</label>
<input
id="accessToken"
type="password"
value={accessToken}
onChange={(e) => setAccessToken(e.target.value)}
className="w-full px-3 py-2 border border-gray-300 rounded-md focus:outline-none focus:ring-2 focus:ring-yellow-500 focus:border-transparent"
placeholder="输入访问令牌"
/>
<p className="text-xs text-gray-500">
访
</p>
</div>
<div className="space-y-2">
<label htmlFor="geminiApiKey" className="text-sm font-medium text-gray-700">
Gemini API
</label>
<input
id="geminiApiKey"
type="password"
value={geminiApiKey}
onChange={(e) => setGeminiApiKey(e.target.value)}
className="w-full px-3 py-2 border border-gray-300 rounded-md focus:outline-none focus:ring-2 focus:ring-yellow-500 focus:border-transparent"
placeholder="输入Gemini API密钥"
/>
<p className="text-xs text-gray-500">
AI图像生成和编辑的Google Gemini API密钥
</p>
</div>
<div className="space-y-2">
<label htmlFor="uploadApiUrl" className="text-sm font-medium text-gray-700">
URL
</label>
<input
id="uploadApiUrl"
type="text"
value={uploadApiUrl}
onChange={(e) => setUploadApiUrl(e.target.value)}
className="w-full px-3 py-2 border border-gray-300 rounded-md focus:outline-none focus:ring-2 focus:ring-yellow-500 focus:border-transparent"
placeholder="输入上传接口URL"
/>
<p className="text-xs text-gray-500">
URL
</p>
</div>
<div className="space-y-2">
<label htmlFor="uploadAssetUrl" className="text-sm font-medium text-gray-700">
URL
</label>
<input
id="uploadAssetUrl"
type="text"
value={uploadAssetUrl}
onChange={(e) => setUploadAssetUrl(e.target.value)}
className="w-full px-3 py-2 border border-gray-300 rounded-md focus:outline-none focus:ring-2 focus:ring-yellow-500 focus:border-transparent"
placeholder="输入上传资源URL前缀"
/>
<p className="text-xs text-gray-500">
URL前缀
</p>
</div>
<div className="space-y-2">
<label htmlFor="modelName" className="text-sm font-medium text-gray-700">
</label>
<input
id="modelName"
type="text"
value={modelName}
onChange={(e) => setModelName(e.target.value)}
className="w-full px-3 py-2 border border-gray-300 rounded-md focus:outline-none focus:ring-2 focus:ring-yellow-500 focus:border-transparent"
placeholder="输入模型名称"
/>
<p className="text-xs text-gray-500">
AI图像生成和编辑的Google Gemini模型名称
</p>
</div>
</div>
<div className="flex justify-between pt-4">
<Button
variant="outline"
onClick={handleReset}
className="px-4 py-2 text-sm"
>
</Button>
<div className="space-x-2">
<Dialog.Close asChild>
<Button
variant="outline"
className="px-4 py-2 text-sm"
>
</Button>
</Dialog.Close>
<Button
onClick={handleSave}
className="px-4 py-2 text-sm bg-yellow-500 hover:bg-yellow-600 text-white"
>
</Button>
</div>
</div>
</div>
</Dialog.Content>
</Dialog.Portal>
</Dialog.Root>
);
};

View File

@@ -13,6 +13,7 @@ export interface ToastProps {
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);
@@ -38,12 +39,16 @@ export const Toast: React.FC<ToastProps> = ({ id, message, type, details, onClos
hoverTimeoutRef.current = null;
}
setIsHovered(true);
onHoverChange?.(true);
};
const handleMouseLeave = () => {
// Immediately mark as not hovered
onHoverChange?.(false);
// Set a timeout to mark as not hovered after 1 second
hoverTimeoutRef.current = setTimeout(() => {
setIsHovered(false);
onHoverChange?.(false);
}, 1000);
};
const handleClose = () => {

View File

@@ -50,11 +50,6 @@ export const ToastProvider: React.FC<{ children: React.ReactNode }> = ({ childre
};
const removeToast = (id: string) => {
// Clear any existing timeout for this toast
if (hoverTimeouts.current[id]) {
clearTimeout(hoverTimeouts.current[id]);
delete hoverTimeouts.current[id];
}
dispatch({ type: 'REMOVE_TOAST', payload: id });
};
@@ -64,24 +59,21 @@ export const ToastProvider: React.FC<{ children: React.ReactNode }> = ({ childre
// Auto remove toasts after duration, but respect hover state
useEffect(() => {
// Create a copy of current timeouts to track which ones we need to clear
const currentTimeouts = { ...hoverTimeouts.current };
toasts.forEach(toast => {
const timers = toasts.map(toast => {
// Clear any existing timeout for this toast
if (currentTimeouts[toast.id]) {
clearTimeout(currentTimeouts[toast.id]);
delete currentTimeouts[toast.id];
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;
return null;
}
// If duration is 0, it's persistent
if (toast.duration === 0) {
return;
return null;
}
// Set timeout to remove toast
@@ -89,23 +81,14 @@ export const ToastProvider: React.FC<{ children: React.ReactNode }> = ({ childre
removeToast(toast.id);
}, toast.duration);
hoverTimeouts.current[toast.id] = timeout;
return { id: toast.id, timeout };
});
// Clear any remaining timeouts for toasts that no longer exist
Object.entries(currentTimeouts).forEach(([id, timeout]) => {
clearTimeout(timeout);
delete hoverTimeouts.current[id];
});
// Cleanup function for when component unmounts or toasts change
// Cleanup function
return () => {
// Clear all active timeouts
Object.values(hoverTimeouts.current).forEach(timeout => {
clearTimeout(timeout);
timers.forEach(timer => {
if (timer) clearTimeout(timer.timeout);
});
// Reset the timeouts object
hoverTimeouts.current = {};
};
}, [toasts]);

View File

@@ -1,11 +1,6 @@
import React from 'react';
import { cva, type VariantProps } from 'class-variance-authority';
import { cn } from '../../utils/cn';
const textareaVariants = cva(
'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'
);
export interface TextareaProps
extends React.TextareaHTMLAttributes<HTMLTextAreaElement>,
VariantProps<typeof textareaVariants> {
@@ -16,7 +11,10 @@ export const Textarea = React.forwardRef<HTMLTextAreaElement, TextareaProps>(
({ className, ...props }, ref) => {
return (
<textarea
className={cn(textareaVariants(), className)}
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}
/>

View File

@@ -6,7 +6,7 @@ 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'
import { blobToBase64 } from '../utils/imageUtils'
export const useImageGeneration = () => {
const { addGeneration, setIsGenerating, setCanvasImage } = useAppStore()
@@ -30,35 +30,35 @@ export const useImageGeneration = () => {
abortControllerRef.current = new AbortController()
// 将参考图像从base64转换为Blob如果需要
let blobReferenceImages: Blob[] | undefined
let blobReferenceImages: Blob[] | undefined;
if (request.referenceImages) {
blobReferenceImages = []
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)
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)
ia[i] = byteString.charCodeAt(i);
}
const blob = new Blob([ab], { type: mimeString })
blobReferenceImages.push(blob)
const blob = new Blob([ab], { type: mimeString });
blobReferenceImages.push(blob);
} else {
// 如果已经是Blob直接使用
blobReferenceImages.push(img)
blobReferenceImages.push(img);
}
}
// 保存参考图像Blob的引用
referenceImageBlobsRef.current = blobReferenceImages
referenceImageBlobsRef.current = blobReferenceImages;
}
const blobRequest: GenerationRequest = {
...request,
referenceImages: blobReferenceImages,
abortSignal: abortControllerRef.current.signal,
}
abortSignal: abortControllerRef.current.signal
};
const result = await geminiService.generateImage(blobRequest)
@@ -73,98 +73,94 @@ export const useImageGeneration = () => {
setIsGenerating(true)
},
onSuccess: async (result, request) => {
const { images, usageMetadata } = result
const { images, usageMetadata } = result;
if (images.length > 0) {
// 直接使用Blob并创建URL避免存储base64数据
const outputAssets: Asset[] = await Promise.all(
images.map(async blob => {
// 使用AppStore的addBlob方法存储Blob并获取URL
const blobUrl = useAppStore.getState().addBlob(blob)
const outputAssets: Asset[] = await Promise.all(images.map(async (blob) => {
// 使用AppStore的addBlob方法存储Blob并获取URL
const blobUrl = useAppStore.getState().addBlob(blob);
// 生成校验和使用Blob的一部分数据
const checksum = await (async () => {
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')
}
return checksum || generateId().slice(0, 32)
} catch {
return generateId().slice(0, 32)
// 生成校验和使用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');
}
})()
return {
id: generateId(),
type: 'output',
url: blobUrl, // 存储Blob URL而不是base64
mime: 'image/png',
width: 1024, // 默认Gemini输出尺寸
height: 1024,
checksum, // 使用生成的校验和
resolve(checksum || generateId().slice(0, 32));
} catch {
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 = localStorage.getItem('VITE_ACCESS_TOKEN') || ''
let uploadResults: Array<{ success: boolean; url?: string; error?: string; timestamp: number }> | undefined
const accessToken = import.meta.env.VITE_ACCESS_TOKEN || '';
let uploadResults: Array<{success: boolean, url?: string, error?: string, timestamp: number}> | undefined;
// 上传生成的图像和参考图像
if (accessToken) {
try {
// 上传生成的图像(跳过缓存,因为这些是新生成的图像)
const imageUrls = outputAssets.map(asset => asset.url)
const outputUploadResults = await uploadImages(imageUrls, accessToken, true)
const imageUrls = outputAssets.map(asset => asset.url);
const outputUploadResults = await uploadImages(imageUrls, accessToken, true);
// 上传参考图像(如果存在,使用缓存机制)
let referenceUploadResults: Array<{ success: boolean; url?: string; error?: string; timestamp: number }> = []
let referenceUploadResults: Array<{success: boolean, url?: string, error?: string, timestamp: number}> = [];
if (request.referenceImages && request.referenceImages.length > 0) {
// 将参考图像转换为base64字符串格式上传与老版本保持一致
const referenceBase64s = await Promise.all(
request.referenceImages.map(async blob => {
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(referenceBase64s, accessToken, false)
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(referenceBase64s, accessToken, false);
}
// 合并上传结果
uploadResults = [...outputUploadResults, ...referenceUploadResults]
uploadResults = [...outputUploadResults, ...referenceUploadResults];
// 检查上传结果
const failedUploads = uploadResults.filter(r => !r.success)
const failedUploads = uploadResults.filter(r => !r.success);
if (failedUploads.length > 0) {
console.warn(`${failedUploads.length}张图像上传失败`)
addToast(`${failedUploads.length}张图像上传失败`, 'warning', 5000)
console.warn(`${failedUploads.length}张图像上传失败`);
addToast(`${failedUploads.length}张图像上传失败`, 'warning', 5000);
} else {
console.log(`${uploadResults.length}张图像全部上传成功`)
addToast('图像已成功上传', 'success', 3000)
console.log(`${uploadResults.length}张图像全部上传成功`);
addToast('图像已成功上传', 'success', 3000);
}
} catch (error) {
console.error('上传图像时出错:', error)
addToast('图像上传失败', 'error', 5000)
uploadResults = undefined
} catch {
console.error('上传图像时出错:', error);
addToast('图像上传失败', 'error', 5000);
uploadResults = undefined;
}
} else {
console.warn('未找到accessToken跳过上传')
console.warn('未找到accessToken跳过上传');
}
// 显示Token消耗信息如果可用
if (usageMetadata?.totalTokenCount) {
addToast(`本次生成消耗 ${usageMetadata.totalTokenCount} Tokens`, 'info', 3000)
addToast(`本次生成消耗 ${usageMetadata.totalTokenCount} Tokens`, 'info', 3000);
}
const generation: Generation = {
@@ -173,64 +169,60 @@ export const useImageGeneration = () => {
parameters: {
aspectRatio: '1:1',
seed: request.seed,
temperature: request.temperature,
temperature: request.temperature
},
sourceAssets: request.referenceImages
? await Promise.all(
request.referenceImages.map(async blob => {
// 将参考图像转换为Blob URL
const blobUrl = useAppStore.getState().addBlob(blob)
sourceAssets: request.referenceImages ? await Promise.all(request.referenceImages.map(async (blob) => {
// 将参考图像转换为Blob URL
const blobUrl = useAppStore.getState().addBlob(blob);
// 生成校验和使用Blob的一部分数据
const checksum = await (async () => {
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')
}
return checksum || generateId().slice(0, 32)
} catch {
return generateId().slice(0, 32)
}
})()
// 生成校验和使用Blob的一部分数据
const checksum = await new Promise<string>(async (resolve) => {
try {
const arrayBuffer = await blob.slice(0, 32).arrayBuffer();
const uint8Array = new Uint8Array(arrayBuffer);
let checksum = '';
for (let i = 0; i < uint8Array.length; i++) {
checksum += uint8Array[i].toString(16).padStart(2, '0');
}
resolve(checksum || generateId().slice(0, 32));
} catch {
resolve(generateId().slice(0, 32));
}
});
return {
id: generateId(),
type: 'original' as const,
url: blobUrl, // 存储Blob URL而不是base64
mime: 'image/png',
width: 1024,
height: 1024,
checksum,
}
})
)
: [],
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到历史记录
}
usageMetadata: usageMetadata // 保存usageMetadata到历史记录
};
addGeneration(generation)
addGeneration(generation);
// 调试日志检查outputAssets
console.log('生成完成outputAssets:', outputAssets)
console.log('生成完成outputAssets:', outputAssets);
if (outputAssets && outputAssets.length > 0) {
console.log('第一个输出资产URL:', outputAssets[0].url)
setCanvasImage(outputAssets[0].url)
console.log('第一个输出资产URL:', outputAssets[0].url);
setCanvasImage(outputAssets[0].url);
} else {
console.error('生成完成但没有输出资产')
console.error('生成完成但没有输出资产');
}
// 自动选择新生成的记录
const { selectGeneration } = useAppStore.getState()
selectGeneration(generation.id)
const { selectGeneration } = useAppStore.getState();
selectGeneration(generation.id);
}
setIsGenerating(false)
setIsGenerating(false);
},
onError: error => {
console.error('生成失败:', error)
@@ -239,10 +231,10 @@ export const useImageGeneration = () => {
addToast(`图像生成失败: ${errorMessage}`, 'error', 5000, errorDetails)
setIsGenerating(false)
// 保持参考图像不变,以便用户可以重新尝试生成
console.log('生成失败,但参考图像已保留,用户可以重新尝试生成')
console.log('生成失败,但参考图像已保留,用户可以重新尝试生成');
// 如果有参考图像数据,确保它们不会被清除
if (referenceImageBlobsRef.current.length > 0) {
console.log(`保留了 ${referenceImageBlobsRef.current.length} 个参考图像,用户可以重新尝试生成`)
console.log(`保留了 ${referenceImageBlobsRef.current.length} 个参考图像,用户可以重新尝试生成`);
}
},
})
@@ -253,16 +245,12 @@ export const useImageGeneration = () => {
if (abortControllerRef.current) {
abortControllerRef.current.abort()
}
// 重置连续生成状态
const { setIsContinuousGenerating } = useAppStore.getState()
setIsContinuousGenerating(false)
setIsGenerating(false)
addToast('生成已中断', 'info', 3000)
}
return {
generate: generateMutation.mutate,
generateAsync: generateMutation.mutateAsync,
isGenerating: generateMutation.isPending,
error: generateMutation.error,
cancelGeneration,
@@ -293,102 +281,102 @@ export const useImageEditing = () => {
if (!sourceImage) throw new Error('没有要编辑的图像')
// 将画布图像转换为Blob
let originalImageBlob: 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
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)
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)
ia[i] = byteString.charCodeAt(i);
}
originalImageBlob = new Blob([ab], { type: mimeString })
originalImageBlob = new Blob([ab], { type: mimeString });
} else {
// 从URL获取Blob
const response = await fetch(sourceImage)
originalImageBlob = await response.blob()
const response = await fetch(sourceImage);
originalImageBlob = await response.blob();
}
// 获取用于样式指导的参考图像
let referenceImageBlobs: Blob[] = []
const updatedReferenceImageUrls: string[] = [...editReferenceImages] // 保存更新后的URL
let referenceImageBlobs: Blob[] = [];
const updatedReferenceImageUrls: string[] = [...editReferenceImages]; // 保存更新后的URL
for (let i = 0; i < editReferenceImages.length; i++) {
const img = editReferenceImages[i]
const img = editReferenceImages[i];
if (img.startsWith('blob:')) {
// 从Blob URL获取Blob数据
const blob = useAppStore.getState().getBlob(img)
const blob = useAppStore.getState().getBlob(img);
if (blob) {
referenceImageBlobs.push(blob)
referenceImageBlobs.push(blob);
} else {
// 如果在AppStore中找不到Blob尝试重新获取
try {
const response = await fetch(img)
const response = await fetch(img);
if (response.ok) {
const blob = await response.blob()
referenceImageBlobs.push(blob)
const blob = await response.blob();
referenceImageBlobs.push(blob);
// 重新添加到AppStore
const newUrl = useAppStore.getState().addBlob(blob)
const newUrl = useAppStore.getState().addBlob(blob);
// 更新editReferenceImages中的URL但不立即修改状态
updatedReferenceImageUrls[i] = newUrl
updatedReferenceImageUrls[i] = newUrl;
} else {
// 即使无法重新获取也要保留原始URL并在下次尝试时重新获取
console.warn('无法重新获取参考图像,将在下次尝试时重新获取:', img)
console.warn('无法重新获取参考图像,将在下次尝试时重新获取:', img);
}
} catch (error) {
} catch {
// 即使出现错误也要保留原始URL并在下次尝试时重新获取
console.warn('无法重新获取参考图像,将在下次尝试时重新获取:', img, error)
console.warn('无法重新获取参考图像,将在下次尝试时重新获取:', img, error);
}
}
} 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)
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 j = 0; j < byteString.length; j++) {
ia[j] = byteString.charCodeAt(j)
ia[j] = byteString.charCodeAt(j);
}
referenceImageBlobs.push(new Blob([ab], { type: mimeString }))
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)
const response = await fetch(img);
const blob = await response.blob();
referenceImageBlobs.push(blob);
} catch {
console.warn('无法获取参考图像:', img, error);
}
}
}
// 过滤掉无效的Blob只保留有效的参考图像
const validBlobs = referenceImageBlobs.filter(blob => blob.size > 0)
const validBlobs = referenceImageBlobs.filter(blob => blob.size > 0);
// 更新editReferenceImages状态如果需要
if (updatedReferenceImageUrls.some((url, index) => url !== editReferenceImages[index])) {
const { clearEditReferenceImages, addEditReferenceImage } = useAppStore.getState()
clearEditReferenceImages()
const { clearEditReferenceImages, addEditReferenceImage } = useAppStore.getState();
clearEditReferenceImages();
updatedReferenceImageUrls.forEach(imageUrl => {
if (imageUrl) {
addEditReferenceImage(imageUrl)
addEditReferenceImage(imageUrl);
}
})
});
}
// 使用有效的参考图像Blob
referenceImageBlobs = validBlobs
referenceImageBlobs = validBlobs;
let maskImageBlob: Blob | undefined
let maskedReferenceImage: string | undefined
let maskImageBlob: Blob | undefined;
let maskedReferenceImage: string | undefined;
// 如果存在画笔描边,则从描边创建遮罩
if (brushStrokes.length > 0) {
@@ -429,14 +417,14 @@ export const useImageEditing = () => {
// 将遮罩转换为Blob
maskImageBlob = await new Promise<Blob>((resolve, reject) => {
canvas.toBlob(blob => {
canvas.toBlob((blob) => {
if (blob) {
resolve(blob)
resolve(blob);
} else {
reject(new Error('无法创建遮罩图像Blob'))
reject(new Error('无法创建遮罩图像Blob'));
}
}, 'image/png')
})
}, 'image/png');
});
// 创建遮罩参考图像(带遮罩叠加的原始图像)
const maskedCanvas = document.createElement('canvas')
@@ -476,7 +464,7 @@ export const useImageEditing = () => {
maskedReferenceImage = maskedDataUrl.split('base64,')[1]
// 将遮罩图像作为参考添加到模型中
referenceImageBlobs = [maskImageBlob, ...referenceImageBlobs]
referenceImageBlobs = [maskImageBlob, ...referenceImageBlobs];
}
const request: EditRequest = {
@@ -485,8 +473,8 @@ export const useImageEditing = () => {
referenceImages: referenceImageBlobs.length > 0 ? referenceImageBlobs : undefined,
maskImage: maskImageBlob,
temperature,
seed: seed !== null ? seed : undefined,
abortSignal: abortControllerRef.current.signal,
seed,
abortSignal: abortControllerRef.current.signal
}
const result = await geminiService.editImage(request)
@@ -502,163 +490,162 @@ export const useImageEditing = () => {
setIsGenerating(true)
},
onSuccess: async ({ result, maskedReferenceImage }, instruction) => {
const { images, usageMetadata } = result
const { images, usageMetadata } = result;
if (images.length > 0) {
// 直接使用Blob并创建URL避免存储base64数据
const outputAssets: Asset[] = await Promise.all(
images.map(async blob => {
// 使用AppStore的addBlob方法存储Blob并获取URL
const blobUrl = useAppStore.getState().addBlob(blob)
// 生成校验和使用Blob的一部分数据
const checksum = await (async () => {
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')
}
return checksum || generateId().slice(0, 32)
} catch {
return 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 (async () => {
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')
}
return checksum || generateId().slice(0, 32)
} catch {
return generateId().slice(0, 32)
}
})()
return {
id: generateId(),
type: 'mask',
url: blobUrl, // 存储Blob URL而不是base64
mime: 'image/png',
width: 1024,
height: 1024,
checksum,
}
})()
: undefined
// 为编辑操作创建参考资产
const sourceAssets: Asset[] = referenceImageBlobs.map(blob => {
const outputAssets: Asset[] = await Promise.all(images.map(async (blob) => {
// 使用AppStore的addBlob方法存储Blob并获取URL
const blobUrl = useAppStore.getState().addBlob(blob)
const blobUrl = useAppStore.getState().addBlob(blob);
// 生成校验和使用Blob的一部分数据
const checksum = (() => {
const checksum = await new Promise<string>(async (resolve) => {
try {
const arrayBuffer = blob.slice(0, 32).arrayBuffer()
const uint8Array = new Uint8Array(arrayBuffer)
let checksum = ''
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')
checksum += uint8Array[i].toString(16).padStart(2, '0');
}
return checksum || generateId().slice(0, 32)
resolve(checksum || generateId().slice(0, 32));
} catch {
return generateId().slice(0, 32)
resolve(generateId().slice(0, 32));
}
})()
});
return {
id: generateId(),
type: 'reference',
url: blobUrl,
mime: blob.type || 'image/png',
type: 'output',
url: blobUrl, // 存储Blob URL而不是base64
mime: 'image/png',
width: 1024,
height: 1024,
checksum,
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 });
// 获取accessToken用于上传
const accessToken = localStorage.getItem('VITE_ACCESS_TOKEN') || ''
let uploadResults: Array<{ success: boolean; url?: string; error?: string; timestamp: number }> | undefined
// 使用AppStore的addBlob方法存储Blob并获取URL
const blobUrl = useAppStore.getState().addBlob(blob);
// 上传编辑后的图像和参考图像
// 生成校验和使用Blob的一部分数据
const checksum = await new Promise<string>(async (resolve) => {
try {
const arrayBuffer = await blob.slice(0, 32).arrayBuffer();
const uint8Array = new Uint8Array(arrayBuffer);
let checksum = '';
for (let i = 0; i < uint8Array.length; i++) {
checksum += uint8Array[i].toString(16).padStart(2, '0');
}
resolve(checksum || generateId().slice(0, 32));
} catch {
resolve(generateId().slice(0, 32));
}
});
return {
id: generateId(),
type: '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: Array<{success: boolean, url?: string, error?: string, timestamp: number}> | undefined;
// 上传编辑后的图像
if (accessToken) {
try {
// 上传生成的图像(跳过缓存,因为这些是新生成的图像)
const imageUrls = outputAssets.map(asset => asset.url)
const outputUploadResults = await uploadImages(imageUrls, accessToken, true)
const imageUrls = outputAssets.map(asset => asset.url);
// 上传编辑后的图像(跳过缓存,因为这些是新生成的图像)
const outputUploadResults = await uploadImages(imageUrls, accessToken, true);
// 上传参考图像(如果存在,使用缓存机制)
let referenceUploadResults: Array<{ success: boolean; url?: string; error?: string; timestamp: number }> = []
let referenceUploadResults: Array<{success: boolean, url?: string, error?: string, timestamp: number}> = [];
if (referenceImageBlobs.length > 0) {
// 将参考图像转换为base64字符串格式上传与老版本保持一致
const referenceBase64s = await Promise.all(
referenceImageBlobs.map(async blob => {
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)
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]
uploadResults = [...outputUploadResults, ...referenceUploadResults];
// 检查上传结果
const failedUploads = uploadResults.filter(r => !r.success)
const failedUploads = uploadResults.filter(r => !r.success);
if (failedUploads.length > 0) {
console.warn(`${failedUploads.length}张图像上传失败`)
addToast(`${failedUploads.length}张图像上传失败`, 'warning', 5000)
console.warn(`${failedUploads.length}编辑后的图像上传失败`);
addToast(`${failedUploads.length}编辑后的图像上传失败`, 'warning', 5000);
} else {
console.log(`${uploadResults.length}张图像全部上传成功`)
addToast('图像已成功上传', 'success', 3000)
console.log(`${uploadResults.length}编辑后的图像全部上传成功`);
addToast('编辑后的图像已成功上传', 'success', 3000);
}
} catch (error) {
console.error('上传图像时出错:', error)
addToast('图像上传失败', 'error', 5000)
uploadResults = undefined
} catch {
console.error('上传编辑后的图像时出错:', error);
addToast('编辑后的图像上传失败', 'error', 5000);
uploadResults = undefined;
}
} else {
console.warn('未找到accessToken跳过上传')
console.warn('未找到accessToken跳过上传');
}
// 显示Token消耗信息如果可用
if (usageMetadata?.totalTokenCount) {
addToast(`本次编辑消耗 ${usageMetadata.totalTokenCount} Tokens`, 'info', 3000);
}
// 将参考图像Blob转换为Asset对象
const sourceAssets: Asset[] = await Promise.all(referenceImageBlobs.map(async (blob) => {
// 使用AppStore的addBlob方法存储Blob并获取URL
const blobUrl = useAppStore.getState().addBlob(blob);
// 生成校验和使用Blob的一部分数据
const checksum = await new Promise<string>(async (resolve) => {
try {
const arrayBuffer = await blob.slice(0, 32).arrayBuffer();
const uint8Array = new Uint8Array(arrayBuffer);
let checksum = '';
for (let i = 0; i < uint8Array.length; i++) {
checksum += uint8Array[i].toString(16).padStart(2, '0');
}
resolve(checksum || generateId().slice(0, 32));
} catch {
resolve(generateId().slice(0, 32));
}
});
return {
id: generateId(),
type: 'original' as const,
url: blobUrl, // 存储Blob URL而不是base64
mime: 'image/png',
width: 1024,
height: 1024,
checksum
};
}));
const edit: Edit = {
id: generateId(),
parentGenerationId: selectedGenerationId || '',
@@ -671,20 +658,20 @@ export const useImageEditing = () => {
uploadResults: uploadResults,
parameters: {
seed: seed || undefined,
temperature: temperature,
temperature: temperature
},
usageMetadata: usageMetadata, // 保存usageMetadata到历史记录
}
usageMetadata: usageMetadata // 保存usageMetadata到历史记录
};
addEdit(edit)
addEdit(edit);
// 自动在画布中加载编辑后的图像
const { selectEdit, selectGeneration } = useAppStore.getState()
setCanvasImage(outputAssets[0].url)
selectEdit(edit.id)
selectGeneration(null)
const { selectEdit, selectGeneration } = useAppStore.getState();
setCanvasImage(outputAssets[0].url);
selectEdit(edit.id);
selectGeneration(null);
}
setIsGenerating(false)
setIsGenerating(false);
},
onError: error => {
console.error('编辑失败:', error)
@@ -693,7 +680,7 @@ export const useImageEditing = () => {
addToast(`图像编辑失败: ${errorMessage}`, 'error', 5000, errorDetails)
setIsGenerating(false)
// 保持参考图像不变,以便用户可以重新尝试编辑
console.log('编辑失败,但参考图像已保留,用户可以重新尝试编辑')
console.log('编辑失败,但参考图像已保留,用户可以重新尝试编辑');
},
})
@@ -703,9 +690,6 @@ export const useImageEditing = () => {
if (abortControllerRef.current) {
abortControllerRef.current.abort()
}
// 重置连续生成状态(如果正在运行)
const { setIsContinuousGenerating } = useAppStore.getState()
setIsContinuousGenerating(false)
setIsGenerating(false)
addToast('编辑已中断', 'info', 3000)
}

View File

@@ -233,52 +233,3 @@ body {
.card-lg {
@apply shadow-card-lg;
}
/* Electron window drag regions */
.draggable {
-webkit-app-region: drag;
}
.non-draggable {
-webkit-app-region: no-drag;
}
/* 窗口控制按钮 */
.window-controls {
display: flex;
align-items: center;
justify-content: flex-end;
padding: 0 10px;
height: 30px;
-webkit-app-region: no-drag;
}
.window-controls button {
width: 45px;
height: 30px;
border: none;
background: transparent;
color: #000;
font-size: 16px;
cursor: pointer;
outline: none;
}
.window-controls button:hover {
background: rgba(0, 0, 0, 0.1);
}
.window-controls .close:hover {
background: #ff5f56;
color: white;
}
.window-controls .minimize:hover {
background: #ffbd2e;
color: white;
}
.window-controls .maximize:hover {
background: #27c93f;
color: white;
}

View File

@@ -1,10 +1,7 @@
import { GoogleGenAI } from '@google/genai'
// 注意:在生产环境中,这应该通过后端代理处理
// 优先使用localStorage中的API密钥如果没有则使用环境变量中的最后使用默认值
const API_KEY = localStorage.getItem('VITE_GEMINI_API_KEY') || import.meta.env.VITE_GEMINI_API_KEY || 'demo-key'
// 优先使用localStorage中的模型名称如果没有则使用环境变量中的最后使用默认值
const MODEL_NAME = localStorage.getItem('VITE_GEMINI_MODEL_NAME') || import.meta.env.VITE_GEMINI_MODEL_NAME || 'gemini-2.5-flash-image-preview'
const API_KEY = import.meta.env.VITE_GEMINI_API_KEY || 'demo-key'
const genAI = new GoogleGenAI({ apiKey: API_KEY })
export interface GenerationRequest {
@@ -79,11 +76,6 @@ export class GeminiService {
async generateImage(request: GenerationRequest): Promise<{ images: Blob[]; usageMetadata?: UsageMetadata }> {
try {
// 在开始之前检查是否已中断
if (request.abortSignal?.aborted) {
throw new Error('生成已取消')
}
const contents: Array<{ text: string } | { inlineData: { mimeType: string; data: string } }> = [{ text: request.prompt }]
// 如果提供了参考图像则添加
@@ -93,11 +85,6 @@ export class GeminiService {
// 为每个参考图像生成或获取base64数据
for (const blob of request.referenceImages) {
// 在处理每个图像之前检查中断
if (request.abortSignal?.aborted) {
throw new Error('生成已取消')
}
// 生成Blob的唯一标识符
const blobId = await this.generateBlobId(blob)
@@ -126,11 +113,6 @@ export class GeminiService {
base64Images.push(base64)
}
// 再次检查中断状态
if (request.abortSignal?.aborted) {
throw new Error('生成已取消')
}
base64Images.forEach(image => {
// 确保图像数据不为空
if (image && image.length > 0) {
@@ -157,11 +139,11 @@ export class GeminiService {
// 准备请求配置包括abortSignal
const generateContentParams: {
model: string
contents: Array<{ text: string } | { inlineData: { mimeType: string; data: string } }>
config?: { httpOptions: { abortSignal: AbortSignal } }
model: string;
contents: Array<{ text: string } | { inlineData: { mimeType: string; data: string } }>;
config?: { httpOptions: { abortSignal: AbortSignal } };
} = {
model: MODEL_NAME,
model: 'gemini-2.5-flash-image-preview',
contents,
}
@@ -169,8 +151,8 @@ export class GeminiService {
if (request.abortSignal) {
generateContentParams.config = {
httpOptions: {
abortSignal: request.abortSignal,
},
abortSignal: request.abortSignal
}
}
}
@@ -182,7 +164,7 @@ export class GeminiService {
if (candidate.finishReason === 'PROHIBITED_CONTENT') {
throw new Error('内容被禁止:您的请求包含不允许的内容。请尝试其他提示。')
}
if (candidate.finishReason === 'IMAGE_SAFETY' || candidate.finishReason === 'IMAGE_OTHER') {
if (candidate.finishReason === 'IMAGE_SAFETY') {
throw new Error('图像安全检查失败:请求的图像内容可能不安全。请尝试调整提示词。')
}
// 检查finishReason为STOP但没有inlineData的情况
@@ -257,11 +239,6 @@ export class GeminiService {
async editImage(request: EditRequest): Promise<{ images: Blob[]; usageMetadata?: UsageMetadata }> {
try {
// 在开始之前检查是否已中断
if (request.abortSignal?.aborted) {
throw new Error('编辑已取消')
}
// 将原始图像Blob转换为base64以发送到API
let originalImageBase64: string
@@ -401,11 +378,11 @@ export class GeminiService {
// 准备请求配置包括abortSignal
const generateContentParams: {
model: string
contents: Array<{ text: string } | { inlineData: { mimeType: string; data: string } }>
config?: { httpOptions: { abortSignal: AbortSignal } }
model: string;
contents: Array<{ text: string } | { inlineData: { mimeType: string; data: string } }>;
config?: { httpOptions: { abortSignal: AbortSignal } };
} = {
model: MODEL_NAME,
model: 'gemini-2.5-flash-image-preview',
contents,
}
@@ -413,8 +390,8 @@ export class GeminiService {
if (request.abortSignal) {
generateContentParams.config = {
httpOptions: {
abortSignal: request.abortSignal,
},
abortSignal: request.abortSignal
}
}
}
@@ -426,7 +403,7 @@ export class GeminiService {
if (candidate.finishReason === 'PROHIBITED_CONTENT') {
throw new Error('内容被禁止:您的请求包含不允许的内容。请尝试其他提示。')
}
if (candidate.finishReason === 'IMAGE_SAFETY' || candidate.finishReason === 'IMAGE_OTHER') {
if (candidate.finishReason === 'IMAGE_SAFETY') {
throw new Error('图像安全检查失败:请求的图像内容可能不安全。请尝试调整提示词。')
}
// 检查finishReason为STOP但没有inlineData的情况
@@ -570,11 +547,11 @@ export class GeminiService {
// 准备请求配置包括abortSignal
const generateContentParams: {
model: string
contents: Array<{ text: string } | { inlineData: { mimeType: string; data: string } }>
config?: { httpOptions: { abortSignal: AbortSignal } }
model: string;
contents: Array<{ text: string } | { inlineData: { mimeType: string; data: string } }>;
config?: { httpOptions: { abortSignal: AbortSignal } };
} = {
model: MODEL_NAME,
model: 'gemini-2.5-flash-image-preview',
contents: prompt,
}
@@ -582,8 +559,8 @@ export class GeminiService {
if (request.abortSignal) {
generateContentParams.config = {
httpOptions: {
abortSignal: request.abortSignal,
},
abortSignal: request.abortSignal
}
}
}
@@ -595,7 +572,7 @@ export class GeminiService {
if (candidate.finishReason === 'PROHIBITED_CONTENT') {
throw new Error('内容被禁止:您的请求包含不允许的内容。请尝试其他提示。')
}
if (candidate.finishReason === 'IMAGE_SAFETY' || candidate.finishReason === 'IMAGE_OTHER') {
if (candidate.finishReason === 'IMAGE_SAFETY') {
throw new Error('图像安全检查失败:请求的图像内容可能不安全。请尝试调整提示词。')
}
// 检查finishReason为STOP但没有inlineData的情况

View File

@@ -192,7 +192,7 @@ const getUploadedAssetUrl = (record: Generation | Edit, assetId: string, assetTy
assetIndex = record.outputAssets.findIndex(a => a.id === assetId);
} else if (assetType === 'source') {
// 源资产参考图像的索引从outputAssets.length开始
assetIndex = record.sourceAssets?.findIndex(a => a.id === assetId) ?? -1;
assetIndex = record.sourceAssets?.findIndex(a => a.id === assetId);
if (assetIndex >= 0) {
assetIndex += record.outputAssets.length;
}

View File

@@ -2,32 +2,31 @@
import { UploadResult } from '../types'
// 上传接口URL
// 优先使用localStorage中的URL如果没有则使用环境变量中的
const UPLOAD_URL = localStorage.getItem('VITE_UPLOAD_API') || import.meta.env.VITE_UPLOAD_API || 'https://api.pandorastudio.cn/auth/OSSupload'
const UPLOAD_URL = import.meta.env.VITE_UPLOAD_API
// 创建一个Map来缓存已上传的图像
const uploadCache = new Map<string, UploadResult>()
// 缓存配置
const MAX_CACHE_SIZE = 50; // 减少最大缓存条目数
const CACHE_EXPIRY_TIME = 15 * 60 * 1000; // 缓存过期时间15分钟
const MAX_CACHE_SIZE = 20 // 减少最大缓存条目数
const CACHE_EXPIRY_TIME = 15 * 60 * 1000 // 缓存过期时间15分钟
/**
* 清理过期的缓存条目
*/
function cleanupExpiredCache(): void {
const now = Date.now();
let deletedCount = 0;
const now = Date.now()
let deletedCount = 0
uploadCache.forEach((value, key) => {
if (now - value.timestamp > CACHE_EXPIRY_TIME) {
uploadCache.delete(key);
deletedCount++;
uploadCache.delete(key)
deletedCount++
}
});
})
if (deletedCount > 0) {
console.log(`清除了 ${deletedCount} 个过期的缓存条目`);
console.log(`清除了 ${deletedCount} 个过期的缓存条目`)
}
}
@@ -38,16 +37,16 @@ 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 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%的条目
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]);
uploadCache.delete(entries[i][0])
}
console.log(`缓存已满,删除了 ${deleteCount} 个最旧的条目`);
console.log(`缓存已满,删除了 ${deleteCount} 个最旧的条目`)
}
}
@@ -61,17 +60,22 @@ function getImageHash(imageData: string): string {
if (imageData.startsWith('blob:')) {
// 对于Blob URL我们使用URL本身作为标识符的一部分
// 这不是完美的解决方案,但对于大多数情况足够了
return btoa(imageData).slice(0, 32);
try {
return btoa(imageData).slice(0, 32)
} catch (e) {
// 如果btoa失败例如包含非Latin1字符使用encodeURIComponent
return btoa(encodeURIComponent(imageData)).slice(0, 32)
}
}
// 对于base64数据使用简单的哈希函数生成图像标识符
let hash = 0;
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位整数
const char = imageData.charCodeAt(i)
hash = (hash << 5) - hash + char
hash = hash & hash // 转换为32位整数
}
return hash.toString();
return hash.toString()
}
/**
@@ -82,38 +86,17 @@ function getImageHash(imageData: string): string {
async function getBlobFromUrl(blobUrl: string): Promise<Blob> {
try {
// 从AppStore获取Blob
const { useAppStore } = await import('../store/useAppStore');
const blob = useAppStore.getState().getBlob(blobUrl);
const { useAppStore } = await import('../store/useAppStore')
const blob = useAppStore.getState().getBlob(blobUrl)
if (!blob) {
// 如果AppStore中没有找到Blob尝试从URL获取
console.warn('无法从AppStore获取Blob尝试从URL获取:', blobUrl);
try {
const response = await fetch(blobUrl);
if (!response.ok) {
throw new Error(`获取Blob失败: ${response.status} ${response.statusText}`);
}
return await response.blob();
} catch (error) {
console.error('从URL获取Blob失败:', error);
throw new Error('无法从Blob URL获取图像数据');
}
throw new Error('无法从AppStore获取BlobBlob可能已被清理');
}
return blob;
} catch (error) {
console.error('从AppStore获取Blob时出错:', error);
// 如果导入AppStore失败直接尝试从URL获取
try {
const response = await fetch(blobUrl);
if (!response.ok) {
throw new Error(`获取Blob失败: ${response.status} ${response.statusText}`);
}
return await response.blob();
} catch (fetchError) {
console.error('从URL获取Blob失败:', fetchError);
throw new Error('无法从Blob URL获取图像数据');
}
throw new Error('无法从Blob URL获取图像数据');
}
}
@@ -126,46 +109,53 @@ async function getBlobFromUrl(blobUrl: string): Promise<Blob> {
*/
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();
const imageHash = typeof imageData === 'string' ? getImageHash(imageData) : 'blob-' + Date.now()
if (!skipCache && typeof imageData === 'string' && uploadCache.has(imageHash)) {
const cachedResult = uploadCache.get(imageHash)!;
const cachedResult = uploadCache.get(imageHash)!
// 检查缓存是否过期
if (Date.now() - cachedResult.timestamp < CACHE_EXPIRY_TIME) {
console.log('从缓存中获取上传结果');
return cachedResult;
console.log('从缓存中获取上传结果')
// 确保返回的数据结构与新上传的结果一致
return {
success: cachedResult.success,
url: cachedResult.url,
error: cachedResult.error
}
} else {
// 缓存过期,删除它
uploadCache.delete(imageHash);
uploadCache.delete(imageHash)
}
}
try {
let blob: Blob;
let blob: Blob
if (typeof imageData === 'string') {
if (imageData.startsWith('blob:')) {
// 从Blob URL获取Blob数据
blob = await getBlobFromUrl(imageData);
blob = await getBlobFromUrl(imageData)
} else if (imageData.includes('base64,')) {
// 从base64数据创建Blob
const base64Data = imageData.split('base64,')[1];
const byteString = atob(base64Data);
const mimeString = 'image/png'; // 默认MIME类型
const ab = new ArrayBuffer(byteString.length);
const ia = new Uint8Array(ab);
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);
ia[i] = byteString.charCodeAt(i)
}
blob = new Blob([ab], { type: mimeString });
blob = new Blob([ab], { type: mimeString })
} else {
// 从URL获取Blob
const response = await fetch(imageData);
blob = await response.blob();
const response = await fetch(imageData)
blob = await response.blob()
}
} else {
// 如果已经是Blob对象直接使用
blob = imageData;
blob = imageData
}
// 创建FormData对象使用唯一文件名
@@ -179,68 +169,69 @@ export const uploadImage = async (imageData: string | Blob, accessToken: string,
const response = await fetch(UPLOAD_URL, {
method: 'POST',
headers: {
'accessToken': accessToken,
accessToken: accessToken,
// 添加其他可能需要的头部
},
body: formData,
});
})
// 记录响应状态以帮助调试
console.log('上传响应状态:', response.status, response.statusText);
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 errorText = await response.text()
console.error('上传失败响应内容:', errorText)
throw new Error(`上传失败: ${response.status} ${response.statusText} - ${errorText}`)
}
const result = await response.json();
console.log('上传响应结果:', result);
const result = await response.json()
console.log('上传响应结果:', result)
// 根据返回格式处理结果: {"code": 200,"msg": "上传成功","data": "9ecbaa0a0.jpg"}
if (result.code === 200) {
// 使用localStorage中的VITE_UPLOAD_ASSET_URL作为前缀,如果没有则使用环境变量中的
const uploadAssetUrl = localStorage.getItem('VITE_UPLOAD_ASSET_URL') || import.meta.env.VITE_UPLOAD_ASSET_URL || '';
const fullUrl = uploadAssetUrl ? `${uploadAssetUrl}/${result.data}` : result.data;
// 使用环境变量中的VITE_UPLOAD_ASSET_URL作为前缀
const uploadAssetUrl = import.meta.env.VITE_UPLOAD_ASSET_URL || ''
const fullUrl = uploadAssetUrl ? `${uploadAssetUrl}/${result.data}` : result.data
// 清理过期缓存
cleanupExpiredCache();
cleanupExpiredCache()
// 维护缓存大小
maintainCacheSize();
maintainCacheSize()
// 将上传结果存储到缓存中
const uploadResult = { success: true, url: fullUrl, error: undefined };
const uploadResult = { success: true, url: fullUrl, error: undefined }
if (typeof imageData === 'string') {
uploadCache.set(imageHash, {
...uploadResult,
timestamp: Date.now()
});
timestamp: Date.now(),
})
}
return uploadResult;
return { success: true, url: fullUrl, error: undefined }
} else {
throw new Error(`上传失败: ${result.msg}`);
throw new Error(`上传失败: ${result.msg}`)
}
} catch (error) {
console.error('上传图像时出错:', error);
const errorResult = { success: false, error: error instanceof Error ? error.message : String(error) };
console.error('上传图像时出错:', error)
const errorMessage = error instanceof Error ? error.message : String(error)
const errorResult = { success: false, error: errorMessage }
// 清理过期缓存
cleanupExpiredCache();
cleanupExpiredCache()
// 维护缓存大小(即使是失败的结果也缓存,但时间较短)
maintainCacheSize();
maintainCacheSize()
// 将失败的上传结果也存储到缓存中(可选)
if (typeof imageData === 'string') {
uploadCache.set(imageHash, {
...errorResult,
timestamp: Date.now()
});
timestamp: Date.now(),
})
}
return errorResult;
return { success: false, error: errorMessage }
}
}
@@ -253,43 +244,43 @@ export const uploadImage = async (imageData: string | Blob, accessToken: string,
*/
export const uploadImages = async (imageDatas: (string | Blob)[], accessToken: string, skipCache: boolean = false): Promise<UploadResult[]> => {
try {
const results: UploadResult[] = [];
const results: UploadResult[] = []
for (let i = 0; i < imageDatas.length; i++) {
const imageData = imageDatas[i];
const imageData = imageDatas[i]
try {
const uploadResult = await uploadImage(imageData, accessToken, skipCache);
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);
}
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);
}
results.push(result)
console.error(`${i + 1}张图像上传失败:`, error)
}
}
// 检查是否有任何上传失败
const failedUploads = results.filter(r => !r.success);
const failedUploads = results.filter(r => !r.success)
if (failedUploads.length > 0) {
console.warn(`${failedUploads.length}张图像上传失败`);
console.warn(`${failedUploads.length}张图像上传失败`)
} else {
console.log(`所有${results.length}张图像上传成功`);
console.log(`所有${results.length}张图像上传成功`)
}
return results;
return results
} catch (error) {
console.error('批量上传图像时出错:', error);
throw error;
console.error('批量上传图像时出错:', error)
throw error
}
}
@@ -297,6 +288,6 @@ export const uploadImages = async (imageDatas: (string | Blob)[], accessToken: s
* 清除上传缓存
*/
export const clearUploadCache = (): void => {
uploadCache.clear();
console.log('上传缓存已清除');
uploadCache.clear()
console.log('上传缓存已清除')
}

View File

@@ -1,6 +1,6 @@
import { create } from 'zustand';
import { devtools, persist } from 'zustand/middleware';
import { Generation, Edit, BrushStroke, UploadResult, Asset } from '../types';
import { Generation, Edit, BrushStroke, UploadResult } from '../types';
import { generateId } from '../utils/imageUtils';
import * as indexedDBService from '../services/indexedDBService';
import * as referenceImageService from '../services/referenceImageService';
@@ -68,8 +68,6 @@ interface AppState {
// 生成状态
isGenerating: boolean;
isContinuousGenerating: boolean;
retryCount: number;
currentPrompt: string;
temperature: number;
seed: number | null;
@@ -109,14 +107,12 @@ interface AppState {
setShowMasks: (show: boolean) => void;
setIsGenerating: (generating: boolean) => void;
setIsContinuousGenerating: (generating: boolean) => void;
setRetryCount: (count: number) => void;
setCurrentPrompt: (prompt: string) => void;
setTemperature: (temp: number) => void;
setSeed: (seed: number | null) => void;
addGeneration: (generation: Generation) => void;
addEdit: (edit: Edit) => void;
addGeneration: (generation) => void;
addEdit: (edit) => void;
removeGeneration: (id: string) => void;
removeEdit: (id: string) => void;
selectGeneration: (id: string | null) => void;
@@ -161,8 +157,6 @@ export const useAppStore = create<AppState>()(
showMasks: true,
isGenerating: false,
isContinuousGenerating: false,
retryCount: 0,
currentPrompt: '',
temperature: 1,
seed: null,
@@ -260,8 +254,6 @@ export const useAppStore = create<AppState>()(
setShowMasks: (show) => set({ showMasks: show }),
setIsGenerating: (generating) => set({ isGenerating: generating }),
setIsContinuousGenerating: (generating) => set({ isContinuousGenerating: generating }),
setRetryCount: (count) => set({ retryCount: count }),
setCurrentPrompt: (prompt) => set({ currentPrompt: prompt }),
setTemperature: (temp) => set({ temperature: temp }),
setSeed: (seed) => set({ seed: seed }),
@@ -283,7 +275,7 @@ export const useAppStore = create<AppState>()(
return state.blobStore.get(url);
},
addGeneration: (generation: Generation) => {
addGeneration: (generation) => {
// 保存到IndexedDB
indexedDBService.addGeneration(generation).catch(err => {
console.error('保存生成记录到IndexedDB失败:', err);
@@ -291,7 +283,7 @@ export const useAppStore = create<AppState>()(
set((state) => {
// 将base64图像数据转换为Blob并存储
const sourceAssets = generation.sourceAssets.map((asset: Asset) => {
const sourceAssets = generation.sourceAssets.map(asset => {
if (asset.url.startsWith('data:')) {
// 从base64创建Blob
const base64 = asset.url.split(',')[1];
@@ -323,6 +315,17 @@ export const useAppStore = create<AppState>()(
};
} 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,
@@ -333,7 +336,7 @@ export const useAppStore = create<AppState>()(
blobUrl: asset.url
};
}
// 对于其他URL类型创建一个新的Blob URL
// 对于其他URL类型直接使用URL
return {
id: asset.id,
type: asset.type,
@@ -346,7 +349,7 @@ export const useAppStore = create<AppState>()(
});
// 将输出资产转换为Blob URL
const outputAssetsBlobUrls = generation.outputAssets.map((asset: Asset) => {
const outputAssetsBlobUrls = generation.outputAssets.map(asset => {
if (asset.url.startsWith('data:')) {
// 从base64创建Blob
const base64 = asset.url.split(',')[1];
@@ -423,7 +426,7 @@ export const useAppStore = create<AppState>()(
});
},
addEdit: (edit: Edit) => {
addEdit: (edit) => {
// 保存到IndexedDB
indexedDBService.addEdit(edit).catch(err => {
console.error('保存编辑记录到IndexedDB失败:', err);
@@ -455,7 +458,7 @@ export const useAppStore = create<AppState>()(
}
// 将输出资产转换为Blob URL
const outputAssetsBlobUrls = edit.outputAssets.map((asset: Asset) => {
const outputAssetsBlobUrls = edit.outputAssets.map(asset => {
if (asset.url.startsWith('data:')) {
// 从base64创建Blob
const base64 = asset.url.split(',')[1];
@@ -535,6 +538,98 @@ export const useAppStore = create<AppState>()(
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 {};
@@ -587,7 +682,7 @@ export const useAppStore = create<AppState>()(
// 释放所有Blob URLs
cleanupAllBlobUrls: () => set((state) => {
// 清理所有Blob URL
state.blobStore.forEach((_, url) => {
state.blobStore.forEach((blob, url) => {
if (url.startsWith('blob:')) {
URL.revokeObjectURL(url);
}

View File

@@ -54,41 +54,7 @@ export function generateId(): string {
return `${Date.now()}-${Math.random().toString(36).substr(2, 9)}`;
}
export async function downloadImage(imageData: string, filename: string): Promise<void> {
try {
// 使用fetch获取图像数据并创建Blob URL以确保正确下载
// 添加更多缓存控制头以绕过CDN缓存
const response = await fetch(imageData, {
headers: {
'Cache-Control': 'no-cache, no-store, must-revalidate',
'Pragma': 'no-cache',
'Expires': '0'
}
});
const blob = await response.blob();
const url = URL.createObjectURL(blob);
const link = document.createElement('a');
link.href = url;
link.download = filename;
document.body.appendChild(link);
link.click();
document.body.removeChild(link);
URL.revokeObjectURL(url);
} catch (error) {
console.error('下载图像失败:', error);
// 如果fetch失败回退到直接使用a标签
const link = document.createElement('a');
link.href = imageData;
link.download = filename;
link.target = '_blank';
document.body.appendChild(link);
link.click();
document.body.removeChild(link);
}
}
export function downloadImageSimple(imageData: string, filename: string): void {
export function downloadImage(imageData: string, filename: string): void {
if (imageData.startsWith('blob:')) {
// 对于Blob URL我们需要获取实际的Blob数据
fetch(imageData)

View File

@@ -1,30 +0,0 @@
{
"compilerOptions": {
"target": "ES2022",
"lib": [
"ES2023"
],
"module": "commonjs",
"skipLibCheck": true,
"outDir": "electron/dist",
"rootDir": "electron",
"strict": true,
"esModuleInterop": true,
"allowSyntheticDefaultImports": true,
"moduleResolution": "node",
"resolveJsonModule": true,
"types": [
"node"
],
"typeRoots": [
"node_modules/@types"
]
},
"include": [
"electron/**/*"
],
"exclude": [
"node_modules",
"dist"
]
}

View File

@@ -6,9 +6,7 @@
"types": [
"jest",
"node"
],
"module": "ESNext",
"moduleResolution": "node"
]
},
"include": [
"src/**/*",

View File

@@ -1,11 +1,8 @@
import { defineConfig } from 'vite';
import react from '@vitejs/plugin-react';
import * as path from 'path';
// https://vitejs.dev/config/
export default defineConfig({
base: './', // 添加这行以确保资源路径正确
publicDir: path.resolve(__dirname, 'public'), // 使用绝对路径指定 public 目录
plugins: [react()],
optimizeDeps: {
exclude: ['lucide-react'],
@@ -17,20 +14,4 @@ export default defineConfig({
'react-day-picker/locale': 'date-fns/locale',
},
},
build: {
outDir: 'dist',
assetsDir: 'assets',
rollupOptions: {
output: {
entryFileNames: 'assets/[name].[hash].js',
chunkFileNames: 'assets/[name].[hash].js',
assetFileNames: 'assets/[name].[hash].[ext]',
},
},
// 确保 public 目录下的文件被复制到 dist 目录
copyPublicDir: true,
},
define: {
'import.meta.env.VITE_DEV_SERVER_URL': JSON.stringify(process.env.VITE_DEV_SERVER_URL),
},
});