You've already forked Nano-Banana-AI-Image-Editor
Compare commits
6 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 3ba2a0cbd5 | |||
| d4f9735f88 | |||
|
|
4b5b1a5eba | ||
|
|
eae15ced5a | ||
|
|
70684b2ddf | ||
|
|
9a5e4d8041 |
233
IFLOW.md
233
IFLOW.md
@@ -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
|
||||||
|
- 移动端适配需要特别关注界面布局和交互
|
||||||
@@ -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 # 运行测试
|
|
||||||
```
|
```
|
||||||
|
|
||||||
### 生产考虑
|
### 生产考虑
|
||||||
|
|||||||
@@ -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']
|
|
||||||
};
|
|
||||||
@@ -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';
|
||||||
|
|||||||
@@ -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();
|
|
||||||
});
|
|
||||||
});
|
|
||||||
});
|
|
||||||
@@ -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);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
});
|
|
||||||
@@ -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;
|
|
||||||
}
|
|
||||||
@@ -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);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
@@ -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);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
});
|
|
||||||
@@ -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
|
|
||||||
});
|
|
||||||
@@ -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);
|
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
重置
|
重置
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|
||||||
|
|||||||
@@ -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);
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -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}
|
||||||
/>
|
/>
|
||||||
|
|||||||
@@ -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 || '',
|
||||||
|
|||||||
@@ -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的情况
|
||||||
|
|||||||
@@ -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;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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获取Blob,Blob可能已被清理');
|
||||||
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对象,使用唯一文件名
|
||||||
@@ -178,68 +169,69 @@ export const uploadImage = async (imageData: string | Blob, accessToken: string,
|
|||||||
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('上传缓存已清除')
|
||||||
}
|
}
|
||||||
@@ -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);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -6,9 +6,7 @@
|
|||||||
"types": [
|
"types": [
|
||||||
"jest",
|
"jest",
|
||||||
"node"
|
"node"
|
||||||
],
|
]
|
||||||
"module": "ESNext",
|
|
||||||
"moduleResolution": "node"
|
|
||||||
},
|
},
|
||||||
"include": [
|
"include": [
|
||||||
"src/**/*",
|
"src/**/*",
|
||||||
|
|||||||
Reference in New Issue
Block a user