2026-04-26 09:13:18 +08:00
"use client" ;
import { useCallback , useEffect , useMemo , useState } from "react" ;
import type {
AiCityForecastPayload ,
AiCityForecastState ,
} from "@/components/dashboard/scan-terminal/types" ;
2026-04-26 11:08:58 +08:00
import {
2026-04-28 10:24:40 +08:00
scanTerminalClient ,
type AiCityStreamProgress ,
} from "@/components/dashboard/scan-terminal/scan-terminal-client" ;
2026-04-26 09:13:18 +08:00
import type { CityDetail , MarketScan } from "@/lib/dashboard-types" ;
import { normalizeCityKey } from "./decision-utils" ;
2026-04-28 06:28:29 +08:00
const AI_CITY_FORECAST_CACHE_PREFIX = "polyWeather_aiCityForecast_v6" ;
2026-04-26 10:02:54 +08:00
const AI_CITY_FORECAST_CACHE_TTL_MS = 60 * 60 * 1000 ;
2026-04-27 01:38:37 +08:00
const AI_CITY_FORECAST_MAX_CONCURRENT_STREAMS = 2 ;
2026-04-27 00:54:24 +08:00
const CITY_MARKET_SCAN_CACHE_PREFIX = "polyWeather_cityMarketScan_v3" ;
2026-04-26 10:02:54 +08:00
const CITY_MARKET_SCAN_CACHE_TTL_MS = 10 * 60 * 1000 ;
2026-04-26 12:39:16 +08:00
const pendingAiCityForecastRequests = new Map <
string ,
Promise < AiCityForecastPayload >
> ();
2026-04-27 01:38:37 +08:00
const queuedAiCityForecastTasks : Array < () => void > = [];
2026-04-27 01:14:31 +08:00
const aiCityForecastStateCache = new Map <
string ,
{ state : AiCityForecastState ; updatedAt : number }
> ();
2026-04-27 01:38:37 +08:00
let activeAiCityForecastStreams = 0 ;
2026-04-26 12:39:16 +08:00
2026-04-26 10:02:54 +08:00
function getStorage() {
if ( typeof window === "undefined" ) return null ;
try {
return window . localStorage ;
} catch {
return null ;
}
}
function buildStorageKey ( prefix : string , parts : Array < string | null | undefined >) {
return ` ${ prefix } : ${ parts
. map (( part ) => encodeURIComponent ( String ( part || "" ). trim ()))
. join ( ":" ) } ` ;
}
2026-04-27 02:18:34 +08:00
function isHkoObservationCity ( detail? : CityDetail | null ) {
const source = String (
detail ? . current ? . settlement_source ||
detail ? . settlement_station ? . settlement_source ||
"" ,
)
. trim ()
. toLowerCase ();
return source === "hko" ;
}
2026-04-26 10:02:54 +08:00
function readCachedPayload < T >( key : string , ttlMs : number ) : T | null {
const storage = getStorage ();
if ( ! storage ) return null ;
try {
const raw = storage . getItem ( key );
if ( ! raw ) return null ;
const parsed = JSON . parse ( raw ) as { cachedAt? : number ; payload? : T };
if ( ! parsed ? . payload ) return null ;
if ( Date . now () - Number ( parsed . cachedAt || 0 ) > ttlMs ) {
storage . removeItem ( key );
return null ;
}
return parsed . payload ;
} catch {
return null ;
}
}
function writeCachedPayload < T >( key : string , payload : T ) {
const storage = getStorage ();
if ( ! storage ) return ;
try {
storage . setItem ( key , JSON . stringify ({ cachedAt : Date.now (), payload }));
} catch {
// Ignore quota/privacy-mode failures; network fallbacks still work.
}
}
2026-04-26 13:50:46 +08:00
function removeCachedPayload ( key : string ) {
const storage = getStorage ();
if ( ! storage ) return ;
try {
storage . removeItem ( key );
} catch {
// Ignore privacy-mode failures; the next network request can still proceed.
}
}
2026-04-27 01:14:31 +08:00
function readCachedAiForecastState ( key : string , ttlMs : number ) {
const cached = aiCityForecastStateCache . get ( key );
if ( ! cached ) return null ;
if ( Date . now () - cached . updatedAt > ttlMs ) {
aiCityForecastStateCache . delete ( key );
return null ;
}
return cached . state ;
}
function writeCachedAiForecastState ( key : string , state : AiCityForecastState ) {
if ( ! key || state . status === "idle" ) return ;
aiCityForecastStateCache . set ( key , {
state ,
updatedAt : Date.now (),
});
}
2026-04-27 01:38:37 +08:00
function runQueuedAiCityForecastTask < T >(
task : () => Promise < T >,
onQueued ?: () => void ,
) {
return new Promise < T >(( resolve , reject ) => {
const run = () => {
activeAiCityForecastStreams += 1 ;
task ()
. then ( resolve , reject )
. finally (() => {
activeAiCityForecastStreams = Math . max (
0 ,
activeAiCityForecastStreams - 1 ,
);
const next = queuedAiCityForecastTasks . shift ();
if ( next ) next ();
});
};
if ( activeAiCityForecastStreams < AI_CITY_FORECAST_MAX_CONCURRENT_STREAMS ) {
run ();
} else {
onQueued ? .();
queuedAiCityForecastTasks . push ( run );
}
});
}
2026-04-26 12:39:16 +08:00
function requestAiCityForecast ({
city ,
forceRefresh ,
locale ,
onProgress ,
requestKey ,
} : {
city : string ;
forceRefresh : boolean ;
locale : string ;
onProgress ?: ( progress : AiCityStreamProgress ) => void ;
requestKey : string ;
}) {
const pending = pendingAiCityForecastRequests . get ( requestKey );
if ( pending ) return pending ;
2026-04-27 01:38:37 +08:00
const request = runQueuedAiCityForecastTask ( async () => {
2026-04-28 10:24:40 +08:00
return scanTerminalClient . streamAiCityRead ({
city ,
forceRefresh ,
locale ,
onProgress ,
2026-04-27 01:38:37 +08:00
});
}, () => {
onProgress ? .({
stage : "queued" ,
message_en :
2026-04-27 02:18:34 +08:00
"AI observation read is queued behind the cities already streaming..." ,
message_zh : "AI 观测解读已排队,正在等待前面的城市完成流式生成…" ,
2026-04-27 01:38:37 +08:00
});
})
2026-04-26 12:39:16 +08:00
. finally (() => {
pendingAiCityForecastRequests . delete ( requestKey );
});
pendingAiCityForecastRequests . set ( requestKey , request );
return request ;
}
function getAiCityStreamProgressText ( progress : AiCityStreamProgress , isEn : boolean ) {
const localizedMessage = String (
( isEn ? progress.message_en : progress.message_zh ) ||
( isEn ? progress.final_judgment_en : progress.final_judgment_zh ) ||
( isEn ? progress.metar_read_en : progress.metar_read_zh ) ||
"" ,
). trim ();
if ( localizedMessage ) return localizedMessage ;
const rawLength = Number ( progress . raw_length );
if ( Number . isFinite ( rawLength ) && rawLength > 0 ) {
return isEn
2026-04-27 02:18:34 +08:00
? `DeepSeek is streaming the observation enhancement... ${ Math . round ( rawLength ) } chars received.`
: `DeepSeek 正在流式增强观测解读... 已收到 ${ Math . round ( rawLength ) } 字符。` ;
2026-04-26 12:39:16 +08:00
}
return "" ;
}
2026-04-26 10:43:14 +08:00
function buildAiCityFallbackPayload ({
detail ,
error ,
isEn ,
report ,
} : {
detail : CityDetail | null ;
error? : unknown ;
isEn : boolean ;
report : string ;
}) : AiCityForecastPayload {
const tempSymbol = detail ? . temp_symbol || "°C" ;
2026-04-27 02:18:34 +08:00
const isHkoObservation = isHkoObservationCity ( detail );
2026-04-26 10:43:14 +08:00
const currentTemp =
2026-04-27 02:18:34 +08:00
( isHkoObservation
? detail?.current?.temp
: detail?.airport_current?.temp ??
detail ? . airport_primary ? . temp ??
detail ? . current ? . temp ) ?? null ;
2026-04-26 10:43:14 +08:00
const currentText =
currentTemp != null && Number . isFinite ( Number ( currentTemp ))
? ` ${ Number ( currentTemp ). toFixed ( 1 ) }${ tempSymbol } `
: isEn
? "the latest observed temperature"
: "最新实测温度" ;
const timeoutLike = /timeout|timed out|504|aborted|超时/i . test ( String ( error || "" ));
2026-04-27 02:18:34 +08:00
const rawMetar = isHkoObservation
? ""
: String ( report || detail ? . airport_current ? . raw_metar || detail ? . current ? . raw_metar || "" ). trim ();
const sourceZh = isHkoObservation ? "香港天文台观测" : "METAR" ;
const sourceEn = isHkoObservation ? "Hong Kong Observatory observation" : "METAR" ;
const bulletinZh = isHkoObservation ? "官方观测" : "机场报文" ;
const bulletinEn = isHkoObservation ? "official observation" : "airport bulletin" ;
2026-04-26 10:43:14 +08:00
const finalZh = timeoutLike
2026-04-27 02:18:34 +08:00
? `DeepSeek 增强暂未返回;当前先以多模型集中度和最新 ${ sourceZh } 快速判断。`
: `当前先以多模型集中度和最新 ${ sourceZh } 快速判断。` ;
2026-04-26 10:43:14 +08:00
const finalEn = timeoutLike
2026-04-27 02:18:34 +08:00
? `DeepSeek enhancement is not back yet; use the model cluster and latest ${ sourceEn } as the fast working read.`
: `Use the model cluster and latest ${ sourceEn } as the fast working read.` ;
2026-04-26 10:43:14 +08:00
const metarZh = rawMetar
2026-04-26 13:12:03 +08:00
? `最新 METAR 显示 ${ currentText } ;当前先作为实况锚点,并结合后续报文确认温度路径。`
2026-04-27 02:18:34 +08:00
: `当前可先参考 ${ currentText } 与多模型路径,等待下一次 ${ bulletinZh } 更新。` ;
2026-04-26 10:43:14 +08:00
const metarEn = rawMetar
2026-04-26 13:12:03 +08:00
? `Latest METAR shows ${ currentText } ; use it as the live anchor while later reports confirm the path.`
2026-04-27 02:18:34 +08:00
: `Use ${ currentText } and the model path for now while waiting for the next ${ bulletinEn } .` ;
2026-04-27 08:28:00 +08:00
const reasonZh = `DEB、多模型集合和最新 ${ sourceZh } 已足够给出当前方向判断;页面会在 DeepSeek 返回后合并完整机场报文解读。` ;
const reasonEn = `DEB, the model cluster and latest ${ sourceEn } are enough for the current directional read; the page will merge the full airport-bulletin read when DeepSeek returns.` ;
2026-04-26 10:43:14 +08:00
return {
city_forecast : {
confidence : "low" ,
final_judgment_en : finalEn ,
final_judgment_zh : finalZh ,
metar_read_en : metarEn ,
metar_read_zh : metarZh ,
model_cluster_note_en : "" ,
model_cluster_note_zh : "" ,
predicted_max : null ,
range_high : null ,
range_low : null ,
reasoning_en : reasonEn ,
reasoning_zh : reasonZh ,
risks_en : [],
risks_zh : [],
unit : tempSymbol ,
},
raw_reason : timeoutLike ? "ai_timeout_fallback" : "ai_unavailable_fallback" ,
reason : isEn ? reasonEn : reasonZh ,
reason_en : reasonEn ,
reason_zh : reasonZh ,
status : timeoutLike ? "timeout_fallback" : "fallback" ,
};
}
2026-04-26 09:13:18 +08:00
export function useAiCityForecast ({
detail ,
detailCityName ,
isEn ,
locale ,
report ,
2026-04-26 10:02:54 +08:00
enabled = true ,
2026-04-26 09:13:18 +08:00
} : {
detail : CityDetail | null ;
detailCityName : string ;
2026-04-26 10:02:54 +08:00
enabled? : boolean ;
2026-04-26 09:13:18 +08:00
isEn : boolean ;
locale : string ;
report : string ;
}) {
const [ aiForecast , setAiForecast ] = useState < AiCityForecastState >({
status : "idle" ,
});
const [ aiRefreshToken , setAiRefreshToken ] = useState ( 0 );
const aiForecastKey = useMemo (
2026-04-27 00:50:14 +08:00
() => {
if ( ! detail ) return "" ;
2026-04-27 02:18:34 +08:00
const isHkoObservation = isHkoObservationCity ( detail );
const observationSource = isHkoObservation ? "hko" : "metar" ;
const observationCurrent = isHkoObservation
? detail . current || {}
: detail . airport_current || detail . current || {};
const observationSignature =
( ! isHkoObservation ? String ( report || "" ). trim () : "" ) ||
2026-04-27 00:50:14 +08:00
[
2026-04-27 02:18:34 +08:00
observationSource ,
observationCurrent . report_time ,
observationCurrent . obs_time_epoch ,
observationCurrent . obs_time ,
2026-04-28 06:28:29 +08:00
observationCurrent . receipt_time ,
2026-04-27 02:18:34 +08:00
observationCurrent . temp ,
2026-04-28 06:28:29 +08:00
observationCurrent . max_so_far ,
2026-04-27 02:18:34 +08:00
observationCurrent . station_code ,
2026-04-28 06:28:29 +08:00
detail . metar_status ? . stale_for_today ,
detail . metar_status ? . last_observation_time ,
2026-04-27 00:50:14 +08:00
]
. filter (( part ) => part != null && part !== "" )
. join ( "|" );
return [
normalizeCityKey ( detailCityName ),
detail . local_date || "" ,
locale ,
2026-04-27 02:18:34 +08:00
observationSignature ,
2026-04-27 00:50:14 +08:00
]. join ( ":" );
},
2026-04-26 10:02:54 +08:00
[ detail , detailCityName , locale , report ],
2026-04-26 09:13:18 +08:00
);
useEffect (() => {
2026-04-26 10:02:54 +08:00
if ( ! enabled || ! aiForecastKey ) {
2026-04-26 09:13:18 +08:00
setAiForecast ({ status : "idle" });
return ;
}
let cancelled = false ;
2026-04-26 10:02:54 +08:00
const cacheKey = buildStorageKey ( AI_CITY_FORECAST_CACHE_PREFIX , [ aiForecastKey ]);
2026-04-26 12:39:16 +08:00
const requestKey = ` ${ cacheKey } : ${ aiRefreshToken > 0 ? `refresh: ${ aiRefreshToken } ` : "normal" } ` ;
2026-04-26 10:02:54 +08:00
const cachedPayload =
aiRefreshToken <= 0
? readCachedPayload < AiCityForecastPayload >(
cacheKey ,
AI_CITY_FORECAST_CACHE_TTL_MS ,
)
: null ;
if ( cachedPayload ) {
2026-04-26 13:50:46 +08:00
if (
cachedPayload . status === "ready" &&
! cachedPayload . degraded &&
cachedPayload . city_forecast
) {
2026-04-27 01:14:31 +08:00
const readyState : AiCityForecastState = {
payload : cachedPayload ,
status : "ready" ,
};
writeCachedAiForecastState ( cacheKey , readyState );
setAiForecast ( readyState );
2026-04-26 13:50:46 +08:00
return () => {
cancelled = true ;
};
}
removeCachedPayload ( cacheKey );
2026-04-26 10:02:54 +08:00
}
2026-04-27 01:14:31 +08:00
const cachedState =
aiRefreshToken <= 0
? readCachedAiForecastState ( cacheKey , AI_CITY_FORECAST_CACHE_TTL_MS )
: null ;
if ( cachedState ? . status === "ready" ) {
setAiForecast ( cachedState );
return () => {
cancelled = true ;
};
}
2026-04-26 12:58:33 +08:00
const initialFallback = buildAiCityFallbackPayload ({ detail , isEn , report });
2026-04-27 01:14:31 +08:00
const loadingState : AiCityForecastState =
cachedState ? . status === "loading"
? cachedState
: {
status : "loading" ,
streamText :
( isEn
? initialFallback.city_forecast?.metar_read_en
: initialFallback.city_forecast?.metar_read_zh ) ||
( isEn
2026-04-27 02:18:34 +08:00
? "Reading the latest observation with model fallback ready..."
: "已先用最新观测给出兜底解读,正在等待 DeepSeek 补充…" ),
2026-04-27 01:14:31 +08:00
};
writeCachedAiForecastState ( cacheKey , loadingState );
setAiForecast ( loadingState );
2026-04-26 12:39:16 +08:00
void requestAiCityForecast ({
city : detailCityName ,
forceRefresh : aiRefreshToken > 0 ,
locale ,
onProgress : ( progress ) => {
const progressText = getAiCityStreamProgressText ( progress , isEn );
if ( ! progressText ) return ;
2026-04-27 01:14:31 +08:00
const cachedProgressState = readCachedAiForecastState (
cacheKey ,
AI_CITY_FORECAST_CACHE_TTL_MS ,
);
const nextStreamText =
progress . stage === "calling_ai" && cachedProgressState ? . streamText
? cachedProgressState.streamText
: progressText ;
writeCachedAiForecastState ( cacheKey , {
... cachedProgressState ,
status : "loading" ,
streamText : nextStreamText ,
});
if ( cancelled ) return ;
2026-04-26 12:39:16 +08:00
setAiForecast (( current ) => ({
... current ,
status : "loading" ,
2026-04-26 12:58:33 +08:00
streamText :
progress.stage === "calling_ai" && current . streamText
? current.streamText
: progressText ,
2026-04-26 12:39:16 +08:00
}));
},
requestKey ,
2026-04-26 10:13:44 +08:00
})
2026-04-26 09:13:18 +08:00
. then (( payload ) => {
2026-04-26 11:08:58 +08:00
if ( ! payload ) return ;
2026-04-27 00:50:14 +08:00
const usablePayload =
payload ? . city_forecast
? payload
: buildAiCityFallbackPayload ({
detail ,
error : payload?.reason || payload ? . raw_reason || payload ? . status ,
isEn ,
report ,
});
if ( usablePayload . status === "ready" && ! usablePayload . degraded ) {
writeCachedPayload ( cacheKey , usablePayload );
}
2026-04-27 01:14:31 +08:00
writeCachedAiForecastState ( cacheKey , {
payload : usablePayload ,
status : "ready" ,
});
2026-04-26 09:13:18 +08:00
if ( ! cancelled ) {
2026-04-26 10:43:14 +08:00
setAiForecast ({ payload : usablePayload , status : "ready" });
2026-04-26 09:13:18 +08:00
}
})
. catch (( error ) => {
2026-04-27 01:14:31 +08:00
const fallbackPayload = buildAiCityFallbackPayload ({
detail ,
error ,
isEn ,
report ,
});
writeCachedAiForecastState ( cacheKey , {
payload : fallbackPayload ,
status : "ready" ,
});
2026-04-26 09:13:18 +08:00
if ( ! cancelled ) {
2026-04-26 10:43:14 +08:00
setAiForecast ({ payload : fallbackPayload , status : "ready" });
2026-04-26 09:13:18 +08:00
}
});
return () => {
cancelled = true ;
};
2026-04-26 10:43:14 +08:00
}, [ aiForecastKey , aiRefreshToken , detail , detailCityName , enabled , isEn , locale , report ]);
2026-04-26 09:13:18 +08:00
const refreshAiForecast = useCallback (() => {
setAiRefreshToken (( current ) => current + 1 );
}, []);
return { aiForecast , refreshAiForecast };
}
export function useCityMarketScan ({
detail ,
detailCityName ,
2026-04-26 10:02:54 +08:00
enabled = true ,
2026-04-26 09:13:18 +08:00
} : {
detail : CityDetail | null ;
detailCityName : string ;
2026-04-26 10:02:54 +08:00
enabled? : boolean ;
2026-04-26 09:13:18 +08:00
}) {
const [ marketScan , setMarketScan ] = useState < MarketScan | null >(
detail ? . market_scan || null ,
);
const [ marketStatus , setMarketStatus ] = useState <
"idle" | "loading" | "ready" | "failed"
> ( detail ? . market_scan ? "ready" : "idle" );
useEffect (() => {
if ( ! detail ) {
setMarketScan ( null );
setMarketStatus ( "idle" );
return ;
}
2026-04-26 10:02:54 +08:00
const cacheKey = buildStorageKey ( CITY_MARKET_SCAN_CACHE_PREFIX , [
normalizeCityKey ( detailCityName ),
detail . local_date || "" ,
2026-04-27 00:54:24 +08:00
"full" ,
2026-04-26 10:02:54 +08:00
]);
2026-04-26 09:13:18 +08:00
let cancelled = false ;
if ( detail . market_scan ) {
setMarketScan ( detail . market_scan );
setMarketStatus ( "ready" );
2026-04-26 10:02:54 +08:00
writeCachedPayload ( cacheKey , detail . market_scan );
return () => {
cancelled = true ;
};
}
if ( ! enabled ) {
const cached = readCachedPayload < MarketScan >(
cacheKey ,
CITY_MARKET_SCAN_CACHE_TTL_MS ,
);
if ( cached ) {
setMarketScan ( cached );
setMarketStatus ( "ready" );
} else {
setMarketScan ( null );
setMarketStatus ( "idle" );
}
return () => {
cancelled = true ;
};
}
const cached = readCachedPayload < MarketScan >(
cacheKey ,
CITY_MARKET_SCAN_CACHE_TTL_MS ,
);
if ( cached ) {
setMarketScan ( cached );
setMarketStatus ( "ready" );
return () => {
cancelled = true ;
};
2026-04-26 09:13:18 +08:00
} else {
setMarketStatus ( "loading" );
}
2026-04-28 10:24:40 +08:00
const controller = new AbortController ();
void scanTerminalClient . getMarketScan ( detailCityName , {
2026-04-27 00:50:14 +08:00
lite : false ,
2026-04-28 10:24:40 +08:00
signal : controller.signal ,
2026-04-26 09:13:18 +08:00
targetDate : detail.local_date || null ,
})
. then (( payload ) => {
if ( cancelled ) return ;
2026-04-26 10:02:54 +08:00
if ( payload ) {
writeCachedPayload ( cacheKey , payload );
}
2026-04-26 09:13:18 +08:00
setMarketScan ( payload || detail . market_scan || null );
setMarketStatus ( "ready" );
})
. catch (() => {
if ( cancelled ) return ;
setMarketScan ( detail . market_scan || null );
setMarketStatus ( detail . market_scan ? "ready" : "failed" );
});
return () => {
cancelled = true ;
2026-04-28 10:24:40 +08:00
controller . abort ();
2026-04-26 09:13:18 +08:00
};
2026-04-28 10:24:40 +08:00
}, [ detail , detailCityName , enabled ]);
2026-04-26 09:13:18 +08:00
return { marketScan , marketStatus };
}