import { createQueryOptions } from '@/api-client/query-config'; import type { SwotData, SwotDataWithoutSummary } from '@/schema/pox'; import type { ContentSection, FvData, GenerateContentsHtmlResponse, GenerateFvHtmlResponse } from '@/schema/proposal'; import type { ThemeExtractionResult } from '@/schema/theme-extraction'; import { useQuery, useQueryClient } from '@tanstack/react-query'; import React from 'react'; import { generateContentsHtml, generateFvHtml, getImageGenerationStatus, startImageGeneration } from './index'; // FV提案HTMLを取得するフック(タブごと) export function useFvHtmlGeneration( tabName: string, fvData?: FvData, enabled: boolean = false, provider?: 'openai' | 'gemini' | 'claude', referenceUrl?: string, screenshot?: string, dummyMode?: boolean, swotData?: SwotData | SwotDataWithoutSummary, userEmail?: string, sourcePage?: string, forceFallbackMode?: boolean, themeData?: ThemeExtractionResult | null, ) { // パラメータから安定したハッシュを生成 const generateParamsHash = () => { if (!fvData) return 'no-fv-data'; // FVデータの主要パラメータからハッシュを生成(実際の構造に合わせる) const relevantParams = { strategy: fvData.strategy, need: fvData.need, // FVデータの主要部分 mainCopy: fvData.data?.fv?.コピー?.メインコピー || '', subCopy1: fvData.data?.fv?.コピー?.サブコピー1 || '', subCopy2: fvData.data?.fv?.コピー?.サブコピー2 || '', subCopy3: fvData.data?.fv?.コピー?.サブコピー3 || '', ctaButton: fvData.data?.fv?.CTA?.ボタンテキスト || '', microCopy: fvData.data?.fv?.CTA?.マイクロコピー || '', visualCreationInstruction: fvData.data?.fv?.ビジュアル?.作成指示 || '', authorityCreationInstruction: fvData.data?.fv?.権威付け?.作成指示 || '', fvCreationIntent: fvData.fvCreationIntent || '', // SWOTデータの主要部分 swotKeys: swotData ? Object.keys(swotData).sort().join('_') : '', swotSample: swotData ? `${swotData['業界'] || ''}_${swotData['商材'] || ''}_${swotData['強み'] || ''}_${swotData['弱み'] || ''}` : '', }; const str = JSON.stringify(relevantParams); let hash = 0; for (let i = 0; i < str.length; i++) { hash = ((hash << 5) - hash + str.charCodeAt(i)) | 0; } return Math.abs(hash).toString(36); }; const paramsHash = generateParamsHash(); const fvDataKey = `${tabName}_${provider || 'default'}_${dummyMode ? 'dummy' : 'live'}_${paramsHash}`; // 前回のキーを保存するためのRef const prevKeyRef = React.useRef(''); // 現在のqueryKeyを生成 // 画像再生成を防ぐため、themeDataはクエリキーに含めない const currentQueryKey = ['fvHtml', tabName, fvDataKey, provider, referenceUrl, dummyMode]; return useQuery({ ...createQueryOptions(), queryKey: currentQueryKey, queryFn: () => { if (!fvData) { throw new Error('fvData is required'); } return generateFvHtml({ tabName, fvData, provider, referenceUrl, screenshot, dummyMode, swotData, userEmail, sourcePage, forceFallbackMode, themeData: null, // テーマはCSSのみで適用するため、画像生成時には渡さない }); }, enabled: enabled && !!fvData, }); } // コンテンツ提案HTMLを取得するフック(タブごと) export function useContentsHtmlGeneration( tabName: string, cnData?: ContentSection[], enabled: boolean = false, provider?: 'openai' | 'gemini' | 'claude', referenceUrl?: string, screenshot?: string, dummyMode?: boolean, swotData?: SwotData | SwotDataWithoutSummary, userEmail?: string, themeData?: ThemeExtractionResult | null, ) { // パラメータから安定したハッシュを生成 const generateParamsHash = () => { if (!cnData) return 'no-cn-data'; // CNデータの主要パラメータからハッシュを生成 const relevantParams = { // CNデータのサンプル(最初の3要素) sectionsCount: cnData.length || 0, firstSections: cnData .slice(0, 3) .map((s) => s.中区分) .join('_') || '', // SWOTデータの主要部分 swotKeys: swotData ? Object.keys(swotData).sort().join('_') : '', swotSample: swotData ? `${swotData['業界'] || ''}_${swotData['商材'] || ''}` : '', }; const str = JSON.stringify(relevantParams); let hash = 0; for (let i = 0; i < str.length; i++) { hash = ((hash << 5) - hash + str.charCodeAt(i)) | 0; } return Math.abs(hash).toString(36); }; const paramsHash = generateParamsHash(); const cnDataKey = `${tabName}_${provider || 'default'}_${dummyMode ? 'dummy' : 'live'}_${paramsHash}`; // 前回のキーを保存するためのRef const prevKeyRef = React.useRef(''); // 現在のqueryKeyを生成 // 画像再生成を防ぐため、themeDataはクエリキーに含めない const currentQueryKey = ['cnHtml', tabName, cnDataKey, provider, referenceUrl, dummyMode]; return useQuery({ ...createQueryOptions(), queryKey: currentQueryKey, queryFn: () => { if (!cnData) { throw new Error('cnData is required'); } console.log(`[CN HTML] 生成開始: ${tabName}`); return generateContentsHtml({ tabName, cnData, provider, referenceUrl, screenshot, dummyMode, userEmail, themeData: null, // テーマはCSSのみで適用するため、画像生成時には渡さない }); }, enabled: enabled && !!cnData, }); } // 画像生成を別途実行するフック export function useContentsImageGeneration( tabName: string, cnData?: ContentSection[], htmlContent?: string, enabled: boolean = false, provider?: 'openai' | 'gemini' | 'claude', ) { const queryClient = useQueryClient(); return useQuery({ queryKey: ['contentsImageGeneration', tabName, cnData, provider], queryFn: async () => { if (!cnData || !htmlContent) { throw new Error('cnData and htmlContent are required'); } try { const batchResult = await startImageGeneration( htmlContent, tabName, cnData, provider === 'openai' || provider === 'claude' ? provider : 'claude', ); if (batchResult.batchId) { // ポーリング処理を開始 const pollInterval = setInterval(async () => { try { const status = await getImageGenerationStatus(batchResult.batchId); if (!status) { clearInterval(pollInterval); return; } if (status.status === 'completed' && status.jobs) { clearInterval(pollInterval); const imageReplacements = status.jobs .filter((job) => job.status === 'completed' && job.generatedImageUrl) .map((job) => ({ imageId: job.imageId, imageUrl: job.generatedImageUrl || '', })); if (imageReplacements.length > 0) { let updatedHtml = htmlContent; imageReplacements.forEach(({ imageId, imageUrl }) => { const regex = new RegExp(`src="placeholder:${imageId}"`, 'g'); updatedHtml = updatedHtml.replace(regex, `src="${imageUrl}"`); }); // キャッシュを更新 const currentData = queryClient.getQueryData(['contentsHtml', tabName, cnData, provider]); if (currentData) { queryClient.setQueryData(['contentsHtml', tabName, cnData, provider], { ...currentData, html: updatedHtml, }); } } } else if (status.status === 'failed') { clearInterval(pollInterval); console.error('[Contents Image Batch] Failed:', status); } } catch (error) { console.error('[Contents Image Polling] Error:', error); } }, 20000); // 20秒ごとにポーリング(負荷軽減) // 最大20分後にタイムアウト(画像生成の実際の処理時間を考慮) setTimeout(() => { clearInterval(pollInterval); console.warn('[Contents Image Polling] Timeout after 20 minutes'); }, 1200000); // 20分 = 1200秒 } return batchResult; } catch (error) { console.error('[Contents Image Batch] Failed to start:', error); throw error; } }, enabled: enabled && !!cnData && !!htmlContent, staleTime: Infinity, // 一度実行したら再実行しない }); }