обавлены улучшения: 1) Сохранение изображения в localStorage для сохранения между сеансами навигации 2) еханизм повторных попыток перевода промпта с отображением ошибки при неудаче

This commit is contained in:
kazachilo 2025-03-14 13:52:49 +03:00
parent b2469b2e10
commit 7a58f26ead
9 changed files with 415 additions and 113 deletions

View File

@ -25,7 +25,7 @@ const BlockRenderer: React.FC<BlockRendererProps> = ({ block, onAction, extraPro
return <GridButtonsBlock block={buttonBlock} onAction={onAction} isInputVisible={extraProps?.visible} />;
case 'uploadPhoto':
return <UploadPhotoBlock
previewUrl={window.history.state?.usr?.previewUrl}
previewUrl={window.history.state?.usr?.previewUrl || localStorage.getItem('stickerPreviewUrl')}
onPhotoSelect={(file) => {
const tempUrl = URL.createObjectURL(file);
window.history.replaceState(

View File

@ -14,6 +14,10 @@ const UploadPhotoBlock: React.FC<UploadPhotoBlockProps> = ({ onPhotoSelect, prev
const handleFileSelect = (file: File) => {
if (file && file.type.startsWith('image/')) {
// Очищаем предыдущие данные изображения при загрузке нового
localStorage.removeItem('stickerPreviewUrl');
localStorage.removeItem('stickerImageData');
onPhotoSelect?.(file);
navigate('/crop-photo', { state: { file } });
}

View File

@ -0,0 +1,113 @@
.overlay {
position: fixed;
top: 0;
left: 0;
right: 0;
bottom: 0;
background-color: rgba(0, 0, 0, 0.5);
display: flex;
justify-content: center;
align-items: center;
z-index: 1000;
}
.modal {
background-color: var(--color-background);
border-radius: var(--border-radius);
padding: var(--spacing-medium);
width: calc(100% - var(--spacing-medium) * 2);
max-width: 400px;
box-shadow: 0 4px 8px rgba(0, 0, 0, 0.1);
margin: 0 var(--spacing-medium);
}
.header {
margin-bottom: var(--spacing-medium);
}
.title {
font-size: 18px;
font-weight: 600;
margin-bottom: var(--spacing-small);
}
.message {
font-size: 14px;
margin-bottom: var(--spacing-small);
line-height: 1.4;
}
.promptContainer {
background-color: var(--color-border);
border-radius: var(--border-radius);
padding: var(--spacing-small);
margin-bottom: var(--spacing-medium);
max-height: 100px;
overflow-y: auto;
}
.promptLabel {
font-size: 12px;
font-weight: 600;
margin-bottom: 4px;
color: var(--color-text-secondary);
}
.promptText {
font-size: 12px;
word-break: break-word;
font-family: monospace;
}
.buttons {
display: flex;
justify-content: space-between;
gap: var(--spacing-small);
}
.button {
flex: 1;
padding: var(--spacing-small);
border: none;
border-radius: var(--border-radius);
font-size: 14px;
font-weight: 500;
cursor: pointer;
transition: background-color 0.2s;
}
.primaryButton {
background-color: var(--color-primary);
color: white;
}
.primaryButton:hover {
background-color: #1976D2;
}
.secondaryButton {
background-color: var(--color-border);
color: var(--color-text);
}
.secondaryButton:hover {
background-color: #d0d0d0;
}
.spinner {
display: inline-block;
width: 16px;
height: 16px;
border: 2px solid rgba(var(--color-text-rgb), 0.1);
border-top-color: var(--color-primary);
border-radius: 50%;
animation: spin 1s linear infinite;
margin-right: var(--spacing-small);
vertical-align: middle;
}
@keyframes spin {
to {
transform: rotate(360deg);
}
}

View File

@ -0,0 +1,62 @@
import React from 'react';
import styles from './NotificationModal.module.css';
interface NotificationModalProps {
isVisible: boolean;
title: string;
message: string;
isLoading?: boolean;
promptText?: string;
onGalleryClick: () => void;
onContinueClick: () => void;
}
const NotificationModal: React.FC<NotificationModalProps> = ({
isVisible,
title,
message,
isLoading = false,
promptText,
onGalleryClick,
onContinueClick
}) => {
if (!isVisible) return null;
return (
<div className={styles.overlay}>
<div className={styles.modal}>
<div className={styles.header}>
<div className={styles.title}>
{isLoading && <span className={styles.spinner}></span>}
{title}
</div>
<div className={styles.message}>{message}</div>
</div>
{promptText && (
<div className={styles.promptContainer}>
<div className={styles.promptLabel}>Использованный промпт:</div>
<div className={styles.promptText}>{promptText}</div>
</div>
)}
<div className={styles.buttons}>
<button
className={`${styles.button} ${styles.primaryButton}`}
onClick={onGalleryClick}
>
В галерею
</button>
<button
className={`${styles.button} ${styles.secondaryButton}`}
onClick={onContinueClick}
>
Продолжить
</button>
</div>
</div>
</div>
);
};
export default NotificationModal;

View File

@ -263,6 +263,11 @@ const CropPhoto: React.FC = () => {
// Передаем не только URL, но и base64 данные
// Убираем префикс data:image/jpeg;base64, оставляем только данные
const imageData = previewUrl.split(',')[1];
// Сохраняем данные в localStorage для сохранения между сеансами навигации
localStorage.setItem('stickerPreviewUrl', previewUrl);
localStorage.setItem('stickerImageData', imageData);
navigate('/', {
state: {
previewUrl,

View File

@ -1,4 +1,4 @@
import React, { useState, useCallback } from 'react';
import React, { useState, useCallback, useEffect } from 'react';
import { useNavigate } from 'react-router-dom';
import BlockRenderer from '../components/blocks/BlockRenderer';
// import UploadPhotoBlock from '../components/blocks/UploadPhotoBlock'; // Не используется
@ -6,26 +6,44 @@ import styles from './Home.module.css';
import { homeScreenConfig } from '../config/homeScreen';
import { stylePresets } from '../config/stylePresets';
import apiService from '../services/api';
import NotificationModal from '../components/shared/NotificationModal';
const Home: React.FC = () => {
const navigate = useNavigate();
// eslint-disable-next-line @typescript-eslint/no-unused-vars
const [previewUrl, setPreviewUrl] = useState<string | undefined>(() => {
// Проверяем, есть ли превью в состоянии навигации
// Проверяем, есть ли превью в состоянии навигации или localStorage
const state = window.history.state?.usr;
return state?.previewUrl;
return state?.previewUrl || localStorage.getItem('stickerPreviewUrl') || undefined;
});
const [imageData, _setImageData] = useState<string | undefined>(() => {
const state = window.history.state?.usr;
return state?.imageData;
return state?.imageData || localStorage.getItem('stickerImageData') || undefined;
});
const [isInputVisible, setIsInputVisible] = useState(false);
const [selectedStyle, setSelectedStyle] = useState<string>('chibi'); // По умолчанию выбран первый стиль
const [selectedButtonId, setSelectedButtonId] = useState<string | undefined>(undefined); // Для хранения ID выбранной кнопки стиля
const [customPrompt, setCustomPrompt] = useState<string>(''); // Для хранения пользовательского промпта
// Состояния для модального окна уведомления
const [isNotificationVisible, setIsNotificationVisible] = useState(false);
const [notificationTitle, setNotificationTitle] = useState('');
const [notificationMessage, setNotificationMessage] = useState('');
const [isLoading, setIsLoading] = useState(false);
const [promptText, setPromptText] = useState('');
// Обработчики для модального окна
const handleGalleryClick = useCallback(() => {
setIsNotificationVisible(false);
navigate('/gallery');
}, [navigate]);
const handleContinueClick = useCallback(() => {
setIsNotificationVisible(false);
}, []);
const handleBlockAction = useCallback(async (actionType: string, actionValue: string, _blockId?: string, buttonId?: string) => {
if (actionType === 'function') {
if (actionValue === 'startGeneration') {
@ -35,30 +53,58 @@ const Home: React.FC = () => {
}
try {
// Показываем уведомление о начале генерации
setNotificationTitle('Генерация стикера');
setNotificationMessage('Отправка запроса...');
setIsLoading(true);
setPromptText('');
setIsNotificationVisible(true);
// Если выбран "Свой промпт" и введен текст, используем его
const userPrompt = selectedButtonId === 'customPrompt' && customPrompt ? customPrompt : undefined;
const result = await apiService.generateImage(imageData, selectedStyle, selectedButtonId, userPrompt);
console.log('Generation started:', result);
// Показываем уведомление о позиции в очереди
if (result.queue_position !== undefined) {
const estimatedTime = apiService.calculateEstimatedWaitTime(result.queue_position);
const minutes = Math.floor(estimatedTime / 60);
const seconds = estimatedTime % 60;
const timeString = minutes > 0
? `${minutes} мин ${seconds} сек`
: `${seconds} сек`;
alert(`Ваша задача отправлена на генерацию!\озиция в очереди: ${result.queue_position}\римерное время ожидания: ${timeString}`);
} else {
alert('Ваша задача отправлена на генерацию!');
// Отправляем запрос на генерацию
const response = await apiService.generateImage(imageData, selectedStyle, selectedButtonId, userPrompt);
console.log('Generation response:', response);
// Проверяем, была ли ошибка перевода
if (response.translationFailed) {
setNotificationTitle('Ошибка перевода');
setNotificationMessage('Не удалось перевести промпт. Генерация отменена.');
setIsLoading(false);
return;
}
// Перенаправляем пользователя в галерею
navigate('/gallery');
// Если нет ошибки перевода, продолжаем обработку результата
if (response.result && response.usedPrompt) {
// Получаем результат и использованный промпт
const { result, usedPrompt } = response;
// Обновляем уведомление с информацией о позиции в очереди
if (result.queue_position !== undefined) {
const estimatedTime = apiService.calculateEstimatedWaitTime(result.queue_position);
const minutes = Math.floor(estimatedTime / 60);
const seconds = estimatedTime % 60;
const timeString = minutes > 0
? `${minutes} мин ${seconds} сек`
: `${seconds} сек`;
setNotificationMessage(`Ваша задача отправлена на генерацию!\озиция в очереди: ${result.queue_position}\римерное время ожидания: ${timeString}`);
} else {
setNotificationMessage('Ваша задача отправлена на генерацию!');
}
// Устанавливаем использованный промпт и убираем индикатор загрузки
setPromptText(usedPrompt);
}
setIsLoading(false);
} catch (error) {
console.error('Generation failed:', error);
alert('Не удалось начать генерацию');
setNotificationTitle('Ошибка');
setNotificationMessage('Не удалось начать генерацию');
setIsLoading(false);
setIsNotificationVisible(true);
}
return;
}
@ -101,6 +147,27 @@ const Home: React.FC = () => {
setIsInputVisible(false);
}, [navigate, imageData, selectedStyle, selectedButtonId, customPrompt]);
// Эффект для обновления window.history.state при загрузке из localStorage
useEffect(() => {
// Если есть данные в localStorage, но нет в history.state, обновляем history.state
const state = window.history.state?.usr;
const localStoragePreviewUrl = localStorage.getItem('stickerPreviewUrl');
const localStorageImageData = localStorage.getItem('stickerImageData');
if (!state?.previewUrl && localStoragePreviewUrl && localStorageImageData) {
window.history.replaceState(
{
usr: {
previewUrl: localStoragePreviewUrl,
imageData: localStorageImageData
}
},
'',
window.location.pathname
);
}
}, []);
// Функция для получения кнопок в зависимости от блока
const getBlockButtons = useCallback((block: any) => {
if (block.id === 'quickActions') {
@ -114,6 +181,17 @@ const Home: React.FC = () => {
return (
<div className={styles.container}>
{/* Модальное окно уведомления */}
<NotificationModal
isVisible={isNotificationVisible}
title={notificationTitle}
message={notificationMessage}
isLoading={isLoading}
promptText={promptText}
onGalleryClick={handleGalleryClick}
onContinueClick={handleContinueClick}
/>
<div className={styles.content}>
{/* Блоки из конфигурации */}
<div className={styles.blocks}>

View File

@ -1,4 +1,4 @@
import { GenerationResponse, ApiError as ApiErrorType, GeneratedImage, PendingTask } from '../types/api';
import { GenerationResponse, ApiError as ApiErrorType, GeneratedImage, PendingTask, GenerationResult } from '../types/api';
import { baseWorkflow } from '../constants/baseWorkflow';
import { prompts } from '../assets/prompts';
import translateService from './translateService';
@ -133,79 +133,98 @@ const apiService = {
}
},
async generateImage(imageData: string, style?: string, promptId?: string, userPrompt?: string) {
try {
// Создаем копию базового воркфлоу
const workflow = JSON.parse(JSON.stringify(baseWorkflow));
async generateImage(imageData: string, style?: string, promptId?: string, userPrompt?: string): Promise<GenerationResult> {
try {
// Создаем копию базового воркфлоу
const workflow = JSON.parse(JSON.stringify(baseWorkflow));
// Вставляем изображение в base64 формате в узел 563
workflow['563'].inputs.image = imageData;
// Переменная для хранения использованного промпта
let usedPrompt = '';
let translationFailed = false;
// Если указан пользовательский промпт и выбрана кнопка "Свой промпт"
if (userPrompt && promptId === 'customPrompt') {
console.log('Переводим пользовательский промпт:', userPrompt);
// Вставляем изображение в base64 формате в узел 563
workflow['563'].inputs.image = imageData;
// Переводим промпт с 3 попытками
const translationResult = await translateService.translateToEnglish(userPrompt, 3);
// Если указан пользовательский промпт и выбрана кнопка "Свой промпт"
if (userPrompt && promptId === 'customPrompt') {
console.log('Переводим пользовательский промпт:', userPrompt);
if (translationResult.success) {
// Успешный перевод
console.log('Переведенный промпт:', translationResult.text);
workflow['316'].inputs.prompt_1 = translationResult.text;
usedPrompt = translationResult.text;
} else {
// Перевод не удался после всех попыток
console.error('Не удалось перевести промпт после нескольких попыток');
translationFailed = true;
try {
// Переводим промпт и ждем результата
const translatedPrompt = await translateService.translateToEnglish(userPrompt);
console.log('Переведенный промпт:', translatedPrompt);
// Явно заменяем промпт в воркфлоу
workflow['316'].inputs.prompt_1 = translatedPrompt;
// Проверяем, что промпт действительно заменен
console.log('Промпт в воркфлоу после замены:', workflow['316'].inputs.prompt_1);
} catch (translationError) {
console.error('Ошибка при переводе:', translationError);
// В случае ошибки перевода используем исходный промпт
workflow['316'].inputs.prompt_1 = userPrompt;
console.log('Используем исходный промпт из-за ошибки перевода:', userPrompt);
}
}
// Иначе используем предустановленный промпт
else if (promptId && prompts[promptId]) {
workflow['316'].inputs.prompt_1 = prompts[promptId];
console.log('Используем предустановленный промпт:', prompts[promptId]);
// Не продолжаем генерацию, возвращаем ошибку
return {
translationFailed: true
};
}
// Создаем строку JSON для workflow ПОСЛЕ того, как все изменения внесены
const workflowJson = JSON.stringify(workflow);
// Определяем тег на основе выбранного стиля
const tag = styleToTagMap[style || 'chibi'] || 'chibi';
console.log(`Используем тег "${tag}" для стиля "${style || 'chibi'}"`);
// Создаем тело запроса как строку JSON вручную
const requestBodyJson = `{"tag":"${tag}","user_id":${getCurrentUserId()},"workflow":${workflowJson}}`;
// Сохраняем JSON для отладки только при локальной разработке
if (!isTelegramWebAppAvailable()) {
const blob = new Blob([requestBodyJson], { type: 'application/json' });
const url = URL.createObjectURL(blob);
const a = document.createElement('a');
a.href = url;
a.download = 'generation_request.json';
document.body.appendChild(a);
a.click();
document.body.removeChild(a);
URL.revokeObjectURL(url);
}
// Отправляем запрос
const response = await fetch(`${API_BASE_URL}/generate_image`, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
body: requestBodyJson
});
}
// Иначе используем предустановленный промпт (они уже переведены)
else if (promptId && prompts[promptId]) {
workflow['316'].inputs.prompt_1 = prompts[promptId];
usedPrompt = prompts[promptId];
console.log('Используем предустановленный промпт:', prompts[promptId]);
}
// Если был сбой перевода, не продолжаем генерацию
if (translationFailed) {
return { translationFailed: true };
}
// Создаем строку JSON для workflow ПОСЛЕ того, как все изменения внесены
const workflowJson = JSON.stringify(workflow);
// Определяем тег на основе выбранного стиля
const tag = styleToTagMap[style || 'chibi'] || 'chibi';
console.log(`Используем тег "${tag}" для стиля "${style || 'chibi'}"`);
// Создаем тело запроса как строку JSON вручную
const requestBodyJson = `{"tag":"${tag}","user_id":${getCurrentUserId()},"workflow":${workflowJson}}`;
// Сохраняем JSON для отладки только при локальной разработке
if (!isTelegramWebAppAvailable()) {
const blob = new Blob([requestBodyJson], { type: 'application/json' });
const url = URL.createObjectURL(blob);
const a = document.createElement('a');
a.href = url;
a.download = 'generation_request.json';
document.body.appendChild(a);
a.click();
document.body.removeChild(a);
URL.revokeObjectURL(url);
}
// Отправляем запрос
const response = await fetch(`${API_BASE_URL}/generate_image`, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
body: requestBodyJson
});
if (!response.ok) {
const errorData: ApiErrorType = await response.json();
throw new GenerationError(errorData.detail);
}
if (!response.ok) {
const errorData: ApiErrorType = await response.json();
throw new GenerationError(errorData.detail);
}
return await response.json() as GenerationResponse;
const result = await response.json() as GenerationResponse;
// Возвращаем результат и использованный промпт
return {
result,
usedPrompt,
translationFailed: false
};
} catch (error) {
if (error instanceof GenerationError) {
throw error;

View File

@ -10,32 +10,46 @@ interface TranslateResponse {
}
const translateService = {
async translateToEnglish(text: string): Promise<string> {
try {
const response = await fetch(TRANSLATE_API_URL, {
method: 'POST',
body: JSON.stringify({
q: text,
source: 'auto',
target: 'en',
format: 'text',
alternatives: 3,
api_key: ''
}),
headers: { 'Content-Type': 'application/json' }
});
async translateToEnglish(text: string, maxRetries = 3): Promise<{ success: boolean; text: string }> {
let retries = 0;
if (!response.ok) {
throw new Error('Translation failed');
while (retries <= maxRetries) {
try {
// Добавляем задержку перед повторными попытками
if (retries > 0) {
await new Promise(resolve => setTimeout(resolve, 1000)); // 1 секунда между попытками
}
const response = await fetch(TRANSLATE_API_URL, {
method: 'POST',
body: JSON.stringify({
q: text,
source: 'auto',
target: 'en',
format: 'text',
alternatives: 3,
api_key: ''
}),
headers: { 'Content-Type': 'application/json' }
});
if (!response.ok) {
throw new Error(`Translation failed with status: ${response.status}`);
}
const data: TranslateResponse = await response.json();
// Успешный перевод
return { success: true, text: data.translatedText };
} catch (error) {
console.error(`Translation attempt ${retries + 1} failed:`, error);
retries++;
}
const data: TranslateResponse = await response.json();
return data.translatedText;
} catch (error) {
console.error('Translation error:', error);
// В случае ошибки возвращаем исходный текст
return text;
}
// Все попытки исчерпаны, возвращаем исходный текст с флагом неудачи
console.error(`All ${maxRetries} translation attempts failed`);
return { success: false, text: text };
}
};

View File

@ -60,3 +60,10 @@ export interface StickerSetResponse {
set_name: string;
user_id: number;
}
// Интерфейс для ответа от apiService.generateImage
export interface GenerationResult {
result?: GenerationResponse;
usedPrompt?: string;
translationFailed: boolean;
}