xpaint_zh / hooks /useAiFeatures.ts
suisuyy
Fix error message handling in useAiFeatures hook to include full error body
a39d37d
import { useState, useCallback } from 'react';
import { ToastMessage } from './useToasts';
import { dataURLtoBlob, calculateSHA256, copyToClipboard } from '../utils/canvasUtils';
import { CanvasHistoryHook } from './useCanvasHistory';
import { TFunction } from '../types';
const SHARE_API_URL = 'https://sharefile.suisuy.eu.org';
const ASK_API_URL = 'https://getai.deno.dev/';
const MAX_UPLOAD_SIZE_BYTES = 50 * 1024 * 1024; // 50MB
export type AiImageQuality = 'low' | 'medium' | 'high';
export type AiDimensionsMode = 'api_default' | 'match_canvas' | 'fixed_1024';
interface AiFeaturesHook {
isMagicUploading: boolean;
showAiEditModal: boolean;
aiPrompt: string;
isGeneratingAiImage: boolean;
sharedImageUrlForAi: string | null;
aiEditError: string | null;
isAskingAi: boolean;
askUrl: string | null;
generatedImageUrl: string | null;
handleMagicUpload: () => Promise<void>;
handleGenerateAiImage: () => Promise<void>;
handleAskAi: () => Promise<void>;
handleCancelAiEdit: () => void;
setAiPrompt: React.Dispatch<React.SetStateAction<string>>;
clearAiOutputs: () => void;
}
interface UseAiFeaturesProps {
currentDataURL: string | null;
showToast: (message: string, type: ToastMessage['type']) => void;
updateCanvasState: CanvasHistoryHook['updateCanvasState'];
setZoomLevel: (zoom: number) => void;
aiImageQuality: AiImageQuality;
aiApiEndpoint: string;
aiDimensionsMode: AiDimensionsMode;
currentCanvasWidth: number;
currentCanvasHeight: number;
t: TFunction;
}
export const useAiFeatures = ({
currentDataURL,
showToast,
updateCanvasState,
setZoomLevel,
aiImageQuality,
aiApiEndpoint,
aiDimensionsMode,
currentCanvasWidth,
currentCanvasHeight,
t,
}: UseAiFeaturesProps): AiFeaturesHook => {
const [isMagicUploading, setIsMagicUploading] = useState<boolean>(false);
const [showAiEditModal, setShowAiEditModal] = useState<boolean>(false);
const [aiPrompt, setAiPrompt] = useState<string>('');
const [isGeneratingAiImage, setIsGeneratingAiImage] = useState<boolean>(false);
const [sharedImageUrlForAi, setSharedImageUrlForAi] = useState<string | null>(null);
const [aiEditError, setAiEditError] = useState<string | null>(null);
const [isAskingAi, setIsAskingAi] = useState<boolean>(false);
const [askUrl, setAskUrl] = useState<string | null>(null);
const [generatedImageUrl, setGeneratedImageUrl] = useState<string | null>(null);
const clearAiOutputs = useCallback(() => {
setAskUrl(null);
setGeneratedImageUrl(null);
}, []);
const loadAiImageOntoCanvas = useCallback((aiImageDataUrl: string) => {
const img = new Image();
img.onload = () => {
const tempCanvas = document.createElement('canvas');
tempCanvas.width = currentCanvasWidth;
tempCanvas.height = currentCanvasHeight;
const tempCtx = tempCanvas.getContext('2d');
if (tempCtx) {
// Fill background (e.g., white)
tempCtx.fillStyle = '#FFFFFF';
tempCtx.fillRect(0, 0, currentCanvasWidth, currentCanvasHeight);
let drawWidth = img.naturalWidth;
let drawHeight = img.naturalHeight;
// Scale image if it's larger than the canvas, preserving aspect ratio
if (img.naturalWidth > currentCanvasWidth || img.naturalHeight > currentCanvasHeight) {
const aspectRatio = img.naturalWidth / img.naturalHeight;
if (currentCanvasWidth / aspectRatio <= currentCanvasHeight) {
drawWidth = currentCanvasWidth;
drawHeight = currentCanvasWidth / aspectRatio;
} else {
drawHeight = currentCanvasHeight;
drawWidth = currentCanvasHeight * aspectRatio;
}
}
// Ensure dimensions are at least 1px
drawWidth = Math.max(1, Math.floor(drawWidth));
drawHeight = Math.max(1, Math.floor(drawHeight));
// Change: Draw image at top-left (0,0)
const drawX = 0;
const drawY = 0;
tempCtx.drawImage(img, drawX, drawY, drawWidth, drawHeight);
const newCanvasState = tempCanvas.toDataURL('image/png');
// Update canvas state with the new image, but keep original canvas dimensions
updateCanvasState(newCanvasState, currentCanvasWidth, currentCanvasHeight);
setShowAiEditModal(false);
showToast(t('aiImageApplied'), 'success');
setAiPrompt('');
setSharedImageUrlForAi(null);
} else {
setAiEditError(t('failedToCreateContext'));
}
setIsGeneratingAiImage(false);
};
img.onerror = () => {
setAiEditError(t('failedToLoadAiImage'));
setIsGeneratingAiImage(false);
};
img.crossOrigin = "anonymous";
img.src = aiImageDataUrl;
}, [showToast, updateCanvasState, currentCanvasWidth, currentCanvasHeight, t]);
const handleMagicUpload = useCallback(async () => {
if (isMagicUploading || !currentDataURL) {
showToast(t('noCanvasContentToShare'), 'info');
return;
}
setIsMagicUploading(true);
setAiEditError(null);
showToast(t('uploadingImage'), 'info');
try {
const blob = await dataURLtoBlob(currentDataURL);
if (blob.size > MAX_UPLOAD_SIZE_BYTES) {
showToast(t('imageTooLarge', { size: (blob.size / 1024 / 1024).toFixed(2) }), 'error');
setIsMagicUploading(false);
return;
}
const hash = await calculateSHA256(blob);
const filename = `tempaint_${hash}.png`;
const uploadUrl = `${SHARE_API_URL}/${filename}`;
const response = await fetch(uploadUrl, {
method: 'POST',
headers: { 'Content-Type': blob.type || 'image/png' },
body: blob,
});
if (!response.ok) {
let errorMsg = t('uploadFailed', { statusText: response.statusText });
try { const errorBody = await response.text(); errorMsg = t('uploadFailed', { statusText: errorBody || response.statusText }); } catch (e) { /* ignore */ }
throw new Error(errorMsg);
}
const returnedObjectName = await response.text();
const finalShareUrl = `https://pub-cb2c87ea7373408abb1050dd43e3cd8e.r2.dev/${returnedObjectName}`;
setSharedImageUrlForAi(finalShareUrl);
setShowAiEditModal(true);
setAiPrompt('');
setAiEditError(null);
clearAiOutputs();
} catch (error: any) {
console.error('Magic upload error:', error);
showToast(error.message || t('magicUploadError'), 'error');
} finally {
setIsMagicUploading(false);
}
}, [isMagicUploading, currentDataURL, showToast, clearAiOutputs, t]);
const handleGenerateAiImage = useCallback(async () => {
if (!aiPrompt.trim() || !sharedImageUrlForAi) {
setAiEditError(t('enterPrompt'));
return;
}
setIsGeneratingAiImage(true);
setAiEditError(null);
clearAiOutputs();
showToast(t('generatingAiImage'), 'info');
const encodedPrompt = encodeURIComponent(aiPrompt);
const encodedImageUrl = encodeURIComponent(sharedImageUrlForAi);
let finalApiUrl = aiApiEndpoint
.replace('{prompt}', encodedPrompt)
.replace('{imgurl.url}', encodedImageUrl);
if (finalApiUrl.includes('{quality}')) {
finalApiUrl = finalApiUrl.replace('{quality}', aiImageQuality);
}
if (finalApiUrl.includes('{size_params}')) {
let sizeParamsString = '';
if (aiDimensionsMode === 'match_canvas') {
sizeParamsString = `&width=${currentCanvasWidth}&height=${currentCanvasHeight}`;
} else if (aiDimensionsMode === 'fixed_1024') {
sizeParamsString = `&width=1024&height=1024`;
}
finalApiUrl = finalApiUrl.replace('{size_params}', sizeParamsString);
}
setGeneratedImageUrl(finalApiUrl);
try {
const response = await fetch(finalApiUrl);
if (!response.ok) {
let errorMsg = t('aiGenFailed', { status: response.status, statusText: response.statusText });
try {
const errorBody = await response.text();
if (errorBody && !errorBody.toLowerCase().includes('<html')) {
errorMsg += ` - ${errorBody}`;
}
} catch(e) { /* ignore if can't read body */ }
throw new Error(errorMsg);
}
const imageBlob = await response.blob();
if (!imageBlob.type.startsWith('image/')) {
throw new Error(t('aiGenInvalidImage'));
}
const reader = new FileReader();
reader.onloadend = () => {
if (typeof reader.result === 'string') {
loadAiImageOntoCanvas(reader.result);
} else {
setAiEditError(t('failedToReadAiData'));
setIsGeneratingAiImage(false);
}
};
reader.onerror = () => {
setAiEditError(t('failedToReadAiData'));
setIsGeneratingAiImage(false);
}
reader.readAsDataURL(imageBlob);
} catch (error: any) {
console.error('AI image generation error:', error);
setAiEditError(error.message || t('unknownAiError'));
setIsGeneratingAiImage(false);
}
}, [aiPrompt, sharedImageUrlForAi, showToast, loadAiImageOntoCanvas, aiImageQuality, aiApiEndpoint, aiDimensionsMode, currentCanvasWidth, currentCanvasHeight, clearAiOutputs, t]);
const handleAskAi = useCallback(async () => {
if (!aiPrompt.trim() || !sharedImageUrlForAi) {
setAiEditError(t('enterQuestion'));
return;
}
setIsAskingAi(true);
setAiEditError(null);
clearAiOutputs();
try {
const encodedPrompt = encodeURIComponent(aiPrompt);
const encodedImageUrl = encodeURIComponent(sharedImageUrlForAi);
const finalAskUrl = `${ASK_API_URL}?q=${encodedPrompt}&image=${encodedImageUrl}`;
setAskUrl(finalAskUrl);
} catch (error: any) {
console.error('AI ask error:', error);
setAiEditError(error.message || t('unknownAskError'));
} finally {
setIsAskingAi(false);
}
}, [aiPrompt, sharedImageUrlForAi, t, clearAiOutputs]);
const handleCancelAiEdit = () => {
setShowAiEditModal(false);
if (sharedImageUrlForAi) {
copyToClipboard(sharedImageUrlForAi, (msg, type) => showToast(msg,type as 'info' | 'error'), t).then(copied => {
if(copied) {
showToast(t('imageUploadedAndCopied', { url: sharedImageUrlForAi }), 'success');
}
});
}
setAiPrompt('');
setAiEditError(null);
setSharedImageUrlForAi(null);
clearAiOutputs();
};
return {
isMagicUploading,
showAiEditModal,
aiPrompt,
isGeneratingAiImage,
sharedImageUrlForAi,
aiEditError,
isAskingAi,
askUrl,
generatedImageUrl,
handleMagicUpload,
handleGenerateAiImage,
handleAskAi,
handleCancelAiEdit,
setAiPrompt,
clearAiOutputs,
};
};