206 lines
8.0 KiB
TypeScript
206 lines
8.0 KiB
TypeScript
"use client";
|
|
|
|
import { useEffect, useState } from "react";
|
|
import type {
|
|
AiCityForecastPayload,
|
|
AiCityForecastState,
|
|
} from "@/components/dashboard/scan-terminal/types";
|
|
import { formatTemperatureValue } from "@/lib/temperature-utils";
|
|
|
|
function confidenceBadge(confidence: string | null, isEn: boolean) {
|
|
const c = String(confidence || "").toLowerCase();
|
|
if (c === "high") return { label: isEn ? "High" : "高", tone: "high" };
|
|
if (c === "medium") return { label: isEn ? "Medium" : "中", tone: "medium" };
|
|
return { label: isEn ? "Low" : "低", tone: "low" };
|
|
}
|
|
|
|
function useTransitionMarker(status: string, hasContent: boolean) {
|
|
const [showUpdated, setShowUpdated] = useState(false);
|
|
useEffect(() => {
|
|
if (status === "ready" && hasContent) {
|
|
setShowUpdated(true);
|
|
const id = setTimeout(() => setShowUpdated(false), 4000);
|
|
return () => clearTimeout(id);
|
|
}
|
|
}, [status, hasContent]);
|
|
return showUpdated;
|
|
}
|
|
|
|
export function AiEvidencePanel({
|
|
aiBullets,
|
|
aiCityForecast,
|
|
aiForecast,
|
|
aiPredictedMax,
|
|
aiRangeLow,
|
|
aiRangeHigh,
|
|
aiConfidence,
|
|
aiReadCompleteText,
|
|
aiReadInProgressText,
|
|
aiRuleEvidenceMode,
|
|
aiRuleEvidenceText,
|
|
debPrediction,
|
|
fallbackAiReason,
|
|
isEn,
|
|
isHkoObservation,
|
|
localModelSupportNote,
|
|
localizedFinalJudgment,
|
|
tempSymbol,
|
|
}: {
|
|
aiBullets: string[];
|
|
aiCityForecast: AiCityForecastPayload["city_forecast"] | null;
|
|
aiForecast: AiCityForecastState;
|
|
aiPredictedMax: number | null;
|
|
aiRangeLow: number | null;
|
|
aiRangeHigh: number | null;
|
|
aiConfidence: string | null;
|
|
aiReadCompleteText: string;
|
|
aiReadInProgressText: string;
|
|
aiRuleEvidenceMode: boolean;
|
|
aiRuleEvidenceText: string;
|
|
debPrediction: number | null;
|
|
fallbackAiReason: string;
|
|
isEn: boolean;
|
|
isHkoObservation: boolean;
|
|
localModelSupportNote: string;
|
|
localizedFinalJudgment: string;
|
|
tempSymbol: string;
|
|
}) {
|
|
const aiConfidenceMeta = confidenceBadge(aiConfidence, isEn);
|
|
const hasAiPrediction = aiPredictedMax != null;
|
|
const hasDebPrediction = debPrediction != null;
|
|
const aiRangeText =
|
|
aiRangeLow != null && aiRangeHigh != null
|
|
? `${formatTemperatureValue(aiRangeLow, tempSymbol, { digits: 0 })} ~ ${formatTemperatureValue(aiRangeHigh, tempSymbol, { digits: 0 })}`
|
|
: null;
|
|
|
|
const showUpdatedBadge = useTransitionMarker(aiForecast.status, Boolean(aiCityForecast));
|
|
|
|
return (
|
|
<section className="scan-ai-city-section scan-ai-city-ai-read">
|
|
<div className="scan-ai-city-section-title">
|
|
{isHkoObservation
|
|
? isEn
|
|
? "Evidence · AI HKO observation read"
|
|
: "证据 · AI 香港天文台观测解读"
|
|
: isEn
|
|
? "Evidence · AI airport read"
|
|
: "证据 · AI 机场报文解读"}
|
|
</div>
|
|
<div className="scan-ai-city-section-body">
|
|
{hasAiPrediction || hasDebPrediction ? (
|
|
<div className="scan-ai-prediction-dual">
|
|
{hasAiPrediction ? (
|
|
<div className="scan-ai-prediction-card ai">
|
|
<small>{isEn ? "AI predicted high" : "AI 预测最高温"}</small>
|
|
<strong>{formatTemperatureValue(aiPredictedMax!, tempSymbol, { digits: 1 })}</strong>
|
|
<span className={`scan-ai-confidence ${aiConfidenceMeta.tone}`}>
|
|
{aiConfidenceMeta.label}
|
|
</span>
|
|
{aiRangeText ? <em>{aiRangeText}</em> : null}
|
|
</div>
|
|
) : aiForecast.status === "loading" && hasDebPrediction ? (
|
|
<div className="scan-ai-prediction-card ai pending">
|
|
<small>{isEn ? "AI predicted high" : "AI 预测最高温"}</small>
|
|
<strong>{isEn ? "..." : "…"}</strong>
|
|
<span className="scan-ai-confidence low">
|
|
{isEn ? "Predicting" : "预测中"}
|
|
</span>
|
|
</div>
|
|
) : null}
|
|
{hasDebPrediction ? (
|
|
<div className="scan-ai-prediction-card deb">
|
|
<small>{isEn ? "DEB fusion" : "DEB 融合"}</small>
|
|
<strong>{formatTemperatureValue(debPrediction!, tempSymbol, { digits: 1 })}</strong>
|
|
<span className="scan-ai-confidence neutral">
|
|
{isEn ? "Reference" : "参考"}
|
|
</span>
|
|
</div>
|
|
) : null}
|
|
</div>
|
|
) : hasDebPrediction ? (
|
|
<div className="scan-ai-prediction-dual">
|
|
<div className="scan-ai-prediction-card deb">
|
|
<small>{isEn ? "DEB fusion" : "DEB 融合"}</small>
|
|
<strong>{formatTemperatureValue(debPrediction!, tempSymbol, { digits: 1 })}</strong>
|
|
<span className="scan-ai-confidence neutral">
|
|
{isEn ? "Reference" : "参考"}
|
|
</span>
|
|
</div>
|
|
<div className="scan-ai-prediction-card ai pending">
|
|
<small>{isEn ? "AI predicted high" : "AI 预测最高温"}</small>
|
|
<strong>{isEn ? "..." : "…"}</strong>
|
|
<span className="scan-ai-confidence low">
|
|
{isEn ? "Predicting" : "预测中"}
|
|
</span>
|
|
</div>
|
|
</div>
|
|
) : null}
|
|
|
|
{aiForecast.status === "loading" ? (
|
|
<>
|
|
<p className="scan-ai-weather-summary">{aiReadInProgressText}</p>
|
|
{localizedFinalJudgment || aiForecast.streamText ? (
|
|
<p className="scan-ai-city-muted">
|
|
{localizedFinalJudgment || aiForecast.streamText}
|
|
</p>
|
|
) : null}
|
|
<p className="scan-ai-city-muted">
|
|
{isEn
|
|
? isHkoObservation
|
|
? "Rule evidence is shown first; the full HKO AI read will merge automatically."
|
|
: "Rule evidence is shown first; the full airport AI read will merge automatically."
|
|
: isHkoObservation
|
|
? "先展示规则证据,完整香港天文台 AI 解读返回后会自动合并。"
|
|
: "先展示规则证据,完整机场 AI 解读返回后会自动合并。"}
|
|
</p>
|
|
</>
|
|
) : aiForecast.status === "ready" && aiCityForecast ? (
|
|
<>
|
|
{showUpdatedBadge ? (
|
|
<span className="scan-ai-updated-badge">
|
|
{isEn ? "✓ Updated" : "✓ 已更新"}
|
|
</span>
|
|
) : null}
|
|
<p className="scan-ai-weather-summary">
|
|
{aiRuleEvidenceMode ? aiRuleEvidenceText : aiReadCompleteText}
|
|
</p>
|
|
<ul className="scan-ai-weather-bullets">
|
|
{[localizedFinalJudgment, ...aiBullets]
|
|
.filter((line) => String(line || "").trim())
|
|
.map((line, index) => (
|
|
<li key={`${line}-${index}`}>{line}</li>
|
|
))}
|
|
</ul>
|
|
</>
|
|
) : aiForecast.status === "ready" ? (
|
|
<>
|
|
<p className="scan-ai-weather-summary">{aiRuleEvidenceText}</p>
|
|
<ul className="scan-ai-weather-bullets">
|
|
{fallbackAiReason ? <li>{fallbackAiReason}</li> : null}
|
|
<li>{localModelSupportNote}</li>
|
|
</ul>
|
|
</>
|
|
) : aiForecast.status === "failed" ? (
|
|
<>
|
|
<p className="scan-ai-weather-summary">{aiRuleEvidenceText}</p>
|
|
<ul className="scan-ai-weather-bullets">
|
|
{aiForecast.error ? <li>{aiForecast.error}</li> : null}
|
|
<li>{localModelSupportNote}</li>
|
|
</ul>
|
|
</>
|
|
) : (
|
|
<p>
|
|
{isEn
|
|
? isHkoObservation
|
|
? "Waiting for AI to read the latest HKO observation."
|
|
: "Waiting for AI to read the latest airport bulletin."
|
|
: isHkoObservation
|
|
? "等待 AI 解读最新香港天文台观测。"
|
|
: "等待 AI 解读最新机场报文。"}
|
|
</p>
|
|
)}
|
|
</div>
|
|
</section>
|
|
);
|
|
}
|