7 Commits

Author SHA1 Message Date
9359dd13de 修复(core): 改进图像生成和编辑的中断功能
- 在 geminiService 中添加多个 AbortSignal 检查点,确保及时响应中断请求
- 修复连续生成模式下的中断处理,避免中断后继续重试
- 在 cancelGeneration 和 cancelEdit 中添加连续生成状态重置
- 优化错误处理,区分用户中断和其他错误类型

解决了用户点击中断按钮后操作仍继续执行的问题
2025-12-22 21:33:25 +08:00
62946be82f 修复(ui): 解决Toast通知不正常关闭的问题
- 修复Toast组件中鼠标悬停状态的延迟通知逻辑
- 重构ToastContext中的定时器管理机制,防止内存泄漏
- 优化Toast的hover状态同步,确保定时器正确暂停和恢复
- 增强cleanup机制,清理已删除Toast的残留定时器
- 解决Toast在指定时间后不自动关闭的问题

修复内容:
- Toast.tsx: 移除鼠标离开后的1秒延迟,立即通知状态变化
- ToastContext.tsx: 改进定时器生命周期管理和清理逻辑

验证:
- 构建成功通过
- 所有测试套件通过(34个测试)
- Toast现在能正确按时关闭,悬停暂停功能正常
2025-12-22 21:23:26 +08:00
8d31b98736 修复(项目): 优化动态导入和测试配置
- 移除ImageCanvas和HistoryPanel中不必要的useAppStore动态导入
- 添加缺失的Jest测试依赖(jest, ts-jest, jest-environment-jsdom, identity-obj-proxy)
- 修复ImageCanvas测试中的React引用问题和forwardRef支持
- 清理因移除动态导入导致的语法错误
- 优化代码结构,提高构建性能

验证:
- 构建成功通过
- 所有5个测试套件通过(34个测试)
- TypeScript类型检查无错误
2025-12-22 21:12:40 +08:00
206dfbf12d \"新增 支持自定义Gemini模型名称配置\" 2025-10-20 21:40:52 +08:00
2b62027842 修复 无法上传问题 2025-10-07 00:53:54 +08:00
c21319fe3c 新增设置面板 2025-10-06 00:02:40 +08:00
29d4152e81 新增 现在支持编译为Windows桌面端 2025-10-05 05:45:34 +08:00
35 changed files with 6771 additions and 4539 deletions

View File

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

2
.gitignore vendored
View File

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

2
.npmrc Normal file
View File

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

View File

@@ -12,8 +12,9 @@
- **AI 集成**: Google Generative AI SDK (Gemini) - **AI 集成**: Google Generative AI SDK (Gemini)
- **数据存储**: IndexedDB (通过 idb-keyval) - **数据存储**: IndexedDB (通过 idb-keyval)
- **构建工具**: Vite - **构建工具**: Vite
- **桌面应用**: Electron
项目结构遵循标准的 React 应用组织方式,主要源代码位于 `src/` 目录下。 项目结构遵循标准的 React 应用组织方式,主要源代码位于 `src/` 目录下。该项目同时支持Web和桌面应用Electron
## 构建和运行 ## 构建和运行
@@ -31,15 +32,23 @@
3. **启动开发服务器**: 3. **启动开发服务器**:
```bash ```bash
# 启动Web开发服务器
npm run dev npm run dev
# 启动Electron开发环境
npm run electron:dev
``` ```
访问 `http://localhost:5173` 查看应用。 访问 `http://localhost:5173` 查看Web应用。
### 构建和部署 ### 构建和部署
- **构建生产版本**: - **构建生产版本**:
```bash ```bash
# 构建Web版本
npm run build npm run build
# 构建Electron桌面应用
npm run electron:build
``` ```
- **预览生产构建**: - **预览生产构建**:
@@ -82,26 +91,39 @@
src/ src/
├── components/ # React 组件 ├── components/ # React 组件
│ ├── ui/ # 可重用的 UI 组件 │ ├── ui/ # 可重用的 UI 组件
│ │ ├── Button.tsx
│ │ ├── Input.tsx
│ │ └── Textarea.tsx
│ ├── CustomTitleBar.tsx # 自定义标题栏用于Electron应用
│ ├── Header.tsx # 应用头部和导航
│ ├── PromptComposer.tsx # 提示输入和工具选择 │ ├── PromptComposer.tsx # 提示输入和工具选择
│ ├── ImageCanvas.tsx # 使用 Konva 的交互式画布 │ ├── ImageCanvas.tsx # 使用 Konva 的交互式画布
│ ├── HistoryPanel.tsx # 生成历史和变体 │ ├── HistoryPanel.tsx # 生成历史和变体
│ ├── Header.tsx # 应用头部和导航 │ ├── InfoModal.tsx # 关于模态框和链接
── InfoModal.tsx # 关于模态框和链接 ── Toast.tsx # 消息提示组件
│ └── ToastContext.tsx # 消息提示上下文
├── services/ # 外部服务集成 ├── services/ # 外部服务集成
│ ├── geminiService.ts # Gemini API 客户端 │ ├── geminiService.ts # Gemini API 客户端
│ ├── uploadService.ts # 图像上传服务 │ ├── indexedDBService.ts # IndexedDB 数据库服务
│ ├── cacheService.ts # IndexedDB 缓存层 │ ├── cacheService.ts # IndexedDB 缓存层(未使用)
── referenceImageService.ts # 参考图像处理 ── referenceImageService.ts # 参考图像处理(未使用)
│ └── uploadService.ts # 图像上传服务(未使用)
├── store/ # Zustand 状态管理 ├── store/ # Zustand 状态管理
│ └── useAppStore.ts # 全局应用状态 │ └── useAppStore.ts # 全局应用状态
├── hooks/ # 自定义 React 钩子 ├── hooks/ # 自定义 React 钩子
│ ├── useImageGeneration.ts # 生成和编辑逻辑 │ ├── useImageGeneration.ts # 生成和编辑逻辑
── useKeyboardShortcuts.ts # 键盘导航 ── useKeyboardShortcuts.ts # 键盘导航
│ └── useIndexedDBListener.ts # IndexedDB监听器未使用
├── utils/ # 工具函数 ├── utils/ # 工具函数
│ ├── cn.ts # 类名工具 │ ├── cn.ts # 类名工具
│ └── imageUtils.ts # 图像处理助手 │ └── imageUtils.ts # 图像处理助手
── types/ # TypeScript 类型定义 ── types/ # TypeScript 类型定义
└── index.ts # 核心类型定义 └── index.ts # 核心类型定义
└── __tests__/ # 测试文件
├── ImageCanvas.test.tsx
├── PromptComposer.test.tsx
├── useAppStore.test.ts
└── useImageGeneration.test.ts
``` ```
### 组件开发 ### 组件开发
@@ -131,3 +153,49 @@ src/
4. 彻底测试,确保键盘导航和可访问性 4. 彻底测试,确保键盘导航和可访问性
5. 记录更改,更新 README 并添加内联注释 5. 记录更改,更新 README 并添加内联注释
6. 遵守 AGPL-3.0 许可证 6. 遵守 AGPL-3.0 许可证
## 项目特性
### 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`: 构建工具

