StickerAI-Front/src/services/api.ts
kazachilo c1d4a2862b fix: исправлена система платежей и улучшен UX карточек токенов
- справлена ошибка 422 при создании инвойса (возврат к целочисленным значениям)
- Сделана вся карточка оффера кликабельной для улучшения UX
- обавлено предотвращение двойного срабатывания при клике на кнопку
2025-03-27 15:34:41 +03:00

337 lines
13 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

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';
import { getCurrentUserId, isTelegramWebAppAvailable } from '../constants/user';
import { trackStickerGeneration, trackRejectedPrompt } from './analyticsService';
const API_BASE_URL = 'https://stickerserver.gymnasticstuff.uk';
/**
* Нормализует URL изображения, заменяя IP-адрес на домен, если необходимо.
* Если URL уже содержит домен, оставляет его без изменений.
* @param url URL изображения
* @returns Нормализованный URL
*/
export const normalizeImageUrl = (url: string): string => {
if (!url) return url;
// Если URL уже содержит домен, оставляем его без изменений
if (url.includes('stickerserver.gymnasticstuff.uk')) {
return url;
}
// Проверяем, содержит ли URL IP-адрес (простая проверка на наличие цифр и точек)
const ipRegex = /\d+\.\d+\.\d+\.\d+/;
if (ipRegex.test(url)) {
// Заменяем IP-адрес на домен
// Извлекаем путь из URL (все, что после IP-адреса и порта, если есть)
const pathMatch = url.match(/https?:\/\/\d+\.\d+\.\d+\.\d+(:\d+)?(\/.*)/);
if (pathMatch && pathMatch[2]) {
return `${API_BASE_URL}${pathMatch[2]}`;
}
}
return url;
};
// Маппинг стилей к тегам для определения, какой тег использовать для каждого стиля
const styleToTagMap: Record<string, string> = {
'chibi': 'image_generation',
'emotions': 'chibi', // Пока используем chibi для всех стилей
'realism': 'chibi'
};
class GenerationError extends Error {
constructor(public details: ApiErrorType['detail']) {
super('API Error');
this.name = 'GenerationError';
}
}
// Временное решение для работы с балансом токенов
// Используется только если не удалось получить баланс с сервера
let mockBalance = 50; // Начальное значение из MOCK_USER
const apiService = {
// Метод для получения информации о пользователе
async getUserInfo(userId: number = getCurrentUserId()): Promise<any> {
try {
const response = await fetch(`${API_BASE_URL}/users/${userId}`, {
method: 'GET',
headers: {
'accept': 'application/json',
}
});
if (!response.ok) {
throw new Error('Failed to fetch user info');
}
return await response.json();
} catch (error) {
console.error('Error fetching user info:', error);
throw error;
}
},
// Метод для создания ссылки на инвойс
async createInvoiceLink(userId: number, starsAmount: number, tokens: number): Promise<string> {
try {
const response = await fetch(`${API_BASE_URL}/create-invoice-link?user_id=${userId}&stars_amount=${starsAmount}&tokens=${tokens}`, {
method: 'POST',
headers: {
'accept': 'application/json',
}
});
if (!response.ok) {
throw new Error('Failed to create invoice link');
}
// Парсим ответ как JSON и извлекаем поле invoice_link
const data = await response.json();
if (!data.invoice_link) {
throw new Error('Invalid response format: missing invoice_link field');
}
return data.invoice_link;
} catch (error) {
console.error('Error creating invoice link:', error);
throw error;
}
},
// Метод для получения текущего баланса
async getBalance(userId: number = getCurrentUserId()): Promise<number> {
try {
const userInfo = await this.getUserInfo(userId);
return userInfo.balance || 0;
} catch (error) {
console.error('Error getting balance:', error);
// В случае ошибки возвращаем mockBalance для обратной совместимости
return mockBalance;
}
},
// Получение списка задач пользователя в статусе PENDING
async getUserPendingTasks(userId = getCurrentUserId()): Promise<PendingTask[]> {
try {
const response = await fetch(`${API_BASE_URL}/user_pending_tasks/${userId}`, {
method: 'GET',
headers: {
'accept': 'application/json',
}
});
if (!response.ok) {
throw new Error('Failed to fetch pending tasks');
}
return await response.json();
} catch (error) {
console.error('Error fetching pending tasks:', error);
return [];
}
},
// Получение текущей позиции задачи в очереди
async getTaskPosition(taskId: string): Promise<{task_id: number, status: string, queue_position: number | null}> {
try {
const response = await fetch(`${API_BASE_URL}/task_position/${taskId}`, {
method: 'GET',
headers: {
'accept': 'application/json',
}
});
if (!response.ok) {
throw new Error('Failed to fetch task position');
}
return await response.json();
} catch (error) {
console.error('Error fetching task position:', error);
throw error;
}
},
// Расчет примерного времени ожидания в секундах
calculateEstimatedWaitTime(queuePosition: number): number {
// Среднее время генерации 13-15 секунд
const averageGenerationTime = 14;
return queuePosition * averageGenerationTime;
},
async getGeneratedImages(userId = getCurrentUserId()): Promise<GeneratedImage[]> {
try {
const response = await fetch(`${API_BASE_URL}/images_links/${userId}`, {
method: 'GET',
headers: {
'accept': 'application/json',
}
});
if (!response.ok) {
throw new Error('Failed to fetch images');
}
// Получаем массив массивов [file_id, created_at]
const rawData = await response.json() as [string, string][];
// Преобразуем в массив объектов GeneratedImage
const images = rawData.map((item, index) => {
const [fileId, createdAt] = item;
return {
id: index + 1,
link: fileId,
prompt_id: '',
status: 'COMPLETED',
created_at: createdAt,
sticker_set_id: null,
url: `${API_BASE_URL}/stickers/proxy/sticker/${encodeURIComponent(fileId)}`
};
});
// Сортируем изображения от новых к старым по дате создания
return images.sort((a, b) =>
new Date(b.created_at).getTime() - new Date(a.created_at).getTime()
);
} catch (error) {
console.error('Error fetching images:', error);
throw new Error('Failed to fetch images');
}
},
// Удаление изображения по file_id
async deleteImage(fileId: string): Promise<string> {
try {
const response = await fetch(`${API_BASE_URL}/delete-by-link?link=${encodeURIComponent(fileId)}`, {
method: 'DELETE',
headers: {
'accept': 'application/json',
}
});
if (!response.ok) {
throw new Error('Failed to delete image');
}
return await response.json();
} catch (error) {
console.error('Error deleting image:', error);
throw new Error('Failed to delete image');
}
},
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);
// Используем новый метод перевода через LLM
const translationResult = await translateService.translateWithLLM(userPrompt);
if (translationResult.success) {
// Успешный перевод
console.log('Переведенный промпт:', translationResult.text);
workflow['316'].inputs.prompt_1 = translationResult.text;
usedPrompt = translationResult.text;
} else {
// Перевод не удался после всех попыток
console.error('Не удалось перевести промпт:', translationResult.text);
translationFailed = true;
// Отслеживаем событие неудавшейся генерации
trackRejectedPrompt(userPrompt);
// Не продолжаем генерацию, возвращаем ошибку с сообщением
return {
translationFailed: true,
usedPrompt: 'Недопустимый промпт', // Сообщение для пользователя
errorDetails: translationResult.text // Детали ошибки для отладки
};
}
}
// Иначе используем предустановленный промпт (они уже переведены)
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);
}
const result = await response.json() as GenerationResponse;
// Отслеживаем событие генерации стикера
trackStickerGeneration(usedPrompt);
// Возвращаем результат и использованный промпт
return {
result,
usedPrompt,
translationFailed: false
};
} catch (error) {
if (error instanceof GenerationError) {
throw error;
}
throw new Error('Failed to generate image');
}
}
};
export default apiService;