GitHub Actions
Deploy from GitHub Actions [dev] - 2025-10-31 07:28:50
68f7925
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, // 一度実行したら再実行しない
});
}