54
build/README.md Normal file
View File

@@ -0,0 +1,54 @@
# 应用图标
此目录包含用于打包桌面应用程序的图标文件。
## 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 文件时包含这些尺寸。

BIN
build/icon.ico Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 37 KiB

14
copy-favicon.js Normal file
View File

@@ -0,0 +1,14 @@
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');

57
electron/dev-runner.js Normal file
View File

@@ -0,0 +1,57 @@
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);
}
});

3
electron/index.js Normal file
View File

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

54
electron/main.js Normal file
View File

@@ -0,0 +1,54 @@
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.

113
electron/main.ts Normal file
View File

@@ -0,0 +1,113 @@
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.

3
electron/package.json Normal file
View File

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

20
electron/preload.js Normal file
View File

@@ -0,0 +1,20 @@
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;

56
electron/prod-runner.js Normal file
View File

@@ -0,0 +1,56 @@
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" /> <meta charset="UTF-8" />
<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>" /> <link rel="icon" type="image/svg+xml" href="/favicon.svg" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" /> <meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Nano Banana AI 图像编辑器 - AI 图像生成器和编辑器</title> <title>Nano Banana AI 图像编辑器 - AI 图像生成器和编辑器</title>
<meta name="description" content="由 Gemini 2.5 Flash Image 提供支持的专业 AI 图像生成和对话式编辑。使用自然语言提示创建、编辑和增强图像。" /> <meta name="description" content="由 Gemini 2.5 Flash Image 提供支持的专业 AI 图像生成和对话式编辑。使用自然语言提示创建、编辑和增强图像。" />

9080
package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

@@ -8,24 +8,30 @@
"type": "git", "type": "git",
"url": "https://git.pandorastudio.cn/yuantao/Nano-Banana-AI-Image-Editor.git" "url": "https://git.pandorastudio.cn/yuantao/Nano-Banana-AI-Image-Editor.git"
}, },
"author": {
"name": "潘哆呐科技",
"email": "work@pandorastudio.cn"
},
"main": "electron/index.js",
"scripts": { "scripts": {
"dev": "vite", "dev": "vite",
"build": "vite build", "build": "vite build && node copy-favicon.js",
"lint": "eslint .", "lint": "eslint .",
"preview": "vite preview", "preview": "vite preview",
"test": "jest", "test": "jest",
"test:watch": "jest --watch" "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"
}, },
"dependencies": { "dependencies": {
"@google/genai": "^1.16.0", "@google/genai": "^1.16.0",
"@radix-ui/react-dialog": "^1.1.15", "@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", "@tanstack/react-query": "^5.85.5",
"class-variance-authority": "^0.7.1", "class-variance-authority": "^0.7.1",
"clsx": "^2.1.1", "clsx": "^2.1.1",
"fabric": "^6.7.1",
"idb-keyval": "^6.2.2", "idb-keyval": "^6.2.2",
"konva": "^9.3.22", "konva": "^9.3.22",
"lucide-react": "^0.344.0", "lucide-react": "^0.344.0",
@@ -40,23 +46,51 @@
"@eslint/js": "^9.9.1", "@eslint/js": "^9.9.1",
"@testing-library/jest-dom": "^6.8.0", "@testing-library/jest-dom": "^6.8.0",
"@testing-library/react": "^16.3.0", "@testing-library/react": "^16.3.0",
"@testing-library/user-event": "^14.6.1",
"@types/jest": "^30.0.0", "@types/jest": "^30.0.0",
"@types/react": "^18.3.5",
"@types/react-dom": "^18.3.0", "@types/react-dom": "^18.3.0",
"@vitejs/plugin-react": "^4.3.1", "@vitejs/plugin-react": "^5.0.4",
"autoprefixer": "^10.4.18", "autoprefixer": "^10.4.21",
"cross-env": "^7.0.3",
"electron": "^38.2.1",
"electron-builder": "^26.0.12",
"eslint": "^9.9.1", "eslint": "^9.9.1",
"eslint-plugin-react-hooks": "^5.1.0-rc.0", "eslint-plugin-react-hooks": "^5.1.0-rc.0",
"eslint-plugin-react-refresh": "^0.4.11", "eslint-plugin-react-refresh": "^0.4.11",
"globals": "^15.9.0", "globals": "^15.9.0",
"identity-obj-proxy": "^3.0.0", "identity-obj-proxy": "^3.0.0",
"jest-environment-jsdom": "^30.1.2", "jest": "^30.2.0",
"postcss": "^8.4.35", "jest-environment-jsdom": "^30.2.0",
"tailwindcss": "^3.4.1", "postcss": "^8.5.6",
"ts-jest": "^29.4.3", "tailwindcss": "^3.4.15",
"ts-jest": "^29.4.6",
"typescript": "^5.5.3", "typescript": "^5.5.3",
"typescript-eslint": "^8.3.0", "typescript-eslint": "^8.3.0",
"vite": "^5.4.2" "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"
}
} }
} }

