6 Commits
futrue ... main

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
21 changed files with 516 additions and 1249 deletions

233
IFLOW.md
View File

@@ -1,133 +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
项目结构遵循标准的 React 应用组织方式,主要源代码位于 `src/` 目录下。 - **核心框架**: 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
npm run dev
```
访问 `http://localhost:5173` 查看应用。
### 构建和部署
- **构建生产版本**:
```bash
npm run 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 进行代码规范检查 - 组件文件和组件名使用 PascalCase (如 `Header.tsx`, `ImageCanvas`)
- 使用 Prettier 进行代码格式化 (通过 ESLint 配置集成) - 工具函数和普通文件使用 camelCase (如 `imageUtils.ts`, `useAppStore.ts`)
- 组件文件使用 `.tsx` 扩展名 - 常量使用 UPPER_SNAKE_CASE
- 工具函数文件使用 `.ts` 扩展名 - 变量和函数使用 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/ Nano-Banana-AI-Image-Editor/
├── components/ # React 组件 ├── src/
│ ├── ui/ # 可重用的 UI 组件 │ ├── components/ # React 组件
│ ├── PromptComposer.tsx # 提示输入和工具选择 │ ├── ui/ # 基础 UI 组件
│ ├── ImageCanvas.tsx # 使用 Konva 的交互式画布 │ ├── Header.tsx # 应用头部
│ ├── HistoryPanel.tsx # 生成历史和变体 │ ├── ImageCanvas.tsx # 图像画布
│ ├── Header.tsx # 应用头部和导航 │ ├── PromptComposer.tsx # 提示词编辑器
── InfoModal.tsx # 关于模态框和链接 ── HistoryPanel.tsx # 历史记录面板
├── services/ # 外部服务集成 │ │ └── ... # 其他组件
│ ├── geminiService.ts # Gemini API 客户端 │ ├── hooks/ # 自定义 React Hooks
│ ├── uploadService.ts # 图像上传服务 │ ├── useImageGeneration.ts # 图像生成 Hook
│ ├── cacheService.ts # IndexedDB 缓存层 │ ├── useIndexedDBListener.ts # IndexedDB 监听 Hook
│ └── referenceImageService.ts # 参考图像处理 │ └── useKeyboardShortcuts.ts # 键盘快捷键 Hook
├── store/ # Zustand 状态管理 ├── services/ # 业务逻辑服务
└── useAppStore.ts # 全局应用状态 │ ├── geminiService.ts # Gemini AI 服务
├── hooks/ # 自定义 React 钩子 ├── indexedDBService.ts # IndexedDB 操作服务
│ ├── useImageGeneration.ts # 生成和编辑逻辑 │ ├── uploadService.ts # 文件上传服务
│ └── useKeyboardShortcuts.ts # 键盘导航 │ └── cacheService.ts # 缓存服务
├── utils/ # 工具函数 │ ├── store/ # 状态管理
── cn.ts # 类名工具 ── useAppStore.ts # 全局状态管理 (Zustand)
── imageUtils.ts # 图像处理助手 ── utils/ # 工具函数
└── types/ # TypeScript 类型定义 │ │ ├── cn.ts # 类名合并工具
└── index.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行 ### 1. 图像画布 (ImageCanvas)
- 使用函数式组件React Hooks - 使用 Konva react-konva 实现图像显示和编辑
- 组件应具有明确的接口Props - 支持图像缩放、平移
- 尽可能使用 TypeScript 进行类型定义 - 实现画笔工具进行遮罩绘制
- 支持图像下载功能
### 状态管理 ### 2. 提示词编辑 (PromptComposer)
- 用户输入提示词生成图像
- 提供提示词建议功能
- 集成 AI 模型参数调整 (如风格、质量等)
- 全局状态使用 Zustand 管理 (`src/store/useAppStore.ts`) ### 3. 历史记录 (HistoryPanel)
- 服务端状态使用 React Query 管理 - 显示生成的图像历史
- 组件内部状态使用 React 的 useState 和 useReducer - 支持历史图像的查看和重新编辑
- 使用 IndexedDB 存储历史数据
### 测试 ### 4. 状态管理 (useAppStore)
- 使用 Zustand 管理全局状态
- 存储画布状态、用户设置、历史记录等
- 提供状态操作方法
- 使用 Jest 和 React Testing Library 进行测试 ### 5. AI 服务 (geminiService)
- 测试文件放在 `src/__tests__` 目录下 - 集成 Google Gemini AI 模型
- 测试文件名应与被测试文件名对应,加上 `.test.tsx` 或 `.test.ts` 后缀 - 实现图像生成和编辑功能
- 处理与 AI 模型的交互
### 贡献指南 ## 开发环境配置
1. 遵循既定的代码风格和项目结构 1. 安装依赖: `npm install`
2. 保持组件在 200 行以内 2. 启动开发服务器: `npm run dev`
3. 维护类型安全,严格使用 TypeScript 和正确定义 3. 构建生产版本: `npm run build`
4. 彻底测试,确保键盘导航和可访问性 4. 代码检查: `npm run lint`
5. 记录更改,更新 README 并添加内联注释
6. 遵守 AGPL-3.0 许可证 ## 注意事项
- 项目使用 IndexedDB 存储图像数据,需要注意存储空间管理
- AI 功能需要配置 Google API 密钥
- 图像处理功能依赖浏览器 Canvas API
- 移动端适配需要特别关注界面布局和交互

View File

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

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']
};

View File

@@ -1,4 +1,4 @@
import React, { useEffect, useState } from 'react'; import React, { useEffect, useState, useReducer } from 'react';
import { QueryClient, QueryClientProvider } from '@tanstack/react-query'; import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
import { cn } from './utils/cn'; import { cn } from './utils/cn';
import { Header } from './components/Header'; import { Header } from './components/Header';

View File

@@ -1,139 +0,0 @@
import { render, screen } from '@testing-library/react';
import '@testing-library/jest-dom';
import { ImageCanvas } from '../components/ImageCanvas';
import { useAppStore } from '../store/useAppStore';
// Mock Konva components
jest.mock('react-konva', () => ({
Stage: ({ children, ...props }: { children: React.ReactNode; [key: string]: unknown }) => (
<div data-testid="konva-stage" {...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,7 +1,7 @@
import React, { useState, useEffect, useCallback, useMemo } from 'react'; import React, { useState, useEffect, useCallback, useMemo } from 'react';
import { useAppStore } from '../store/useAppStore'; import { useAppStore } from '../store/useAppStore';
import { Button } from './ui/Button'; 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 { cn } from '../utils/cn';
import { ImagePreviewModal } from './ImagePreviewModal'; import { ImagePreviewModal } from './ImagePreviewModal';
import { useIndexedDBListener } from '../hooks/useIndexedDBListener'; import { useIndexedDBListener } from '../hooks/useIndexedDBListener';
@@ -200,7 +200,7 @@ export const HistoryPanel: React.FC<{
}, [displayGenerations, displayEdits, getBlob, decodedImages]); }, [displayGenerations, displayEdits, getBlob, decodedImages]);
// 获取上传后的图片链接 // 获取上传后的图片链接
const getUploadedImageUrl = (generationOrEdit: Generation | Edit, index: number) => { const getUploadedImageUrl = (generationOrEdit: any, index: number) => {
if (generationOrEdit.uploadResults && generationOrEdit.uploadResults[index]) { if (generationOrEdit.uploadResults && generationOrEdit.uploadResults[index]) {
const uploadResult = generationOrEdit.uploadResults[index]; const uploadResult = generationOrEdit.uploadResults[index];
if (uploadResult.success && uploadResult.url) { 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) { if (!showHistory) {
return ( 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 <button
onClick={() => setShowHistory(true)} 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="显示历史面板" title="显示历史面板"
> >
<div className="flex flex-col space-y-1"> <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 transition-colors duration-200"></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"></div> <div className="w-1 h-1 bg-gray-500 group-hover:bg-gray-400 rounded-full transition-colors duration-200"></div>
</div> </div>
</button> </button>
</div> </div>
@@ -537,8 +566,6 @@ export const HistoryPanel: React.FC<{
from: today, from: today,
to: today to: today
}); });
// 重置分页到第一页
setCurrentPage(1);
}} }}
> >

View File

@@ -1,7 +1,5 @@
import React, { useRef, useEffect, useState, useCallback } from 'react'; import React, { useRef, useEffect, useState, useCallback } from 'react';
import { Stage, Layer, Image as KonvaImage, Line } from 'react-konva'; 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 { 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';
@@ -10,7 +8,7 @@ export const ImageCanvas: React.FC = () => {
const { const {
canvasImage, canvasImage,
canvasZoom, canvasZoom,
// canvasPan, canvasPan,
setCanvasZoom, setCanvasZoom,
setCanvasPan, setCanvasPan,
brushStrokes, brushStrokes,
@@ -18,14 +16,12 @@ export const ImageCanvas: React.FC = () => {
showMasks, showMasks,
selectedTool, selectedTool,
isGenerating, isGenerating,
isContinuousGenerating,
retryCount,
brushSize, brushSize,
showHistory, showHistory,
showPromptPanel showPromptPanel
} = useAppStore(); } = useAppStore();
const stageRef = useRef<StageType>(null); const stageRef = useRef<any>(null);
const [image, setImage] = useState<HTMLImageElement | null>(null); const [image, setImage] = useState<HTMLImageElement | null>(null);
const [stageSize, setStageSize] = useState({ width: 800, height: 600 }); const [stageSize, setStageSize] = useState({ width: 800, height: 600 });
const [isDrawing, setIsDrawing] = useState(false); const [isDrawing, setIsDrawing] = useState(false);
@@ -300,16 +296,14 @@ export const ImageCanvas: React.FC = () => {
return () => container.removeEventListener('wheel', handleWheel); return () => container.removeEventListener('wheel', handleWheel);
}, [canvasZoom, handleZoom]); }, [canvasZoom, handleZoom]);
const handleMouseDown = (e: KonvaEventObject<MouseEvent>) => { const handleMouseDown = (e: Konva.KonvaEventObject<MouseEvent>) => {
if (selectedTool !== 'mask' || !image) return; if (selectedTool !== 'mask' || !image) return;
setIsDrawing(true); setIsDrawing(true);
const stage = e.target.getStage(); const stage = e.target.getStage();
if (!stage) return;
// 使用 Konva 的 getRelativePointerPosition 获取准确坐标 // 使用 Konva 的 getRelativePointerPosition 获取准确坐标
const relativePos = stage.getRelativePointerPosition(); const relativePos = stage.getRelativePointerPosition();
if (!relativePos) return;
// 计算图像在舞台上的边界 // 计算图像在舞台上的边界
const imageX = (stageSize.width / canvasZoom - image.width) / 2; const imageX = (stageSize.width / canvasZoom - image.width) / 2;
@@ -325,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; if (!isDrawing || selectedTool !== 'mask' || !image) return;
const stage = e.target.getStage(); const stage = e.target.getStage();
if (!stage) return;
// 使用 Konva 的 getRelativePointerPosition 获取准确坐标 // 使用 Konva 的 getRelativePointerPosition 获取准确坐标
const relativePos = stage.getRelativePointerPosition(); const relativePos = stage.getRelativePointerPosition();
if (!relativePos) return;
// 计算图像在舞台上的边界 // 计算图像在舞台上的边界
const imageX = (stageSize.width / canvasZoom - image.width) / 2; const imageX = (stageSize.width / canvasZoom - image.width) / 2;
@@ -361,7 +353,6 @@ export const ImageCanvas: React.FC = () => {
id: `stroke-${Date.now()}`, id: `stroke-${Date.now()}`,
points: currentStroke, points: currentStroke,
brushSize, brushSize,
color: '#A855F7',
}); });
setCurrentStroke([]); setCurrentStroke([]);
}; };
@@ -433,14 +424,12 @@ export const ImageCanvas: React.FC = () => {
console.error('下载图像失败:', error); console.error('下载图像失败:', error);
// 如果fetch失败回退到直接使用a标签 // 如果fetch失败回退到直接使用a标签
const link = document.createElement('a'); const link = document.createElement('a');
if (uploadResult.url) { link.href = uploadResult.url;
link.href = uploadResult.url; link.download = `nano-banana-${Date.now()}.png`;
link.download = `nano-banana-${Date.now()}.png`; link.target = '_blank';
link.target = '_blank'; document.body.appendChild(link);
document.body.appendChild(link); link.click();
link.click(); document.body.removeChild(link);
document.body.removeChild(link);
}
}); });
// 立即返回 // 立即返回
@@ -582,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="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" /> <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> <p className="text-gray-700 text-sm font-medium">...</p>
{/* 显示重试次数 */}
{isContinuousGenerating && (
<p className="text-gray-500 text-xs mt-2">
: {retryCount}
</p>
)}
</div> </div>
</div> </div>
)} )}
@@ -609,8 +592,8 @@ export const ImageCanvas: React.FC = () => {
} }
}} }}
onMouseDown={handleMouseDown} onMouseDown={handleMouseDown}
onMouseMove={handleMouseMove} onMousemove={handleMouseMove}
onMouseUp={handleMouseUp} onMouseup={handleMouseUp}
style={{ style={{
cursor: selectedTool === 'mask' ? 'crosshair' : 'default', cursor: selectedTool === 'mask' ? 'crosshair' : 'default',
zIndex: 10 zIndex: 10

View File

@@ -85,16 +85,12 @@ const ImagePreview: React.FC<{
onDragStart={(e) => onDragStart && onDragStart(e, index)} onDragStart={(e) => onDragStart && onDragStart(e, index)}
onDragOver={(e) => { onDragOver={(e) => {
e.preventDefault(); e.preventDefault();
if (onDragOver) { onDragOver && onDragOver(e, index);
onDragOver(e, index);
}
}} }}
onDragEnd={(e) => onDragEnd && onDragEnd(e)} onDragEnd={(e) => onDragEnd && onDragEnd(e)}
onDrop={(e) => { onDrop={(e) => {
e.preventDefault(); e.preventDefault();
if (onDrop) { onDrop && onDrop(e, index);
onDrop(e, index);
}
}} }}
> >
<img <img
@@ -130,8 +126,6 @@ export const PromptComposer: React.FC = () => {
seed, seed,
setSeed, setSeed,
isGenerating, isGenerating,
isContinuousGenerating,
retryCount,
uploadedImages, uploadedImages,
addUploadedImage, addUploadedImage,
removeUploadedImage, removeUploadedImage,
@@ -145,15 +139,11 @@ export const PromptComposer: React.FC = () => {
setCanvasImage, setCanvasImage,
showPromptPanel, showPromptPanel,
setShowPromptPanel, setShowPromptPanel,
clearBrushStrokes, clearBrushStrokes
setIsContinuousGenerating,
setRetryCount
} = useAppStore(); } = useAppStore();
const { generate, generateAsync, cancelGeneration } = useImageGeneration(); const { generate, cancelGeneration } = useImageGeneration();
const { edit, cancelEdit } = useImageEditing(); const { edit, cancelEdit } = useImageEditing();
// 连续生成状态已在AppStore中管理
const [showAdvanced, setShowAdvanced] = useState(false); const [showAdvanced, setShowAdvanced] = useState(false);
const [showPromptSuggestions, setShowPromptSuggestions] = useState(true); const [showPromptSuggestions, setShowPromptSuggestions] = useState(true);
const [showClearConfirm, setShowClearConfirm] = useState(false); const [showClearConfirm, setShowClearConfirm] = useState(false);
@@ -258,122 +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 {
// 即使没有参考图像也继续生成,因为提示文本是必需的
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) => {
// 生成失败,增加重试计数并继续
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(generateWithRetry, 1000); // 1秒后重试
}
}
};
// 启动连续生成
generateWithRetry();
};
// 取消连续生成
const cancelContinuousGeneration = () => {
setIsContinuousGenerating(false);
cancelGeneration();
};
const handleFileUpload = async (file: File) => { const handleFileUpload = async (file: File) => {
if (file && file.type.startsWith('image/')) { if (file && file.type.startsWith('image/')) {
try { try {
@@ -455,7 +329,7 @@ export const PromptComposer: React.FC = () => {
e.dataTransfer.setData('text/plain', index.toString()); e.dataTransfer.setData('text/plain', index.toString());
}; };
const handleDragOverPreview = (e: React.DragEvent<HTMLDivElement>) => { const handleDragOverPreview = (e: React.DragEvent<HTMLDivElement>, index: number) => {
e.preventDefault(); e.preventDefault();
e.dataTransfer.dropEffect = 'move'; e.dataTransfer.dropEffect = 'move';
}; };
@@ -502,16 +376,16 @@ export const PromptComposer: React.FC = () => {
if (!showPromptPanel) { if (!showPromptPanel) {
return ( 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 <button
onClick={() => setShowPromptPanel(true)} 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" 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="显示提示面板" title="显示提示面板"
> >
<div className="flex flex-col space-y-1"> <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 transition-colors duration-200"></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"></div> <div className="w-1 h-1 bg-gray-500 group-hover:bg-gray-400 rounded-full transition-colors duration-200"></div>
</div> </div>
</button> </button>
</div> </div>
@@ -692,45 +566,25 @@ export const PromptComposer: React.FC = () => {
{/* 生成按钮 */} {/* 生成按钮 */}
<div className="flex-shrink-0"> <div className="flex-shrink-0">
{isGenerating || isContinuousGenerating ? ( {isGenerating ? (
<div className="flex gap-3"> <div className="flex gap-3">
<Button <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" 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" /> <div className="animate-spin rounded-full h-5 w-5 border-b-2 border-white mr-2" />
</Button> </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>
) : ( ) : (
<div className="flex gap-2"> <Button
<Button onClick={handleGenerate}
onClick={handleGenerate} disabled={!currentPrompt.trim()}
disabled={!currentPrompt.trim()} className="w-full h-14 text-base font-semibold rounded-xl shadow-md hover:shadow-lg transition-all card"
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" />
<Wand2 className="h-5 w-5 mr-2" /> {selectedTool === 'generate' ? '生成图像' : '应用编辑'}
{selectedTool === 'generate' ? '生成图像' : '应用编辑'} </Button>
</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>
)} )}
</div> </div>

View File

@@ -13,6 +13,7 @@ export interface ToastProps {
export const Toast: React.FC<ToastProps> = ({ id, message, type, details, onClose, onHoverChange }) => { export const Toast: React.FC<ToastProps> = ({ id, message, type, details, onClose, onHoverChange }) => {
const [showDetails, setShowDetails] = useState(false); const [showDetails, setShowDetails] = useState(false);
const [isHovered, setIsHovered] = useState(false);
const [isExiting, setIsExiting] = useState(false); const [isExiting, setIsExiting] = useState(false);
const hoverTimeoutRef = useRef<NodeJS.Timeout | null>(null); const hoverTimeoutRef = useRef<NodeJS.Timeout | null>(null);
@@ -38,12 +39,14 @@ export const Toast: React.FC<ToastProps> = ({ id, message, type, details, onClos
hoverTimeoutRef.current = null; hoverTimeoutRef.current = null;
} }
setIsHovered(true);
onHoverChange?.(true); onHoverChange?.(true);
}; };
const handleMouseLeave = () => { const handleMouseLeave = () => {
// Set a timeout to mark as not hovered after 1 second // Set a timeout to mark as not hovered after 1 second
hoverTimeoutRef.current = setTimeout(() => { hoverTimeoutRef.current = setTimeout(() => {
setIsHovered(false);
onHoverChange?.(false); onHoverChange?.(false);
}, 1000); }, 1000);
}; };

View File

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

View File

@@ -6,7 +6,7 @@ import { generateId } from '../utils/imageUtils'
import { Generation, Edit, Asset } from '../types' import { Generation, Edit, Asset } from '../types'
import { useToast } from '../components/ToastContext' import { useToast } from '../components/ToastContext'
import { uploadImages } from '../services/uploadService' import { uploadImages } from '../services/uploadService'
// import { blobToBase64 } from '../utils/imageUtils' import { blobToBase64 } from '../utils/imageUtils'
export const useImageGeneration = () => { export const useImageGeneration = () => {
const { addGeneration, setIsGenerating, setCanvasImage } = useAppStore() const { addGeneration, setIsGenerating, setCanvasImage } = useAppStore()
@@ -81,7 +81,7 @@ export const useImageGeneration = () => {
const blobUrl = useAppStore.getState().addBlob(blob); const blobUrl = useAppStore.getState().addBlob(blob);
// 生成校验和使用Blob的一部分数据 // 生成校验和使用Blob的一部分数据
const checksum = await (async () => { const checksum = await new Promise<string>(async (resolve) => {
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);
@@ -89,11 +89,11 @@ export const useImageGeneration = () => {
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); resolve(checksum || generateId().slice(0, 32));
} catch { } catch {
return generateId().slice(0, 32); resolve(generateId().slice(0, 32));
} }
})(); });
return { return {
id: generateId(), id: generateId(),
@@ -107,7 +107,7 @@ export const useImageGeneration = () => {
})); }));
// 获取accessToken // 获取accessToken
const accessToken = (import.meta as unknown as { env: { VITE_ACCESS_TOKEN?: string } }).env.VITE_ACCESS_TOKEN || ''; const accessToken = import.meta.env.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;
// 上传生成的图像和参考图像 // 上传生成的图像和参考图像
@@ -149,7 +149,7 @@ export const useImageGeneration = () => {
console.log(`${uploadResults.length}张图像全部上传成功`); console.log(`${uploadResults.length}张图像全部上传成功`);
addToast('图像已成功上传', 'success', 3000); addToast('图像已成功上传', 'success', 3000);
} }
} catch (error) { } catch {
console.error('上传图像时出错:', error); console.error('上传图像时出错:', error);
addToast('图像上传失败', 'error', 5000); addToast('图像上传失败', 'error', 5000);
uploadResults = undefined; uploadResults = undefined;
@@ -176,7 +176,7 @@ export const useImageGeneration = () => {
const blobUrl = useAppStore.getState().addBlob(blob); const blobUrl = useAppStore.getState().addBlob(blob);
// 生成校验和使用Blob的一部分数据 // 生成校验和使用Blob的一部分数据
const checksum = await (async () => { const checksum = await new Promise<string>(async (resolve) => {
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);
@@ -184,17 +184,17 @@ export const useImageGeneration = () => {
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); resolve(checksum || generateId().slice(0, 32));
} catch { } catch {
return generateId().slice(0, 32); resolve(generateId().slice(0, 32));
} }
})(); });
return { return {
id: generateId(), id: generateId(),
type: 'original' as const, type: 'original' as const,
url: blobUrl, // 存储Blob URL而不是base64 url: blobUrl, // 存储Blob URL而不是base64
mime: 'image/png', mime: blob.type || 'image/png',
width: 1024, width: 1024,
height: 1024, height: 1024,
checksum checksum
@@ -251,7 +251,6 @@ export const useImageGeneration = () => {
return { return {
generate: generateMutation.mutate, generate: generateMutation.mutate,
generateAsync: generateMutation.mutateAsync,
isGenerating: generateMutation.isPending, isGenerating: generateMutation.isPending,
error: generateMutation.error, error: generateMutation.error,
cancelGeneration, cancelGeneration,
@@ -331,7 +330,7 @@ export const useImageEditing = () => {
// 即使无法重新获取也要保留原始URL并在下次尝试时重新获取 // 即使无法重新获取也要保留原始URL并在下次尝试时重新获取
console.warn('无法重新获取参考图像,将在下次尝试时重新获取:', img); console.warn('无法重新获取参考图像,将在下次尝试时重新获取:', img);
} }
} catch (error) { } catch {
// 即使出现错误也要保留原始URL并在下次尝试时重新获取 // 即使出现错误也要保留原始URL并在下次尝试时重新获取
console.warn('无法重新获取参考图像,将在下次尝试时重新获取:', img, error); console.warn('无法重新获取参考图像,将在下次尝试时重新获取:', img, error);
} }
@@ -353,7 +352,7 @@ export const useImageEditing = () => {
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 {
console.warn('无法获取参考图像:', img, error); console.warn('无法获取参考图像:', img, error);
} }
} }
@@ -474,7 +473,7 @@ export const useImageEditing = () => {
referenceImages: referenceImageBlobs.length > 0 ? referenceImageBlobs : undefined, referenceImages: referenceImageBlobs.length > 0 ? referenceImageBlobs : undefined,
maskImage: maskImageBlob, maskImage: maskImageBlob,
temperature, temperature,
seed: seed !== null ? seed : undefined, seed,
abortSignal: abortControllerRef.current.signal abortSignal: abortControllerRef.current.signal
} }
@@ -499,7 +498,7 @@ export const useImageEditing = () => {
const blobUrl = useAppStore.getState().addBlob(blob); const blobUrl = useAppStore.getState().addBlob(blob);
// 生成校验和使用Blob的一部分数据 // 生成校验和使用Blob的一部分数据
const checksum = await (async () => { const checksum = await new Promise<string>(async (resolve) => {
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);
@@ -507,11 +506,11 @@ export const useImageEditing = () => {
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); resolve(checksum || generateId().slice(0, 32));
} catch { } catch {
return generateId().slice(0, 32); resolve(generateId().slice(0, 32));
} }
})(); });
return { return {
id: generateId(), id: generateId(),
@@ -540,7 +539,7 @@ export const useImageEditing = () => {
const blobUrl = useAppStore.getState().addBlob(blob); const blobUrl = useAppStore.getState().addBlob(blob);
// 生成校验和使用Blob的一部分数据 // 生成校验和使用Blob的一部分数据
const checksum = await (async () => { const checksum = await new Promise<string>(async (resolve) => {
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);
@@ -548,11 +547,11 @@ export const useImageEditing = () => {
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); resolve(checksum || generateId().slice(0, 32));
} catch { } catch {
return generateId().slice(0, 32); resolve(generateId().slice(0, 32));
} }
})(); });
return { return {
id: generateId(), id: generateId(),
@@ -565,46 +564,15 @@ export const useImageEditing = () => {
}; };
})() : undefined; })() : undefined;
// 为编辑操作创建参考资产 // 获取accessToken
const sourceAssets: Asset[] = referenceImageBlobs.map((blob) => { const accessToken = import.meta.env.VITE_ACCESS_TOKEN || '';
// 使用AppStore的addBlob方法存储Blob并获取URL
const blobUrl = useAppStore.getState().addBlob(blob);
// 生成校验和使用Blob的一部分数据
const checksum = (() => {
try {
const arrayBuffer = 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: 'reference',
url: blobUrl,
mime: blob.type || 'image/png',
width: 1024,
height: 1024,
checksum
};
});
// 获取accessToken用于上传
const accessToken = (import.meta as unknown as { env: { VITE_ACCESS_TOKEN?: string } }).env.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);
// 上传参考图像(如果存在,使用缓存机制) // 上传参考图像(如果存在,使用缓存机制)
@@ -627,21 +595,57 @@ export const useImageEditing = () => {
// 检查上传结果 // 检查上传结果
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 {
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消耗信息如果可用
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 = { const edit: Edit = {
id: generateId(), id: generateId(),
parentGenerationId: selectedGenerationId || '', parentGenerationId: selectedGenerationId || '',

View File

@@ -139,9 +139,9 @@ export class GeminiService {
// 准备请求配置包括abortSignal // 准备请求配置包括abortSignal
const generateContentParams: { const generateContentParams: {
model: string model: string;
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: 'gemini-2.5-flash-image-preview',
contents, contents,
@@ -151,8 +151,8 @@ export class GeminiService {
if (request.abortSignal) { if (request.abortSignal) {
generateContentParams.config = { generateContentParams.config = {
httpOptions: { httpOptions: {
abortSignal: request.abortSignal, abortSignal: request.abortSignal
}, }
} }
} }
@@ -164,7 +164,7 @@ export class GeminiService {
if (candidate.finishReason === 'PROHIBITED_CONTENT') { if (candidate.finishReason === 'PROHIBITED_CONTENT') {
throw new Error('内容被禁止:您的请求包含不允许的内容。请尝试其他提示。') throw new Error('内容被禁止:您的请求包含不允许的内容。请尝试其他提示。')
} }
if (candidate.finishReason === 'IMAGE_SAFETY' || candidate.finishReason === 'IMAGE_OTHER') { if (candidate.finishReason === 'IMAGE_SAFETY') {
throw new Error('图像安全检查失败:请求的图像内容可能不安全。请尝试调整提示词。') throw new Error('图像安全检查失败:请求的图像内容可能不安全。请尝试调整提示词。')
} }
// 检查finishReason为STOP但没有inlineData的情况 // 检查finishReason为STOP但没有inlineData的情况
@@ -378,9 +378,9 @@ export class GeminiService {
// 准备请求配置包括abortSignal // 准备请求配置包括abortSignal
const generateContentParams: { const generateContentParams: {
model: string model: string;
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: 'gemini-2.5-flash-image-preview',
contents, contents,
@@ -390,8 +390,8 @@ export class GeminiService {
if (request.abortSignal) { if (request.abortSignal) {
generateContentParams.config = { generateContentParams.config = {
httpOptions: { httpOptions: {
abortSignal: request.abortSignal, abortSignal: request.abortSignal
}, }
} }
} }
@@ -403,7 +403,7 @@ export class GeminiService {
if (candidate.finishReason === 'PROHIBITED_CONTENT') { if (candidate.finishReason === 'PROHIBITED_CONTENT') {
throw new Error('内容被禁止:您的请求包含不允许的内容。请尝试其他提示。') throw new Error('内容被禁止:您的请求包含不允许的内容。请尝试其他提示。')
} }
if (candidate.finishReason === 'IMAGE_SAFETY' || candidate.finishReason === 'IMAGE_OTHER') { if (candidate.finishReason === 'IMAGE_SAFETY') {
throw new Error('图像安全检查失败:请求的图像内容可能不安全。请尝试调整提示词。') throw new Error('图像安全检查失败:请求的图像内容可能不安全。请尝试调整提示词。')
} }
// 检查finishReason为STOP但没有inlineData的情况 // 检查finishReason为STOP但没有inlineData的情况
@@ -547,9 +547,9 @@ export class GeminiService {
// 准备请求配置包括abortSignal // 准备请求配置包括abortSignal
const generateContentParams: { const generateContentParams: {
model: string model: string;
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: 'gemini-2.5-flash-image-preview',
contents: prompt, contents: prompt,
@@ -559,8 +559,8 @@ export class GeminiService {
if (request.abortSignal) { if (request.abortSignal) {
generateContentParams.config = { generateContentParams.config = {
httpOptions: { httpOptions: {
abortSignal: request.abortSignal, abortSignal: request.abortSignal
}, }
} }
} }
@@ -572,7 +572,7 @@ export class GeminiService {
if (candidate.finishReason === 'PROHIBITED_CONTENT') { if (candidate.finishReason === 'PROHIBITED_CONTENT') {
throw new Error('内容被禁止:您的请求包含不允许的内容。请尝试其他提示。') throw new Error('内容被禁止:您的请求包含不允许的内容。请尝试其他提示。')
} }
if (candidate.finishReason === 'IMAGE_SAFETY' || candidate.finishReason === 'IMAGE_OTHER') { if (candidate.finishReason === 'IMAGE_SAFETY') {
throw new Error('图像安全检查失败:请求的图像内容可能不安全。请尝试调整提示词。') throw new Error('图像安全检查失败:请求的图像内容可能不安全。请尝试调整提示词。')
} }
// 检查finishReason为STOP但没有inlineData的情况 // 检查finishReason为STOP但没有inlineData的情况
@@ -628,4 +628,4 @@ export class GeminiService {
} }
} }
export const geminiService = new GeminiService() export const geminiService = new GeminiService()

View File

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

View File

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

View File

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

View File

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