feat: add AI city forecast terminal and integration components
This commit is contained in:
@@ -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 },
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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 },
|
||||
);
|
||||
},
|
||||
|
||||
|
||||
Reference in New Issue
Block a user