4
public/favicon.svg Normal file
View File

@@ -0,0 +1,4 @@
<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>

After

Width:  |  Height:  |  Size: 201 B

View File

@@ -1,7 +1,7 @@
import React, { useEffect, useState } from 'react'; import React, { useEffect, useState } from 'react';
import { QueryClient, QueryClientProvider } from '@tanstack/react-query'; import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
import { cn } from './utils/cn'; import { cn } from './utils/cn';
import { Header } from './components/Header'; import { CustomTitleBar } from './components/CustomTitleBar';
import { PromptComposer } from './components/PromptComposer'; import { PromptComposer } from './components/PromptComposer';
import { ImageCanvas } from './components/ImageCanvas'; import { ImageCanvas } from './components/ImageCanvas';
import { HistoryPanel } from './components/HistoryPanel'; import { HistoryPanel } from './components/HistoryPanel';
@@ -27,6 +27,30 @@ function AppContent() {
const [previewPosition, setPreviewPosition] = useState<{x: number, y: number} | null>(null); const [previewPosition, setPreviewPosition] = useState<{x: number, y: number} | null>(null);
const [isPreviewVisible, setIsPreviewVisible] = useState(false); 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数据 // 在挂载时初始化IndexedDB并清理base64数据
useEffect(() => { useEffect(() => {
const init = async () => { const init = async () => {
@@ -91,9 +115,7 @@ function AppContent() {
return ( return (
<div className="h-screen bg-gray-50 text-gray-900 flex flex-col font-sans"> <div className="h-screen bg-gray-50 text-gray-900 flex flex-col font-sans">
<div className="card card-lg rounded-none"> <CustomTitleBar />
<Header />
</div>
<div className="flex-1 flex overflow-hidden p-4 gap-4 relative"> <div className="flex-1 flex overflow-hidden p-4 gap-4 relative">
<div className={cn("flex-shrink-0 transition-all duration-300 ease-in-out overflow-hidden", showPromptPanel ? "w-72" : "w-8")}> <div className={cn("flex-shrink-0 transition-all duration-300 ease-in-out overflow-hidden", showPromptPanel ? "w-72" : "w-8")}>

View File

@@ -1,15 +1,16 @@
import { render, screen } from '@testing-library/react'; import { render, screen } from '@testing-library/react';
import '@testing-library/jest-dom'; import '@testing-library/jest-dom';
import React from 'react';
import { ImageCanvas } from '../components/ImageCanvas'; import { ImageCanvas } from '../components/ImageCanvas';
import { useAppStore } from '../store/useAppStore'; import { useAppStore } from '../store/useAppStore';
// Mock Konva components // Mock Konva components
jest.mock('react-konva', () => ({ jest.mock('react-konva', () => ({
Stage: ({ children, ...props }: { children: React.ReactNode; [key: string]: unknown }) => ( Stage: React.forwardRef(({ children, ...props }: { children: React.ReactNode; [key: string]: unknown }, ref: React.Ref<HTMLDivElement>) => (
<div data-testid="konva-stage" {...props}> <div data-testid="konva-stage" ref={ref} {...props}>
{children} {children}
</div> </div>
), )),
Layer: ({ children }: { children: React.ReactNode }) => <div data-testid="konva-layer">{children}</div>, Layer: ({ children }: { children: React.ReactNode }) => <div data-testid="konva-layer">{children}</div>,
Image: () => <div data-testid="konva-image" />, Image: () => <div data-testid="konva-image" />,
Line: () => <div data-testid="konva-line" /> Line: () => <div data-testid="konva-line" />

View File

@@ -0,0 +1,119 @@
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,10 +1,12 @@
import React, { useState } from 'react'; import React, { useState } from 'react';
import { Button } from './ui/Button'; import { Button } from './ui/Button';
import { HelpCircle } from 'lucide-react'; import { HelpCircle, Settings } from 'lucide-react';
import { InfoModal } from './InfoModal'; import { InfoModal } from './InfoModal';
import { SettingsModal } from './SettingsModal';
export const Header: React.FC = () => { export const Header: React.FC = () => {
const [showInfoModal, setShowInfoModal] = useState(false); const [showInfoModal, setShowInfoModal] = useState(false);
const [showSettingsModal, setShowSettingsModal] = useState(false);
return ( return (
<> <>
@@ -19,6 +21,14 @@ export const Header: React.FC = () => {
</div> </div>
<div className="flex items-center space-x-1"> <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 <Button
variant="ghost" variant="ghost"
size="icon" size="icon"
@@ -31,6 +41,7 @@ export const Header: React.FC = () => {
</header> </header>
<InfoModal open={showInfoModal} onOpenChange={setShowInfoModal} /> <InfoModal open={showInfoModal} onOpenChange={setShowInfoModal} />
<SettingsModal open={showSettingsModal} onOpenChange={setShowSettingsModal} />
</> </>
); );
}; };

View File

@@ -725,9 +725,7 @@ export const HistoryPanel: React.FC<{
console.error('图像加载失败:', error); console.error('图像加载失败:', error);
// 如果是Blob URL失效尝试重新获取 // 如果是Blob URL失效尝试重新获取
if (imageUrl.startsWith('blob:')) { if (imageUrl.startsWith('blob:')) {
import('../store/useAppStore').then((module) => { const blob = getBlob(imageUrl);
const useAppStore = module.useAppStore;
const blob = useAppStore.getState().getBlob(imageUrl);
if (blob) { if (blob) {
console.log('从AppStore找到Blob尝试重新创建URL...'); console.log('从AppStore找到Blob尝试重新创建URL...');
// 重新创建Blob URL // 重新创建Blob URL
@@ -767,20 +765,6 @@ export const HistoryPanel: React.FC<{
setPreviewPosition({ x: e.clientX, y: e.clientY }); setPreviewPosition({ x: e.clientX, y: e.clientY });
} }
} }
}).catch(err => {
console.error('导入AppStore时出错:', err);
// 即使图像加载失败,也显示预览
setHoveredImage({
url: imageUrl,
title: `生成记录 G${globalIndex + 1}`,
width: 0,
height: 0
});
// 传递鼠标位置信息给App组件
if (setPreviewPosition) {
setPreviewPosition({ x: e.clientX, y: e.clientY });
}
});
} else { } else {
// 即使图像加载失败,也显示预览 // 即使图像加载失败,也显示预览
setHoveredImage({ setHoveredImage({
@@ -1356,8 +1340,7 @@ export const HistoryPanel: React.FC<{
const img = new Image(); const img = new Image();
img.onerror = () => { img.onerror = () => {
// Blob URL可能已失效尝试重新创建 // Blob URL可能已失效尝试重新创建
import('../store/useAppStore').then((module) => { // 直接使用已导入的useAppStore
const useAppStore = module.useAppStore;
const blob = useAppStore.getState().getBlob(displayUrl); const blob = useAppStore.getState().getBlob(displayUrl);
if (blob) { if (blob) {
const newUrl = URL.createObjectURL(blob); const newUrl = URL.createObjectURL(blob);
@@ -1367,7 +1350,6 @@ export const HistoryPanel: React.FC<{
imgElement.src = newUrl; imgElement.src = newUrl;
} }
} }
});
}; };
img.src = displayUrl; img.src = displayUrl;
} }
@@ -1394,14 +1376,12 @@ export const HistoryPanel: React.FC<{
onError={(e) => { onError={(e) => {
// 如果图像加载失败尝试重新创建Blob URL // 如果图像加载失败尝试重新创建Blob URL
if (displayUrl.startsWith('blob:')) { if (displayUrl.startsWith('blob:')) {
import('../store/useAppStore').then((module) => { // 直接使用已导入的useAppStore
const useAppStore = module.useAppStore;
const blob = useAppStore.getState().getBlob(displayUrl); const blob = useAppStore.getState().getBlob(displayUrl);
if (blob) { if (blob) {
const newUrl = URL.createObjectURL(blob); const newUrl = URL.createObjectURL(blob);
(e.target as HTMLImageElement).src = newUrl; (e.target as HTMLImageElement).src = newUrl;
} }
});
} }
}} }}
/> />
@@ -1446,8 +1426,7 @@ export const HistoryPanel: React.FC<{
const img = new Image(); const img = new Image();
img.onerror = () => { img.onerror = () => {
// Blob URL可能已失效尝试重新创建 // Blob URL可能已失效尝试重新创建
import('../store/useAppStore').then((module) => { // 直接使用已导入的useAppStore
const useAppStore = module.useAppStore;
const blob = useAppStore.getState().getBlob(displayUrl); const blob = useAppStore.getState().getBlob(displayUrl);
if (blob) { if (blob) {
const newUrl = URL.createObjectURL(blob); const newUrl = URL.createObjectURL(blob);
@@ -1457,7 +1436,6 @@ export const HistoryPanel: React.FC<{
imgElement.src = newUrl; imgElement.src = newUrl;
} }
} }
});
}; };
img.src = displayUrl; img.src = displayUrl;
} }
@@ -1484,14 +1462,12 @@ export const HistoryPanel: React.FC<{
onError={(e) => { onError={(e) => {
// 如果图像加载失败尝试重新创建Blob URL // 如果图像加载失败尝试重新创建Blob URL
if (displayUrl.startsWith('blob:')) { if (displayUrl.startsWith('blob:')) {
import('../store/useAppStore').then((module) => { // 直接使用已导入的useAppStore
const useAppStore = module.useAppStore;
const blob = useAppStore.getState().getBlob(displayUrl); const blob = useAppStore.getState().getBlob(displayUrl);
if (blob) { if (blob) {
const newUrl = URL.createObjectURL(blob); const newUrl = URL.createObjectURL(blob);
(e.target as HTMLImageElement).src = newUrl; (e.target as HTMLImageElement).src = newUrl;
} }
});
} }
}} }}
/> />
@@ -1590,8 +1566,7 @@ export const HistoryPanel: React.FC<{
const img = new Image(); const img = new Image();
img.onerror = () => { img.onerror = () => {
// Blob URL可能已失效尝试重新创建 // Blob URL可能已失效尝试重新创建
import('../store/useAppStore').then((module) => { // 直接使用已导入的useAppStore
const useAppStore = module.useAppStore;
const blob = useAppStore.getState().getBlob(displayUrl); const blob = useAppStore.getState().getBlob(displayUrl);
if (blob) { if (blob) {
const newUrl = URL.createObjectURL(blob); const newUrl = URL.createObjectURL(blob);
@@ -1601,7 +1576,6 @@ export const HistoryPanel: React.FC<{
imgElement.src = newUrl; imgElement.src = newUrl;
} }
} }
});
}; };
img.src = displayUrl; img.src = displayUrl;
} }
@@ -1628,14 +1602,12 @@ export const HistoryPanel: React.FC<{
onError={(e) => { onError={(e) => {
// 如果图像加载失败尝试重新创建Blob URL // 如果图像加载失败尝试重新创建Blob URL
if (displayUrl.startsWith('blob:')) { if (displayUrl.startsWith('blob:')) {
import('../store/useAppStore').then((module) => { // 直接使用已导入的useAppStore
const useAppStore = module.useAppStore;
const blob = useAppStore.getState().getBlob(displayUrl); const blob = useAppStore.getState().getBlob(displayUrl);
if (blob) { if (blob) {
const newUrl = URL.createObjectURL(blob); const newUrl = URL.createObjectURL(blob);
(e.target as HTMLImageElement).src = newUrl; (e.target as HTMLImageElement).src = newUrl;
} }
});
} }
}} }}
/> />
@@ -1680,8 +1652,7 @@ export const HistoryPanel: React.FC<{
const img = new Image(); const img = new Image();
img.onerror = () => { img.onerror = () => {
// Blob URL可能已失效尝试重新创建 // Blob URL可能已失效尝试重新创建
import('../store/useAppStore').then((module) => { // 直接使用已导入的useAppStore
const useAppStore = module.useAppStore;
const blob = useAppStore.getState().getBlob(displayUrl); const blob = useAppStore.getState().getBlob(displayUrl);
if (blob) { if (blob) {
const newUrl = URL.createObjectURL(blob); const newUrl = URL.createObjectURL(blob);
@@ -1691,7 +1662,6 @@ export const HistoryPanel: React.FC<{
imgElement.src = newUrl; imgElement.src = newUrl;
} }
} }
});
}; };
img.src = displayUrl; img.src = displayUrl;
} }
@@ -1718,14 +1688,12 @@ export const HistoryPanel: React.FC<{
onError={(e) => { onError={(e) => {
// 如果图像加载失败尝试重新创建Blob URL // 如果图像加载失败尝试重新创建Blob URL
if (displayUrl.startsWith('blob:')) { if (displayUrl.startsWith('blob:')) {
import('../store/useAppStore').then((module) => { // 直接使用已导入的useAppStore
const useAppStore = module.useAppStore;
const blob = useAppStore.getState().getBlob(displayUrl); const blob = useAppStore.getState().getBlob(displayUrl);
if (blob) { if (blob) {
const newUrl = URL.createObjectURL(blob); const newUrl = URL.createObjectURL(blob);
(e.target as HTMLImageElement).src = newUrl; (e.target as HTMLImageElement).src = newUrl;
} }
});
} }
}} }}
/> />
@@ -1776,8 +1744,7 @@ export const HistoryPanel: React.FC<{
const img = new Image(); const img = new Image();
img.onerror = () => { img.onerror = () => {
// Blob URL可能已失效尝试重新创建 // Blob URL可能已失效尝试重新创建
import('../store/useAppStore').then((module) => { // 直接使用已导入的useAppStore
const useAppStore = module.useAppStore;
const blob = useAppStore.getState().getBlob(displayUrl); const blob = useAppStore.getState().getBlob(displayUrl);
if (blob) { if (blob) {
const newUrl = URL.createObjectURL(blob); const newUrl = URL.createObjectURL(blob);
@@ -1787,7 +1754,6 @@ export const HistoryPanel: React.FC<{
imgElement.src = newUrl; imgElement.src = newUrl;
} }
} }
});
}; };
img.src = displayUrl; img.src = displayUrl;
} }
@@ -1814,14 +1780,12 @@ export const HistoryPanel: React.FC<{
onError={(e) => { onError={(e) => {
// 如果图像加载失败尝试重新创建Blob URL // 如果图像加载失败尝试重新创建Blob URL
if (displayUrl.startsWith('blob:')) { if (displayUrl.startsWith('blob:')) {
import('../store/useAppStore').then((module) => { // 直接使用已导入的useAppStore
const useAppStore = module.useAppStore;
const blob = useAppStore.getState().getBlob(displayUrl); const blob = useAppStore.getState().getBlob(displayUrl);
if (blob) { if (blob) {
const newUrl = URL.createObjectURL(blob); const newUrl = URL.createObjectURL(blob);
(e.target as HTMLImageElement).src = newUrl; (e.target as HTMLImageElement).src = newUrl;
} }
});
} }
}} }}
/> />

View File

@@ -5,6 +5,7 @@ import type { Stage as StageType } from 'konva/lib/Stage';
import { useAppStore } from '../store/useAppStore'; import { useAppStore } from '../store/useAppStore';
import { Button } from './ui/Button'; import { Button } from './ui/Button';
import { ZoomIn, ZoomOut, RotateCcw, Download } from 'lucide-react'; import { ZoomIn, ZoomOut, RotateCcw, Download } from 'lucide-react';
import { downloadImage } from '../utils/imageUtils';
export const ImageCanvas: React.FC = () => { export const ImageCanvas: React.FC = () => {
const { const {
@@ -146,13 +147,10 @@ export const ImageCanvas: React.FC = () => {
const newUrl = URL.createObjectURL(blob); const newUrl = URL.createObjectURL(blob);
console.log('从IndexedDB创建新的Blob URL:', newUrl); console.log('从IndexedDB创建新的Blob URL:', newUrl);
// 更新canvasImage为新的URL // 更新canvasImage为新的URL
import('../store/useAppStore').then((storeModule) => {
const useAppStore = storeModule.useAppStore;
// 检查是否已取消 // 检查是否已取消
if (!isCancelled) { if (!isCancelled) {
useAppStore.getState().setCanvasImage(newUrl); useAppStore.getState().setCanvasImage(newUrl);
} }
});
} else { } else {
console.error('IndexedDB中未找到图像'); console.error('IndexedDB中未找到图像');
} }
@@ -179,8 +177,6 @@ export const ImageCanvas: React.FC = () => {
console.log('正在检查Blob URL是否有效...'); console.log('正在检查Blob URL是否有效...');
// 尝试从AppStore重新获取Blob并创建新的URL // 尝试从AppStore重新获取Blob并创建新的URL
import('../store/useAppStore').then((module) => {
const useAppStore = module.useAppStore;
const blob = useAppStore.getState().getBlob(canvasImage); const blob = useAppStore.getState().getBlob(canvasImage);
if (blob) { if (blob) {
// 检查是否已取消 // 检查是否已取消
@@ -224,14 +220,7 @@ export const ImageCanvas: React.FC = () => {
console.error('检查Blob URL时出错:', fetchErr); console.error('检查Blob URL时出错:', fetchErr);
}); });
} }
}).catch(err => {
// 检查是否已取消
if (isCancelled) {
return;
}
console.error('导入AppStore时出错:', err);
});
} }
}; };
@@ -392,7 +381,7 @@ export const ImageCanvas: React.FC = () => {
} }
}; };
const handleDownload = () => { const handleDownload = async () => {
// 首先尝试从当前选中的生成记录或编辑记录中获取上传后的URL // 首先尝试从当前选中的生成记录或编辑记录中获取上传后的URL
const { selectedGenerationId, selectedEditId, currentProject } = useAppStore.getState(); const { selectedGenerationId, selectedEditId, currentProject } = useAppStore.getState();
@@ -409,40 +398,11 @@ export const ImageCanvas: React.FC = () => {
// 下载第一个上传结果(通常是生成的图像) // 下载第一个上传结果(通常是生成的图像)
const uploadResult = selectedRecord.uploadResults[0]; const uploadResult = selectedRecord.uploadResults[0];
if (uploadResult.success && uploadResult.url) { if (uploadResult.success && uploadResult.url) {
// 使用fetch获取图像数据并创建Blob URL以确保正确下载 try {
// 添加更多缓存控制头以绕过CDN缓存 await downloadImage(uploadResult.url, `nano-banana-${Date.now()}.png`);
fetch(uploadResult.url, { } catch (error) {
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); console.error('下载图像失败:', error);
// 如果fetch失败回退到直接使用a标签
const link = document.createElement('a');
if (uploadResult.url) {
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; return;
} }
@@ -469,86 +429,15 @@ export const ImageCanvas: React.FC = () => {
// 如果Konva下载失败回退到下载原始图像 // 如果Konva下载失败回退到下载原始图像
if (canvasImage) { if (canvasImage) {
// 处理不同类型的URL try {
if (canvasImage.startsWith('data:')) { await downloadImage(canvasImage, `nano-banana-${Date.now()}.png`);
// base64格式 } catch (error) {
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); 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 ( return (
<div className="flex flex-col h-full"> <div className="flex flex-col h-full">

View File

@@ -334,6 +334,12 @@ export const PromptComposer: React.FC = () => {
// 开始连续生成循环 // 开始连续生成循环
const generateWithRetry = async () => { const generateWithRetry = async () => {
try { try {
// 在开始前检查是否仍在连续生成模式
if (!useAppStore.getState().isContinuousGenerating) {
console.log('连续生成已取消');
return;
}
// 即使没有参考图像也继续生成,因为提示文本是必需的 // 即使没有参考图像也继续生成,因为提示文本是必需的
await new Promise<void>((resolve, reject) => { await new Promise<void>((resolve, reject) => {
// 使用mutateAsync来等待结果 // 使用mutateAsync来等待结果
@@ -347,6 +353,13 @@ export const PromptComposer: React.FC = () => {
setIsContinuousGenerating(false); setIsContinuousGenerating(false);
resolve(); resolve();
}).catch((error) => { }).catch((error) => {
// 检查是否是因为中断导致的错误
if (error.message === '生成已中断' || error.message === '生成已取消') {
console.log('生成被用户中断');
setIsContinuousGenerating(false);
resolve(); // 不再重试
return;
}
// 生成失败,增加重试计数并继续 // 生成失败,增加重试计数并继续
const newCount = useAppStore.getState().retryCount + 1; const newCount = useAppStore.getState().retryCount + 1;
setRetryCount(newCount); setRetryCount(newCount);
@@ -359,7 +372,12 @@ export const PromptComposer: React.FC = () => {
// 如果仍在连续生成模式下,继续重试 // 如果仍在连续生成模式下,继续重试
if (useAppStore.getState().isContinuousGenerating) { if (useAppStore.getState().isContinuousGenerating) {
console.log('生成失败,正在重试...'); console.log('生成失败,正在重试...');
setTimeout(generateWithRetry, 1000); // 1秒后重试 // 再次检查连续生成状态
setTimeout(() => {
if (useAppStore.getState().isContinuousGenerating) {
generateWithRetry();
}
}, 1000); // 1秒后重试
} }
} }
}; };
@@ -371,6 +389,7 @@ export const PromptComposer: React.FC = () => {
// 取消连续生成 // 取消连续生成
const cancelContinuousGeneration = () => { const cancelContinuousGeneration = () => {
setIsContinuousGenerating(false); setIsContinuousGenerating(false);
// 立即调用 cancelGeneration 来中断当前请求
cancelGeneration(); cancelGeneration();
}; };

View File

@@ -0,0 +1,223 @@
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

@@ -42,10 +42,8 @@ export const Toast: React.FC<ToastProps> = ({ id, message, type, details, onClos
}; };
const handleMouseLeave = () => { const handleMouseLeave = () => {
// Set a timeout to mark as not hovered after 1 second // Immediately mark as not hovered
hoverTimeoutRef.current = setTimeout(() => {
onHoverChange?.(false); onHoverChange?.(false);
}, 1000);
}; };
const handleClose = () => { const handleClose = () => {

View File

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

View File

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

View File

@@ -233,3 +233,52 @@ body {
.card-lg { .card-lg {
@apply shadow-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,7 +1,10 @@
import { GoogleGenAI } from '@google/genai' import { GoogleGenAI } from '@google/genai'
// 注意:在生产环境中,这应该通过后端代理处理 // 注意:在生产环境中,这应该通过后端代理处理
const API_KEY = import.meta.env.VITE_GEMINI_API_KEY || 'demo-key' // 优先使用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 genAI = new GoogleGenAI({ apiKey: API_KEY }) const genAI = new GoogleGenAI({ apiKey: API_KEY })
export interface GenerationRequest { export interface GenerationRequest {
@@ -76,6 +79,11 @@ export class GeminiService {
async generateImage(request: GenerationRequest): Promise<{ images: Blob[]; usageMetadata?: UsageMetadata }> { async generateImage(request: GenerationRequest): Promise<{ images: Blob[]; usageMetadata?: UsageMetadata }> {
try { try {
// 在开始之前检查是否已中断
if (request.abortSignal?.aborted) {
throw new Error('生成已取消')
}
const contents: Array<{ text: string } | { inlineData: { mimeType: string; data: string } }> = [{ text: request.prompt }] const contents: Array<{ text: string } | { inlineData: { mimeType: string; data: string } }> = [{ text: request.prompt }]
// 如果提供了参考图像则添加 // 如果提供了参考图像则添加
@@ -85,6 +93,11 @@ export class GeminiService {
// 为每个参考图像生成或获取base64数据 // 为每个参考图像生成或获取base64数据
for (const blob of request.referenceImages) { for (const blob of request.referenceImages) {
// 在处理每个图像之前检查中断
if (request.abortSignal?.aborted) {
throw new Error('生成已取消')
}
// 生成Blob的唯一标识符 // 生成Blob的唯一标识符
const blobId = await this.generateBlobId(blob) const blobId = await this.generateBlobId(blob)
@@ -113,6 +126,11 @@ export class GeminiService {
base64Images.push(base64) base64Images.push(base64)
} }
// 再次检查中断状态
if (request.abortSignal?.aborted) {
throw new Error('生成已取消')
}
base64Images.forEach(image => { base64Images.forEach(image => {
// 确保图像数据不为空 // 确保图像数据不为空
if (image && image.length > 0) { if (image && image.length > 0) {
@@ -143,7 +161,7 @@ export class GeminiService {
contents: Array<{ text: string } | { inlineData: { mimeType: string; data: string } }> contents: Array<{ text: string } | { inlineData: { mimeType: string; data: string } }>
config?: { httpOptions: { abortSignal: AbortSignal } } config?: { httpOptions: { abortSignal: AbortSignal } }
} = { } = {
model: 'gemini-2.5-flash-image-preview', model: MODEL_NAME,
contents, contents,
} }
@@ -239,6 +257,11 @@ export class GeminiService {
async editImage(request: EditRequest): Promise<{ images: Blob[]; usageMetadata?: UsageMetadata }> { async editImage(request: EditRequest): Promise<{ images: Blob[]; usageMetadata?: UsageMetadata }> {
try { try {
// 在开始之前检查是否已中断
if (request.abortSignal?.aborted) {
throw new Error('编辑已取消')
}
// 将原始图像Blob转换为base64以发送到API // 将原始图像Blob转换为base64以发送到API
let originalImageBase64: string let originalImageBase64: string
@@ -382,7 +405,7 @@ export class GeminiService {
contents: Array<{ text: string } | { inlineData: { mimeType: string; data: string } }> contents: Array<{ text: string } | { inlineData: { mimeType: string; data: string } }>
config?: { httpOptions: { abortSignal: AbortSignal } } config?: { httpOptions: { abortSignal: AbortSignal } }
} = { } = {
model: 'gemini-2.5-flash-image-preview', model: MODEL_NAME,
contents, contents,
} }
@@ -551,7 +574,7 @@ export class GeminiService {
contents: Array<{ text: string } | { inlineData: { mimeType: string; data: string } }> contents: Array<{ text: string } | { inlineData: { mimeType: string; data: string } }>
config?: { httpOptions: { abortSignal: AbortSignal } } config?: { httpOptions: { abortSignal: AbortSignal } }
} = { } = {
model: 'gemini-2.5-flash-image-preview', model: MODEL_NAME,
contents: prompt, contents: prompt,
} }

View File

@@ -2,7 +2,8 @@
import { UploadResult } from '../types' import { UploadResult } from '../types'
// 上传接口URL // 上传接口URL
const UPLOAD_URL = 'https://api.pandorastudio.cn/auth/OSSupload' // 优先使用localStorage中的URL如果没有则使用环境变量中的
const UPLOAD_URL = localStorage.getItem('VITE_UPLOAD_API') || import.meta.env.VITE_UPLOAD_API || 'https://api.pandorastudio.cn/auth/OSSupload'
// 创建一个Map来缓存已上传的图像 // 创建一个Map来缓存已上传的图像
const uploadCache = new Map<string, UploadResult>() const uploadCache = new Map<string, UploadResult>()
@@ -198,8 +199,8 @@ export const uploadImage = async (imageData: string | Blob, accessToken: string,
// 根据返回格式处理结果: {"code": 200,"msg": "上传成功","data": "9ecbaa0a0.jpg"} // 根据返回格式处理结果: {"code": 200,"msg": "上传成功","data": "9ecbaa0a0.jpg"}
if (result.code === 200) { if (result.code === 200) {
// 使用环境变量中的VITE_UPLOAD_ASSET_URL作为前缀 // 使用localStorage中的VITE_UPLOAD_ASSET_URL作为前缀,如果没有则使用环境变量中的
const uploadAssetUrl = import.meta.env.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; const fullUrl = uploadAssetUrl ? `${uploadAssetUrl}/${result.data}` : result.data;
// 清理过期缓存 // 清理过期缓存

View File

@@ -54,7 +54,41 @@ export function generateId(): string {
return `${Date.now()}-${Math.random().toString(36).substr(2, 9)}`; return `${Date.now()}-${Math.random().toString(36).substr(2, 9)}`;
} }
export function downloadImage(imageData: string, filename: string): void { 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 {
if (imageData.startsWith('blob:')) { if (imageData.startsWith('blob:')) {
// 对于Blob URL我们需要获取实际的Blob数据 // 对于Blob URL我们需要获取实际的Blob数据
fetch(imageData) fetch(imageData)

30
tsconfig.electron.json Normal file
View File

@@ -0,0 +1,30 @@
{
"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

@@ -1,8 +1,11 @@
import { defineConfig } from 'vite'; import { defineConfig } from 'vite';
import react from '@vitejs/plugin-react'; import react from '@vitejs/plugin-react';
import * as path from 'path';
// https://vitejs.dev/config/ // https://vitejs.dev/config/
export default defineConfig({ export default defineConfig({
base: './', // 添加这行以确保资源路径正确
publicDir: path.resolve(__dirname, 'public'), // 使用绝对路径指定 public 目录
plugins: [react()], plugins: [react()],
optimizeDeps: { optimizeDeps: {
exclude: ['lucide-react'], exclude: ['lucide-react'],
@@ -14,4 +17,20 @@ export default defineConfig({
'react-day-picker/locale': 'date-fns/locale', '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),
},
}); });