feat: implement AiPinnedForecastView component with associated data hooks and decision utilities for AI-driven weather analysis
This commit is contained in:
@@ -375,7 +375,7 @@ function AiPinnedCityCard({
|
|||||||
{isEn ? "YES" : "YES 买入"} <b>{marketDecisionView.priceText}</b>
|
{isEn ? "YES" : "YES 买入"} <b>{marketDecisionView.priceText}</b>
|
||||||
</small>
|
</small>
|
||||||
<small>
|
<small>
|
||||||
{isEn ? "Edge" : "概率差"} <b>{marketDecisionView.edgeText}</b>
|
{isEn ? "Model-market" : "模型-市场差"} <b>{marketDecisionView.edgeText}</b>
|
||||||
</small>
|
</small>
|
||||||
</div>
|
</div>
|
||||||
{marketDecisionView.marketUrl ? (
|
{marketDecisionView.marketUrl ? (
|
||||||
|
|||||||
@@ -1,5 +1,9 @@
|
|||||||
import type { MarketScan, MarketTopBucket } from "@/lib/dashboard-types";
|
import type { MarketScan, MarketTopBucket } from "@/lib/dashboard-types";
|
||||||
import { formatTemperatureValue, getTodayPaceView } from "@/lib/dashboard-utils";
|
import {
|
||||||
|
formatTemperatureValue,
|
||||||
|
getTodayPaceView,
|
||||||
|
normalizeTemperatureLabel,
|
||||||
|
} from "@/lib/dashboard-utils";
|
||||||
import type { AiCityForecastPayload } from "@/components/dashboard/scan-terminal/types";
|
import type { AiCityForecastPayload } from "@/components/dashboard/scan-terminal/types";
|
||||||
|
|
||||||
export type WeatherDecisionView = {
|
export type WeatherDecisionView = {
|
||||||
@@ -126,7 +130,12 @@ export function normalizeMarketComparableTemp(
|
|||||||
export function getMarketBucketLabel(bucket?: MarketTopBucket | null, tempSymbol = "°C") {
|
export function getMarketBucketLabel(bucket?: MarketTopBucket | null, tempSymbol = "°C") {
|
||||||
const direct = String(bucket?.label || "").trim();
|
const direct = String(bucket?.label || "").trim();
|
||||||
if (direct && /[°]?[CF]\b|\d+\s*[+-]?$/i.test(direct) && !/[�。紊]/.test(direct)) {
|
if (direct && /[°]?[CF]\b|\d+\s*[+-]?$/i.test(direct) && !/[�。紊]/.test(direct)) {
|
||||||
return direct.replace(/\bC\b/g, "°C").replace(/\bF\b/g, "°F");
|
const labelSymbol = /°?\s*F\b/i.test(direct)
|
||||||
|
? "°F"
|
||||||
|
: /°?\s*C\b/i.test(direct)
|
||||||
|
? "°C"
|
||||||
|
: tempSymbol;
|
||||||
|
return normalizeTemperatureLabel(direct, labelSymbol).replace(/\s+°([CF])\b/g, "°$1");
|
||||||
}
|
}
|
||||||
const numeric = toFiniteMarketNumber(bucket?.temp ?? bucket?.value ?? bucket?.lower);
|
const numeric = toFiniteMarketNumber(bucket?.temp ?? bucket?.value ?? bucket?.lower);
|
||||||
if (numeric != null) {
|
if (numeric != null) {
|
||||||
|
|||||||
@@ -22,6 +22,10 @@ const pendingAiCityForecastRequests = new Map<
|
|||||||
string,
|
string,
|
||||||
Promise<AiCityForecastPayload>
|
Promise<AiCityForecastPayload>
|
||||||
>();
|
>();
|
||||||
|
const aiCityForecastStateCache = new Map<
|
||||||
|
string,
|
||||||
|
{ state: AiCityForecastState; updatedAt: number }
|
||||||
|
>();
|
||||||
|
|
||||||
type AiCityStreamProgress = {
|
type AiCityStreamProgress = {
|
||||||
stage?: string | null;
|
stage?: string | null;
|
||||||
@@ -92,6 +96,24 @@ function removeCachedPayload(key: string) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
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(),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
function parseAiCityStreamBlock(block: string): AiCityStreamEvent | null {
|
function parseAiCityStreamBlock(block: string): AiCityStreamEvent | null {
|
||||||
const eventLines = block
|
const eventLines = block
|
||||||
.split(/\r?\n/)
|
.split(/\r?\n/)
|
||||||
@@ -400,32 +422,65 @@ export function useAiCityForecast({
|
|||||||
!cachedPayload.degraded &&
|
!cachedPayload.degraded &&
|
||||||
cachedPayload.city_forecast
|
cachedPayload.city_forecast
|
||||||
) {
|
) {
|
||||||
setAiForecast({ payload: cachedPayload, status: "ready" });
|
const readyState: AiCityForecastState = {
|
||||||
|
payload: cachedPayload,
|
||||||
|
status: "ready",
|
||||||
|
};
|
||||||
|
writeCachedAiForecastState(cacheKey, readyState);
|
||||||
|
setAiForecast(readyState);
|
||||||
return () => {
|
return () => {
|
||||||
cancelled = true;
|
cancelled = true;
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
removeCachedPayload(cacheKey);
|
removeCachedPayload(cacheKey);
|
||||||
}
|
}
|
||||||
|
const cachedState =
|
||||||
|
aiRefreshToken <= 0
|
||||||
|
? readCachedAiForecastState(cacheKey, AI_CITY_FORECAST_CACHE_TTL_MS)
|
||||||
|
: null;
|
||||||
|
if (cachedState?.status === "ready") {
|
||||||
|
setAiForecast(cachedState);
|
||||||
|
return () => {
|
||||||
|
cancelled = true;
|
||||||
|
};
|
||||||
|
}
|
||||||
const initialFallback = buildAiCityFallbackPayload({ detail, isEn, report });
|
const initialFallback = buildAiCityFallbackPayload({ detail, isEn, report });
|
||||||
setAiForecast({
|
const loadingState: AiCityForecastState =
|
||||||
status: "loading",
|
cachedState?.status === "loading"
|
||||||
streamText:
|
? cachedState
|
||||||
(isEn
|
: {
|
||||||
? initialFallback.city_forecast?.metar_read_en
|
status: "loading",
|
||||||
: initialFallback.city_forecast?.metar_read_zh) ||
|
streamText:
|
||||||
(isEn
|
(isEn
|
||||||
? "Reading the latest airport bulletin with model/METAR fallback ready..."
|
? initialFallback.city_forecast?.metar_read_en
|
||||||
: "已先用最新 METAR 给出兜底解读,正在等待 DeepSeek 补充…"),
|
: initialFallback.city_forecast?.metar_read_zh) ||
|
||||||
});
|
(isEn
|
||||||
|
? "Reading the latest airport bulletin with model/METAR fallback ready..."
|
||||||
|
: "已先用最新 METAR 给出兜底解读,正在等待 DeepSeek 补充…"),
|
||||||
|
};
|
||||||
|
writeCachedAiForecastState(cacheKey, loadingState);
|
||||||
|
setAiForecast(loadingState);
|
||||||
void requestAiCityForecast({
|
void requestAiCityForecast({
|
||||||
city: detailCityName,
|
city: detailCityName,
|
||||||
forceRefresh: aiRefreshToken > 0,
|
forceRefresh: aiRefreshToken > 0,
|
||||||
locale,
|
locale,
|
||||||
onProgress: (progress) => {
|
onProgress: (progress) => {
|
||||||
if (cancelled) return;
|
|
||||||
const progressText = getAiCityStreamProgressText(progress, isEn);
|
const progressText = getAiCityStreamProgressText(progress, isEn);
|
||||||
if (!progressText) return;
|
if (!progressText) return;
|
||||||
|
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;
|
||||||
setAiForecast((current) => ({
|
setAiForecast((current) => ({
|
||||||
...current,
|
...current,
|
||||||
status: "loading",
|
status: "loading",
|
||||||
@@ -451,18 +506,26 @@ export function useAiCityForecast({
|
|||||||
if (usablePayload.status === "ready" && !usablePayload.degraded) {
|
if (usablePayload.status === "ready" && !usablePayload.degraded) {
|
||||||
writeCachedPayload(cacheKey, usablePayload);
|
writeCachedPayload(cacheKey, usablePayload);
|
||||||
}
|
}
|
||||||
|
writeCachedAiForecastState(cacheKey, {
|
||||||
|
payload: usablePayload,
|
||||||
|
status: "ready",
|
||||||
|
});
|
||||||
if (!cancelled) {
|
if (!cancelled) {
|
||||||
setAiForecast({ payload: usablePayload, status: "ready" });
|
setAiForecast({ payload: usablePayload, status: "ready" });
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
.catch((error) => {
|
.catch((error) => {
|
||||||
|
const fallbackPayload = buildAiCityFallbackPayload({
|
||||||
|
detail,
|
||||||
|
error,
|
||||||
|
isEn,
|
||||||
|
report,
|
||||||
|
});
|
||||||
|
writeCachedAiForecastState(cacheKey, {
|
||||||
|
payload: fallbackPayload,
|
||||||
|
status: "ready",
|
||||||
|
});
|
||||||
if (!cancelled) {
|
if (!cancelled) {
|
||||||
const fallbackPayload = buildAiCityFallbackPayload({
|
|
||||||
detail,
|
|
||||||
error,
|
|
||||||
isEn,
|
|
||||||
report,
|
|
||||||
});
|
|
||||||
setAiForecast({ payload: fallbackPayload, status: "ready" });
|
setAiForecast({ payload: fallbackPayload, status: "ready" });
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|||||||
Reference in New Issue
Block a user