Spaces:
Sleeping
Sleeping
| 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<string>(''); | |
| // 現在のqueryKeyを生成 | |
| // 画像再生成を防ぐため、themeDataはクエリキーに含めない | |
| const currentQueryKey = ['fvHtml', tabName, fvDataKey, provider, referenceUrl, dummyMode]; | |
| return useQuery<GenerateFvHtmlResponse>({ | |
| ...createQueryOptions<GenerateFvHtmlResponse>(), | |
| 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<string>(''); | |
| // 現在のqueryKeyを生成 | |
| // 画像再生成を防ぐため、themeDataはクエリキーに含めない | |
| const currentQueryKey = ['cnHtml', tabName, cnDataKey, provider, referenceUrl, dummyMode]; | |
| return useQuery<GenerateContentsHtmlResponse>({ | |
| ...createQueryOptions<GenerateContentsHtmlResponse>(), | |
| 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<GenerateContentsHtmlResponse>(['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, // 一度実行したら再実行しない | |
| }); | |
| } | |