feat: add AI city forecast terminal and integration components

This commit is contained in:
2569718930@qq.com
2026-04-26 10:43:14 +08:00
parent 312b366a50
commit e374ad4bf0
6 changed files with 155 additions and 34 deletions
+16 -11
View File
@@ -5,13 +5,18 @@ import {
} from "@/lib/backend-auth";
const API_BASE = process.env.POLYWEATHER_API_BASE_URL;
const SCAN_AI_PROXY_TIMEOUT_MS = Math.max(
const AI_CITY_GATEWAY_TIMEOUT_MS = Math.max(
10_000,
Number(process.env.POLYWEATHER_SCAN_AI_PROXY_TIMEOUT_MS || "55000") || 55_000,
Number(
process.env.POLYWEATHER_SCAN_AI_GATEWAY_TIMEOUT_MS ||
process.env.POLYWEATHER_AI_CITY_GATEWAY_TIMEOUT_MS ||
process.env.POLYWEATHER_SCAN_AI_PROXY_TIMEOUT_MS ||
"120000",
) || 120_000,
);
export const dynamic = "force-dynamic";
export const maxDuration = 90;
export const maxDuration = 130;
export async function POST(req: NextRequest) {
if (!API_BASE) {
@@ -31,7 +36,7 @@ export async function POST(req: NextRequest) {
let auth: Awaited<ReturnType<typeof buildBackendRequestHeaders>> | null = null;
const controller = new AbortController();
const startedAt = Date.now();
const timeoutId = setTimeout(() => controller.abort(), SCAN_AI_PROXY_TIMEOUT_MS);
const timeoutId = setTimeout(() => controller.abort(), AI_CITY_GATEWAY_TIMEOUT_MS);
const requestBody = body && typeof body === "object" ? body as Record<string, unknown> : {};
try {
@@ -39,11 +44,11 @@ export async function POST(req: NextRequest) {
const headers = new Headers(auth.headers);
headers.set("Content-Type", "application/json");
headers.set("Accept", "application/json");
console.info("[scan-ai-city] proxy request", {
console.info("[scan-ai-city] gateway request", {
city: requestBody.city,
force_refresh: requestBody.force_refresh === true,
locale: requestBody.locale,
timeout_ms: SCAN_AI_PROXY_TIMEOUT_MS,
timeout_ms: AI_CITY_GATEWAY_TIMEOUT_MS,
});
const res = await fetch(`${API_BASE}/api/scan/terminal/ai-city`, {
method: "POST",
@@ -65,7 +70,7 @@ export async function POST(req: NextRequest) {
return applyAuthResponseCookies(response, auth.response);
}
const data = await res.json();
console.info("[scan-ai-city] proxy complete", {
console.info("[scan-ai-city] gateway complete", {
status: data?.status,
city: data?.city,
model: data?.model,
@@ -81,21 +86,21 @@ export async function POST(req: NextRequest) {
} catch (error) {
const timedOut = controller.signal.aborted;
const elapsedMs = Date.now() - startedAt;
console.warn("[scan-ai-city] proxy failed", {
console.warn("[scan-ai-city] gateway failed", {
city: requestBody.city,
timed_out: timedOut,
elapsed_ms: elapsedMs,
timeout_ms: SCAN_AI_PROXY_TIMEOUT_MS,
timeout_ms: AI_CITY_GATEWAY_TIMEOUT_MS,
error: String(error),
});
const response = NextResponse.json(
{
error: timedOut
? "City AI request timed out"
? "City AI gateway timed out before backend responded"
: "Failed to fetch city AI data",
detail: String(error),
elapsed_ms: elapsedMs,
timeout_ms: SCAN_AI_PROXY_TIMEOUT_MS,
timeout_ms: AI_CITY_GATEWAY_TIMEOUT_MS,
city: requestBody.city,
},
{ status: timedOut ? 504 : 500 },
+22 -4
View File
@@ -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);
+21 -13
View File
@@ -599,6 +599,13 @@ export function DashboardStoreProvider({
const citiesRef = useRef<CityListItem[]>([]);
const citySummariesRef = useRef<Record<string, CitySummary>>({});
const selectedCityRef = useRef<string | null>(null);
const setCityDetailLoading = (isLoading: boolean) => {
setLoadingState((current) =>
current.cityDetail === isLoading
? current
: { ...current, cityDetail: isLoading },
);
};
const selectedDetail = selectedCity
? findCachedCityDetail(cityDetailsByName, selectedCity)
: null;
@@ -790,7 +797,7 @@ export function DashboardStoreProvider({
if (findCachedCityDetail(cityDetailsByName, selectedCity)) return;
let cancelled = false;
setLoadingState((current) => ({ ...current, cityDetail: true }));
setCityDetailLoading(true);
void ensureCityDetail(selectedCity, false, "panel")
.then((detail) => {
if (cancelled) return;
@@ -799,7 +806,7 @@ export function DashboardStoreProvider({
.catch(() => {})
.finally(() => {
if (cancelled) return;
setLoadingState((current) => ({ ...current, cityDetail: false }));
setCityDetailLoading(false);
});
return () => {
@@ -807,7 +814,6 @@ export function DashboardStoreProvider({
};
}, [
cityDetailsByName,
ensureCityDetail,
isPanelOpen,
proAccess.authenticated,
proAccess.loading,
@@ -1080,7 +1086,7 @@ export function DashboardStoreProvider({
: Promise.resolve(citySummariesRef.current[cityName]);
if (proAccessRef.current.loading) {
setLoadingState((current) => ({ ...current, cityDetail: true }));
setCityDetailLoading(true);
const detailPromise = ensureCityDetail(cityName, false, "panel");
try {
const [, detail] = await Promise.allSettled([summaryPromise, detailPromise]);
@@ -1091,12 +1097,14 @@ export function DashboardStoreProvider({
}
} catch {
} finally {
setLoadingState((current) => ({ ...current, cityDetail: false }));
if (selectedCityRef.current === cityName) {
setCityDetailLoading(false);
}
}
return;
}
setLoadingState((current) => ({ ...current, cityDetail: !cached }));
setCityDetailLoading(!cached);
const detailPromise = ensureCityDetail(cityName, false, "panel");
void Promise.allSettled([summaryPromise, detailPromise])
.then(([, detail]) => {
@@ -1107,7 +1115,7 @@ export function DashboardStoreProvider({
})
.finally(() => {
if (selectedCityRef.current !== cityName) return;
setLoadingState((current) => ({ ...current, cityDetail: false }));
setCityDetailLoading(false);
});
};
@@ -1119,7 +1127,7 @@ export function DashboardStoreProvider({
setSelectedForecastDate(null);
setFutureModalDate(null);
setForecastModalMode(null);
setLoadingState((current) => ({ ...current, cityDetail: !cached }));
setCityDetailLoading(!cached);
void Promise.allSettled([
ensureCitySummary(cityName),
ensureCityDetail(cityName, false, "panel"),
@@ -1132,7 +1140,7 @@ export function DashboardStoreProvider({
})
.finally(() => {
if (selectedCityRef.current !== cityName) return;
setLoadingState((current) => ({ ...current, cityDetail: false }));
setCityDetailLoading(false);
});
};
@@ -1369,12 +1377,12 @@ export function DashboardStoreProvider({
selectedCityRef.current === cityName;
let cachedDetail = findCachedCityDetail(cityDetailsByName, selectedCity);
if (!cachedDetail) {
setLoadingState((current) => ({ ...current, cityDetail: true }));
setCityDetailLoading(true);
try {
cachedDetail = await ensureCityDetail(cityName, false, "panel");
} finally {
if (isLatestModalRequest()) {
setLoadingState((current) => ({ ...current, cityDetail: false }));
setCityDetailLoading(false);
}
}
}
@@ -1423,12 +1431,12 @@ export function DashboardStoreProvider({
selectedCityRef.current === cityName;
let cachedDetail = findCachedCityDetail(cityDetailsByName, cityName);
if (!cachedDetail) {
setLoadingState((current) => ({ ...current, cityDetail: true }));
setCityDetailLoading(true);
try {
cachedDetail = await ensureCityDetail(cityName, false, "panel");
} finally {
if (isLatestModalRequest()) {
setLoadingState((current) => ({ ...current, cityDetail: false }));
setCityDetailLoading(false);
}
}
}
+3
View File
@@ -64,6 +64,7 @@ export type AssistantChatResponse = {
const CACHE_KEY = "polyWeather_v1";
const CACHE_TTL_MS = 30 * 60 * 1000;
const SCAN_TERMINAL_CLIENT_TIMEOUT_MS = 35_000;
const CITY_DETAIL_CLIENT_TIMEOUT_MS = 35_000;
const pendingCityDetailRequests = new Map<string, Promise<CityDetail>>();
const pendingHistoryRequests = new Map<string, Promise<HistoryPayload>>();
const pendingCitySummaryRequests = new Map<string, Promise<CitySummary>>();
@@ -320,6 +321,7 @@ export const dashboardClient = {
const request = fetchJson<CityDetail>(
`/api/city/${normalizeCityName(cityName)}?force_refresh=false&depth=${depth}`,
{ timeoutMs: CITY_DETAIL_CLIENT_TIMEOUT_MS },
).finally(() => {
pendingCityDetailRequests.delete(requestKey);
});
@@ -335,6 +337,7 @@ export const dashboardClient = {
});
return fetchJson<CityDetail>(
`/api/city/${normalizeCityName(cityName)}?${params.toString()}`,
{ timeoutMs: CITY_DETAIL_CLIENT_TIMEOUT_MS },
);
},