feat: add AI city forecast terminal and integration components
This commit is contained in:
@@ -226,7 +226,9 @@ export function DetailPanel({
|
||||
(detail.forecast?.daily?.length ?? 0) <= 1),
|
||||
);
|
||||
const isPanelSyncing = store.loadingState.cityDetail;
|
||||
const isShowingCachedDetailDuringSync = Boolean(detail && isPanelSyncing);
|
||||
const [panelSyncTimedOut, setPanelSyncTimedOut] = useState(false);
|
||||
const showPanelSyncing = isPanelSyncing && !panelSyncTimedOut;
|
||||
const isShowingCachedDetailDuringSync = false;
|
||||
|
||||
const blurActiveElement = () => {
|
||||
if (typeof document === "undefined") return;
|
||||
@@ -269,6 +271,22 @@ export function DetailPanel({
|
||||
void store.openHistory();
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
if (!isPanelSyncing || !store.selectedCity) {
|
||||
setPanelSyncTimedOut(false);
|
||||
return;
|
||||
}
|
||||
|
||||
setPanelSyncTimedOut(false);
|
||||
const timeoutId = window.setTimeout(() => {
|
||||
setPanelSyncTimedOut(true);
|
||||
}, 12_000);
|
||||
|
||||
return () => {
|
||||
window.clearTimeout(timeoutId);
|
||||
};
|
||||
}, [isPanelSyncing, store.selectedCity]);
|
||||
|
||||
useEffect(() => {
|
||||
const panel = panelRef.current;
|
||||
if (!panel) return;
|
||||
@@ -362,7 +380,7 @@ export function DetailPanel({
|
||||
</div>
|
||||
<h2>{panelDisplayName}</h2>
|
||||
</div>
|
||||
{isPanelSyncing && (
|
||||
{showPanelSyncing && (
|
||||
<div
|
||||
className="panel-loading-hint"
|
||||
role="status"
|
||||
@@ -462,14 +480,14 @@ export function DetailPanel({
|
||||
</div>
|
||||
</div>
|
||||
<div style={{ color: "var(--text-muted)", fontSize: "13px" }}>
|
||||
{store.loadingState.cityDetail
|
||||
{showPanelSyncing
|
||||
? t("detail.loading")
|
||||
: t("detail.emptyHint")}
|
||||
</div>
|
||||
</section>
|
||||
) : !detail ? (
|
||||
<div className="detail-mini-meta" role="status" aria-live="polite">
|
||||
{store.loadingState.cityDetail
|
||||
{showPanelSyncing
|
||||
? locale === "en-US"
|
||||
? "Loading city cards..."
|
||||
: "正在加载城市卡片..."
|
||||
|
||||
@@ -423,8 +423,8 @@ function AiPinnedCityCard({
|
||||
</p>
|
||||
<p className="scan-ai-city-muted">
|
||||
{isEn
|
||||
? "Non-streaming mode is enabled to avoid truncated airport reads."
|
||||
: "已停用流式输出,改用非流式 JSON 请求,避免报文解读被截断。"}
|
||||
? "The final airport read will appear here shortly."
|
||||
: "机场报文解读稍后将在这里显示。"}
|
||||
</p>
|
||||
</>
|
||||
) : aiForecast.status === "ready" && aiCityForecast ? (
|
||||
|
||||
@@ -57,6 +57,77 @@ function writeCachedPayload<T>(key: string, payload: T) {
|
||||
}
|
||||
}
|
||||
|
||||
function buildAiCityFallbackPayload({
|
||||
detail,
|
||||
error,
|
||||
isEn,
|
||||
report,
|
||||
}: {
|
||||
detail: CityDetail | null;
|
||||
error?: unknown;
|
||||
isEn: boolean;
|
||||
report: string;
|
||||
}): AiCityForecastPayload {
|
||||
const tempSymbol = detail?.temp_symbol || "°C";
|
||||
const currentTemp =
|
||||
detail?.airport_current?.temp ??
|
||||
detail?.airport_primary?.temp ??
|
||||
detail?.current?.temp ??
|
||||
null;
|
||||
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 || ""));
|
||||
const rawMetar = String(report || detail?.airport_current?.raw_metar || detail?.current?.raw_metar || "").trim();
|
||||
|
||||
const finalZh = timeoutLike
|
||||
? "AI 解读暂未返回;先以多模型集中度和最新 METAR 实况作为判断依据。"
|
||||
: "AI 解读暂不可用;先以多模型集中度和最新 METAR 实况作为判断依据。";
|
||||
const finalEn = timeoutLike
|
||||
? "The AI read is not back yet; use the model cluster and latest METAR as the working read."
|
||||
: "The AI read is temporarily unavailable; use the model cluster and latest METAR as the working read.";
|
||||
const metarZh = rawMetar
|
||||
? `最新 METAR 显示 ${currentText};原始报文已保留,可继续结合后续报文确认温度路径。`
|
||||
: `当前可先参考 ${currentText} 与多模型路径,等待下一次机场报文更新。`;
|
||||
const metarEn = rawMetar
|
||||
? `Latest METAR shows ${currentText}; the raw bulletin is preserved and later reports should confirm the path.`
|
||||
: `Use ${currentText} and the model path for now while waiting for the next airport bulletin.`;
|
||||
const reasonZh = timeoutLike
|
||||
? "AI 服务响应较慢,本次未在页面等待窗口内完成;页面已自动降级为天气证据模式。"
|
||||
: "AI 服务本次没有返回可用解读;页面已自动降级为天气证据模式。";
|
||||
const reasonEn = timeoutLike
|
||||
? "The AI service was slow and did not finish within the page wait window; the card fell back to weather evidence mode."
|
||||
: "The AI service did not return a usable read; the card fell back to weather evidence mode.";
|
||||
|
||||
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",
|
||||
};
|
||||
}
|
||||
|
||||
export function useAiCityForecast({
|
||||
detail,
|
||||
detailCityName,
|
||||
@@ -156,21 +227,37 @@ export function useAiCityForecast({
|
||||
})
|
||||
.then((payload) => {
|
||||
if (!cancelled) {
|
||||
writeCachedPayload(cacheKey, payload);
|
||||
setAiForecast({ payload, status: "ready" });
|
||||
const usablePayload =
|
||||
payload?.city_forecast
|
||||
? payload
|
||||
: buildAiCityFallbackPayload({
|
||||
detail,
|
||||
error: payload?.reason || payload?.raw_reason || payload?.status,
|
||||
isEn,
|
||||
report,
|
||||
});
|
||||
writeCachedPayload(cacheKey, usablePayload);
|
||||
setAiForecast({ payload: usablePayload, status: "ready" });
|
||||
}
|
||||
})
|
||||
.catch((error) => {
|
||||
if (controller.signal.aborted) return;
|
||||
if (!cancelled) {
|
||||
setAiForecast({ error: String(error), status: "failed" });
|
||||
const fallbackPayload = buildAiCityFallbackPayload({
|
||||
detail,
|
||||
error,
|
||||
isEn,
|
||||
report,
|
||||
});
|
||||
writeCachedPayload(cacheKey, fallbackPayload);
|
||||
setAiForecast({ payload: fallbackPayload, status: "ready" });
|
||||
}
|
||||
});
|
||||
return () => {
|
||||
cancelled = true;
|
||||
controller.abort();
|
||||
};
|
||||
}, [aiForecastKey, aiRefreshToken, detailCityName, enabled, isEn, locale]);
|
||||
}, [aiForecastKey, aiRefreshToken, detail, detailCityName, enabled, isEn, locale, report]);
|
||||
|
||||
const refreshAiForecast = useCallback(() => {
|
||||
setAiRefreshToken((current) => current + 1);
|
||||
|
||||
Reference in New Issue
Block a user