157 lines
4.4 KiB
TypeScript
157 lines
4.4 KiB
TypeScript
"use client";
|
|
|
|
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
|
|
import { scanTerminalClient } from "@/components/dashboard/scan-terminal/scan-terminal-client";
|
|
import type { AiCityForecastState } from "@/components/dashboard/scan-terminal/types";
|
|
import type { CityDetail } from "@/lib/dashboard-types";
|
|
import {
|
|
buildAiCityErrorForecastState,
|
|
buildAiCityForecastCacheKey,
|
|
buildAiCityForecastKey,
|
|
buildAiCityForecastRequestKey,
|
|
buildAiCityLoadingForecastState,
|
|
buildAiCityProgressForecastState,
|
|
buildAiCityReadyForecastState,
|
|
readReadyCachedAiForecastState,
|
|
} from "./ai-city-forecast-stream-state";
|
|
|
|
const AI_CITY_READ_SOFT_TIMEOUT_MS = 4_500;
|
|
|
|
export function useAiCityForecast({
|
|
detail,
|
|
detailCityName,
|
|
isEn,
|
|
locale,
|
|
report,
|
|
enabled = true,
|
|
}: {
|
|
detail: CityDetail | null;
|
|
detailCityName: string;
|
|
enabled?: boolean;
|
|
isEn: boolean;
|
|
locale: string;
|
|
report: string;
|
|
}) {
|
|
const [aiRefreshToken, setAiRefreshToken] = useState(0);
|
|
// Keep refs to the latest detail/report so the effect closure always reads
|
|
// current values without needing to list unstable object refs as deps.
|
|
const detailRef = useRef<CityDetail | null>(detail);
|
|
const reportRef = useRef<string>(report);
|
|
detailRef.current = detail;
|
|
reportRef.current = report;
|
|
const aiForecastKey = useMemo(
|
|
() => buildAiCityForecastKey({ detail, detailCityName, locale, report }),
|
|
[detail, detailCityName, locale, report],
|
|
);
|
|
|
|
const [aiForecast, setAiForecast] = useState<AiCityForecastState>(() => {
|
|
if (!enabled || !aiForecastKey) return { status: "idle" };
|
|
const cacheKey = buildAiCityForecastCacheKey(aiForecastKey);
|
|
const cached = readReadyCachedAiForecastState(cacheKey, 0);
|
|
if (cached) return cached;
|
|
return { status: "idle" };
|
|
});
|
|
|
|
useEffect(() => {
|
|
if (!enabled || !aiForecastKey) {
|
|
setAiForecast({ status: "idle" });
|
|
return;
|
|
}
|
|
let cancelled = false;
|
|
let resolved = false;
|
|
const cacheKey = buildAiCityForecastCacheKey(aiForecastKey);
|
|
const requestKey = buildAiCityForecastRequestKey(cacheKey, aiRefreshToken);
|
|
|
|
// If cache was loaded into initial state and no force refresh, skip
|
|
if (aiRefreshToken === 0) {
|
|
const cached = readReadyCachedAiForecastState(cacheKey, 0);
|
|
if (cached) {
|
|
setAiForecast(cached);
|
|
return () => { cancelled = true; };
|
|
}
|
|
}
|
|
|
|
const loadingState = buildAiCityLoadingForecastState({
|
|
cacheKey,
|
|
detail: detailRef.current,
|
|
isEn,
|
|
report: reportRef.current,
|
|
});
|
|
setAiForecast(loadingState);
|
|
const softTimeoutId = window.setTimeout(() => {
|
|
if (cancelled || resolved) return;
|
|
const fallbackState = buildAiCityErrorForecastState({
|
|
cacheKey,
|
|
detail: detailRef.current,
|
|
error: "ai_soft_timeout_fallback",
|
|
isEn,
|
|
report: reportRef.current,
|
|
});
|
|
setAiForecast(fallbackState);
|
|
}, AI_CITY_READ_SOFT_TIMEOUT_MS);
|
|
void scanTerminalClient.streamAiCityRead({
|
|
city: detailCityName,
|
|
forceRefresh: aiRefreshToken > 0,
|
|
locale,
|
|
onProgress: (progress) => {
|
|
if (cancelled) return;
|
|
setAiForecast((current) =>
|
|
buildAiCityProgressForecastState({
|
|
cacheKey,
|
|
current,
|
|
isEn,
|
|
progress,
|
|
}) ?? current,
|
|
);
|
|
},
|
|
requestKey,
|
|
})
|
|
.then((payload) => {
|
|
if (!payload) return;
|
|
resolved = true;
|
|
window.clearTimeout(softTimeoutId);
|
|
const readyState = buildAiCityReadyForecastState({
|
|
cacheKey,
|
|
detail: detailRef.current,
|
|
isEn,
|
|
payload,
|
|
report: reportRef.current,
|
|
});
|
|
if (!cancelled) {
|
|
setAiForecast(readyState);
|
|
}
|
|
})
|
|
.catch((error) => {
|
|
resolved = true;
|
|
window.clearTimeout(softTimeoutId);
|
|
const errorState = buildAiCityErrorForecastState({
|
|
cacheKey,
|
|
detail: detailRef.current,
|
|
error,
|
|
isEn,
|
|
report: reportRef.current,
|
|
});
|
|
if (!cancelled) {
|
|
setAiForecast(errorState);
|
|
}
|
|
});
|
|
return () => {
|
|
cancelled = true;
|
|
window.clearTimeout(softTimeoutId);
|
|
};
|
|
}, [
|
|
aiForecastKey,
|
|
aiRefreshToken,
|
|
detailCityName,
|
|
enabled,
|
|
isEn,
|
|
locale,
|
|
]);
|
|
|
|
const refreshAiForecast = useCallback(() => {
|
|
setAiRefreshToken((current) => current + 1);
|
|
}, []);
|
|
|
|
return { aiForecast, refreshAiForecast };
|
|
}
|