import {useCallback, useEffect} from 'react'; import {Check, Loader2} from 'lucide-react'; import {JobStatus} from '@o2o/shared'; import {delay, getAccessToken, pollJob, startCopy} from '@/api'; import {Progress} from '@/components/ui/progress'; import {notify, notifyApiError} from '@/lib/notify'; import {cn} from '@/lib/utils'; import {useBuilderStore} from '@/stores/builder'; import {EDITOR_STEP, useWizardStep} from './wizardUrl'; const BUILD_STEPS = [ '수집된 사진 분류 및 대체 텍스트 생성', '브랜드 컬러 시스템 및 타이포그래피 조합', '확인된 사실만으로 소개 콘텐츠 구성', '위치 기반 길찾기 및 주변 정보 연동', '구조화 데이터(JSON-LD) 준비', ]; const STAGE_MS = 600; /** * ★ 잡은 PENDING/RUNNING/DONE 세 상태만 준다 — 단계별 progress 필드가 없다. * 그래서 문구는 "지금 서버가 이 근처를 하고 있다"는 안내이고, 진짜 신호는 잡 상태다. * RUNNING 동안 마지막 단계 직전까지만 올리고, 완료는 오직 DONE 이 만든다 * — 100%를 먼저 그려 놓고 기다리게 하지 않는다. */ const RUNNING_STAGE_CEILING = BUILD_STEPS.length - 1; export function Step5Generating() { const storeName = useBuilderStore((s) => s.storeName); const stage = useBuilderStore((s) => s.generateStage); const setGenerateStage = useBuilderStore((s) => s.setGenerateStage); const placeId = useBuilderStore((s) => s.placeId); const [, goToStep] = useWizardStep(); /** * 생성이 끝나면 곧바로 에디터다. * * ★ 중간에 다른 화면을 끼우지 않는다. 위저드를 끝까지 걸어온 사람의 다음 용건은 * 자기 사이트를 보는 것이고, 목록을 한 번 거치게 하면 그게 한 번 더 클릭이 된다. * 사업장은 이미 2단계에서 등록돼 목록에 들어가 있다 — '목록에 생기는 것'과 * '목록으로 보내는 것'은 다른 얘기다. */ const finishOnboarding = useCallback(() => { // ★ `flow=onboarding` 도 같이 뗀다. 남겨 두면 이 주소를 새로고침했을 때 위저드 중인 // 사업장으로 읽혀 신원이 다시 세워진다 — 편집 중인 사람에게는 아무 의미가 없는 일이다. goToStep(EDITOR_STEP, {params: {flow: null}, replace: true}); }, [goToStep]); /** * 소개문·FAQ 생성(JobType.COPY). `POST /v1/place/{id}/copy` 로 잡을 넣고 폴링한다. * * ★ 정적 빌드(JobType.BUILD)는 여기가 아니라 발행 모달이 부른다 — 빌드는 발행 검수 * 게이트와 한 몸이고(services/build_service), 여기서 미리 구워 봐야 편집 전 스냅샷이라 * 버릴 버전만 쌓인다. * ★ 폴백: placeId(데모 경로) 나 토큰이 없으면 백엔드를 아예 부르지 않고 타이머로 돈다. */ useEffect(() => { const controller = new AbortController(); /** 타이머 시뮬레이션. 폴백 경로 전용 — 백엔드가 없어도 위저드는 끝까지 간다. */ const runSimulation = async () => { for (let next = 1; next <= BUILD_STEPS.length; next += 1) { await delay(STAGE_MS, controller.signal); if (controller.signal.aborted) return; setGenerateStage(next); } await delay(STAGE_MS, controller.signal); if (!controller.signal.aborted) finishOnboarding(); }; /** RUNNING 인 동안 문구만 천천히 밀어 준다. 완료 판정은 하지 않는다. */ const creep = () => setGenerateStage( Math.min(RUNNING_STAGE_CEILING, useBuilderStore.getState().generateStage + 1), ); const runCopy = async (id: string) => { let jobId: string; try { const started = await startCopy(id, {}, undefined, controller.signal); // ★ 근거로 쓸 확인된 fact 가 0 이면 서버는 아무것도 쓰지 않는다 — LLM 은 사실을 만들지 않는다. if (started.grounded_facts === 0) { notify.warn( '확인된 정보가 없어 소개문을 쓰지 않았습니다.', '[확인] 탭에서 수집된 정보를 확정하면 그 사실만으로 소개문을 씁니다.', ); } if (!started.job_id) { await runSimulation(); return; } jobId = started.job_id; } catch (error) { if (controller.signal.aborted) return; notifyApiError(error, '소개문 생성을 시작하지 못했습니다.'); await runSimulation(); return; } setGenerateStage(1); const outcome = await pollJob(jobId, { signal: controller.signal, onTick: (job) => { if (job.status === JobStatus.RUNNING) creep(); }, }); if (outcome.kind === 'aborted') return; if (outcome.kind === 'dead') { notify.error('소개문을 만들지 못했습니다.', outcome.job.last_error ?? undefined); } else if (outcome.kind === 'unreachable') { notifyApiError(outcome.error, '생성 상태를 확인하지 못했습니다.'); } else if (outcome.kind === 'timeout') { notify.warn('생성이 예상보다 오래 걸립니다.', '생성은 계속 진행됩니다.'); } // ★ 실패해도 편집기로는 보낸다. 소개문은 없어도 확인된 fact 로 편집·발행이 되고, // 여기서 멈추면 사장님은 되돌아갈 길이 없는 화면에 갇힌다. setGenerateStage(BUILD_STEPS.length); await delay(STAGE_MS, controller.signal); if (!controller.signal.aborted) finishOnboarding(); }; if (!placeId || !getAccessToken()) void runSimulation(); else void runCopy(placeId); return () => controller.abort(); }, [setGenerateStage, finishOnboarding, placeId]); const progress = Math.min(100, Math.round((stage / BUILD_STEPS.length) * 100)); return (
{storeName}의 확인된 정보만 담아 정적 페이지로 굽고 있어요.
생성이 끝나면 실시간 에디터로 자동 전환됩니다.