feat: implement AI city forecast and market scan integration for terminal dashboard
This commit is contained in:
@@ -24,6 +24,9 @@ POLYWEATHER_ALERT_RELAY_PORT=9099
|
|||||||
POLYWEATHER_GRAFANA_PORT=3001
|
POLYWEATHER_GRAFANA_PORT=3001
|
||||||
POLYWEATHER_GRAFANA_ADMIN_USER=admin
|
POLYWEATHER_GRAFANA_ADMIN_USER=admin
|
||||||
POLYWEATHER_GRAFANA_ADMIN_PASSWORD=polyweather
|
POLYWEATHER_GRAFANA_ADMIN_PASSWORD=polyweather
|
||||||
|
# Backend CORS allowlist. Add your Vercel production/preview domains when
|
||||||
|
# NEXT_PUBLIC_POLYWEATHER_API_BASE_URL points browsers directly at this backend.
|
||||||
|
WEB_CORS_ORIGINS=http://localhost:3000,http://127.0.0.1:3000,https://polyweather-pro.vercel.app
|
||||||
|
|
||||||
########################################
|
########################################
|
||||||
# 2) Telegram bot minimal
|
# 2) Telegram bot minimal
|
||||||
@@ -116,6 +119,11 @@ NEXT_PUBLIC_WALLETCONNECT_PROJECT_ID=
|
|||||||
NEXT_PUBLIC_WALLETCONNECT_POLYGON_RPC_URL=https://polygon-bor-rpc.publicnode.com
|
NEXT_PUBLIC_WALLETCONNECT_POLYGON_RPC_URL=https://polygon-bor-rpc.publicnode.com
|
||||||
# Optional: disable homepage city summary preloading. Default is enabled.
|
# Optional: disable homepage city summary preloading. Default is enabled.
|
||||||
NEXT_PUBLIC_POLYWEATHER_DISABLE_EAGER_SUMMARIES=false
|
NEXT_PUBLIC_POLYWEATHER_DISABLE_EAGER_SUMMARIES=false
|
||||||
|
# Optional: browser-visible FastAPI base URL for Vercel deployments.
|
||||||
|
# Set this to your VPS HTTPS origin to let AI / METAR / scan dashboard calls
|
||||||
|
# bypass Vercel Functions / Fluid Compute instead of going through Next.js API proxies.
|
||||||
|
# Example: NEXT_PUBLIC_POLYWEATHER_API_BASE_URL=https://api.example.com
|
||||||
|
NEXT_PUBLIC_POLYWEATHER_API_BASE_URL=
|
||||||
|
|
||||||
########################################
|
########################################
|
||||||
# 7) Optional modules
|
# 7) Optional modules
|
||||||
|
|||||||
@@ -2,8 +2,15 @@
|
|||||||
# 只部署天气看板时,先填下面 4 项即可。
|
# 只部署天气看板时,先填下面 4 项即可。
|
||||||
|
|
||||||
# 必填:后端 FastAPI 基础地址
|
# 必填:后端 FastAPI 基础地址
|
||||||
|
# 默认供 Next.js API Route 在服务端代理后端使用。
|
||||||
POLYWEATHER_API_BASE_URL=http://127.0.0.1:8000
|
POLYWEATHER_API_BASE_URL=http://127.0.0.1:8000
|
||||||
|
|
||||||
|
# 可选:浏览器直连后端 FastAPI 基础地址。
|
||||||
|
# 在 Vercel 免费额度下建议配置为 VPS HTTPS 域名,让 AI / METAR / scan 等
|
||||||
|
# 长耗时请求绕过 Vercel Functions / Fluid Compute。
|
||||||
|
# 例如:https://api.example.com
|
||||||
|
NEXT_PUBLIC_POLYWEATHER_API_BASE_URL=
|
||||||
|
|
||||||
# 必填:Supabase 前端公钥(鉴权开启时必须)
|
# 必填:Supabase 前端公钥(鉴权开启时必须)
|
||||||
NEXT_PUBLIC_SUPABASE_URL=
|
NEXT_PUBLIC_SUPABASE_URL=
|
||||||
NEXT_PUBLIC_SUPABASE_ANON_KEY=
|
NEXT_PUBLIC_SUPABASE_ANON_KEY=
|
||||||
|
|||||||
@@ -11,12 +11,12 @@ const AI_CITY_GATEWAY_TIMEOUT_MS = Math.max(
|
|||||||
process.env.POLYWEATHER_SCAN_AI_GATEWAY_TIMEOUT_MS ||
|
process.env.POLYWEATHER_SCAN_AI_GATEWAY_TIMEOUT_MS ||
|
||||||
process.env.POLYWEATHER_AI_CITY_GATEWAY_TIMEOUT_MS ||
|
process.env.POLYWEATHER_AI_CITY_GATEWAY_TIMEOUT_MS ||
|
||||||
process.env.POLYWEATHER_SCAN_AI_PROXY_TIMEOUT_MS ||
|
process.env.POLYWEATHER_SCAN_AI_PROXY_TIMEOUT_MS ||
|
||||||
"120000",
|
"55000",
|
||||||
) || 120_000,
|
) || 55_000,
|
||||||
);
|
);
|
||||||
|
|
||||||
export const dynamic = "force-dynamic";
|
export const dynamic = "force-dynamic";
|
||||||
export const maxDuration = 130;
|
export const maxDuration = 60;
|
||||||
|
|
||||||
export async function POST(req: NextRequest) {
|
export async function POST(req: NextRequest) {
|
||||||
if (!API_BASE) {
|
if (!API_BASE) {
|
||||||
|
|||||||
@@ -32,6 +32,10 @@ import type {
|
|||||||
ScanOpportunityRow,
|
ScanOpportunityRow,
|
||||||
ScanTerminalResponse,
|
ScanTerminalResponse,
|
||||||
} from "@/lib/dashboard-types";
|
} from "@/lib/dashboard-types";
|
||||||
|
import {
|
||||||
|
buildBrowserBackendHeaders,
|
||||||
|
resolveBackendApiUrl,
|
||||||
|
} from "@/lib/backend-api";
|
||||||
import { getLocalizedCityName } from "@/lib/dashboard-home-copy";
|
import { getLocalizedCityName } from "@/lib/dashboard-home-copy";
|
||||||
import { AiPinnedForecastView } from "@/components/dashboard/scan-terminal/AiPinnedForecastView";
|
import { AiPinnedForecastView } from "@/components/dashboard/scan-terminal/AiPinnedForecastView";
|
||||||
import { CalendarView } from "@/components/dashboard/scan-terminal/CalendarView";
|
import { CalendarView } from "@/components/dashboard/scan-terminal/CalendarView";
|
||||||
@@ -240,11 +244,19 @@ function ScanTerminalScreen() {
|
|||||||
time_range: "today",
|
time_range: "today",
|
||||||
limit: "36",
|
limit: "36",
|
||||||
});
|
});
|
||||||
fetch(`/api/scan/terminal?${params.toString()}`, {
|
void buildBrowserBackendHeaders({
|
||||||
cache: "no-store",
|
Accept: "application/json",
|
||||||
signal: controller.signal,
|
|
||||||
})
|
})
|
||||||
|
.then((headers) => {
|
||||||
|
if (cancelled) return null;
|
||||||
|
return fetch(resolveBackendApiUrl(`/api/scan/terminal?${params.toString()}`), {
|
||||||
|
cache: "no-store",
|
||||||
|
headers,
|
||||||
|
signal: controller.signal,
|
||||||
|
});
|
||||||
|
})
|
||||||
.then(async (response) => {
|
.then(async (response) => {
|
||||||
|
if (!response) return null;
|
||||||
if (!response.ok) {
|
if (!response.ok) {
|
||||||
let message = `HTTP ${response.status}`;
|
let message = `HTTP ${response.status}`;
|
||||||
try {
|
try {
|
||||||
@@ -258,6 +270,7 @@ function ScanTerminalScreen() {
|
|||||||
return response.json() as Promise<ScanTerminalResponse>;
|
return response.json() as Promise<ScanTerminalResponse>;
|
||||||
})
|
})
|
||||||
.then((payload) => {
|
.then((payload) => {
|
||||||
|
if (!payload) return;
|
||||||
if (cancelled) return;
|
if (cancelled) return;
|
||||||
setTerminalData(payload);
|
setTerminalData(payload);
|
||||||
setScanError(null);
|
setScanError(null);
|
||||||
|
|||||||
@@ -6,6 +6,10 @@ import type {
|
|||||||
AiCityForecastState,
|
AiCityForecastState,
|
||||||
} from "@/components/dashboard/scan-terminal/types";
|
} from "@/components/dashboard/scan-terminal/types";
|
||||||
import { useDashboardStore } from "@/hooks/useDashboardStore";
|
import { useDashboardStore } from "@/hooks/useDashboardStore";
|
||||||
|
import {
|
||||||
|
buildBrowserBackendHeaders,
|
||||||
|
resolveBackendApiUrl,
|
||||||
|
} from "@/lib/backend-api";
|
||||||
import type { CityDetail, MarketScan } from "@/lib/dashboard-types";
|
import type { CityDetail, MarketScan } from "@/lib/dashboard-types";
|
||||||
import { normalizeCityKey } from "./decision-utils";
|
import { normalizeCityKey } from "./decision-utils";
|
||||||
|
|
||||||
@@ -182,21 +186,26 @@ export function useAiCityForecast({
|
|||||||
? "DeepSeek V4-Pro is reading the latest airport bulletin..."
|
? "DeepSeek V4-Pro is reading the latest airport bulletin..."
|
||||||
: "DeepSeek V4-Pro 正在解读最新机场报文...",
|
: "DeepSeek V4-Pro 正在解读最新机场报文...",
|
||||||
});
|
});
|
||||||
fetch("/api/scan/terminal/ai-city", {
|
void buildBrowserBackendHeaders({
|
||||||
method: "POST",
|
|
||||||
headers: {
|
|
||||||
Accept: "application/json",
|
Accept: "application/json",
|
||||||
"Content-Type": "application/json",
|
"Content-Type": "application/json",
|
||||||
},
|
})
|
||||||
cache: "no-store",
|
.then((headers) => {
|
||||||
signal: controller.signal,
|
if (cancelled) return null;
|
||||||
body: JSON.stringify({
|
return fetch(resolveBackendApiUrl("/api/scan/terminal/ai-city"), {
|
||||||
city: detailCityName,
|
method: "POST",
|
||||||
force_refresh: aiRefreshToken > 0,
|
headers,
|
||||||
locale,
|
cache: "no-store",
|
||||||
}),
|
signal: controller.signal,
|
||||||
})
|
body: JSON.stringify({
|
||||||
|
city: detailCityName,
|
||||||
|
force_refresh: aiRefreshToken > 0,
|
||||||
|
locale,
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
})
|
||||||
.then(async (response) => {
|
.then(async (response) => {
|
||||||
|
if (!response) return null;
|
||||||
if (!response.ok) {
|
if (!response.ok) {
|
||||||
let detailMessage = "";
|
let detailMessage = "";
|
||||||
try {
|
try {
|
||||||
@@ -226,6 +235,7 @@ export function useAiCityForecast({
|
|||||||
return response.json() as Promise<AiCityForecastPayload>;
|
return response.json() as Promise<AiCityForecastPayload>;
|
||||||
})
|
})
|
||||||
.then((payload) => {
|
.then((payload) => {
|
||||||
|
if (!payload) return;
|
||||||
if (!cancelled) {
|
if (!cancelled) {
|
||||||
const usablePayload =
|
const usablePayload =
|
||||||
payload?.city_forecast
|
payload?.city_forecast
|
||||||
|
|||||||
@@ -0,0 +1,45 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import {
|
||||||
|
getSupabaseBrowserClient,
|
||||||
|
hasSupabasePublicEnv,
|
||||||
|
} from "@/lib/supabase/client";
|
||||||
|
|
||||||
|
const PUBLIC_BACKEND_API_BASE_URL =
|
||||||
|
process.env.NEXT_PUBLIC_POLYWEATHER_API_BASE_URL?.trim() || "";
|
||||||
|
|
||||||
|
export function hasDirectBackendApiBaseUrl() {
|
||||||
|
return Boolean(PUBLIC_BACKEND_API_BASE_URL);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function resolveBackendApiUrl(path: string) {
|
||||||
|
if (!PUBLIC_BACKEND_API_BASE_URL || /^https?:\/\//i.test(path)) {
|
||||||
|
return path;
|
||||||
|
}
|
||||||
|
const base = PUBLIC_BACKEND_API_BASE_URL.replace(/\/+$/, "");
|
||||||
|
const suffix = path.startsWith("/") ? path : `/${path}`;
|
||||||
|
return `${base}${suffix}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function buildBrowserBackendHeaders(init?: HeadersInit) {
|
||||||
|
const headers = new Headers(init);
|
||||||
|
if (!headers.has("Accept")) {
|
||||||
|
headers.set("Accept", "application/json");
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!headers.has("Authorization") && hasSupabasePublicEnv()) {
|
||||||
|
try {
|
||||||
|
const {
|
||||||
|
data: { session },
|
||||||
|
} = await getSupabaseBrowserClient().auth.getSession();
|
||||||
|
const accessToken = session?.access_token || "";
|
||||||
|
if (accessToken) {
|
||||||
|
headers.set("Authorization", `Bearer ${accessToken}`);
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
// Direct backend mode must also work for public/optional-auth dashboards.
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return headers;
|
||||||
|
}
|
||||||
@@ -9,6 +9,10 @@ import {
|
|||||||
ScanTerminalFilters,
|
ScanTerminalFilters,
|
||||||
ScanTerminalResponse,
|
ScanTerminalResponse,
|
||||||
} from "@/lib/dashboard-types";
|
} from "@/lib/dashboard-types";
|
||||||
|
import {
|
||||||
|
buildBrowserBackendHeaders,
|
||||||
|
resolveBackendApiUrl,
|
||||||
|
} from "@/lib/backend-api";
|
||||||
|
|
||||||
export type AssistantOpportunityContext = {
|
export type AssistantOpportunityContext = {
|
||||||
city_name: string;
|
city_name: string;
|
||||||
@@ -108,11 +112,15 @@ async function fetchJson<T>(url: string, options?: { timeoutMs?: number }): Prom
|
|||||||
const timeoutId = controller
|
const timeoutId = controller
|
||||||
? window.setTimeout(() => controller.abort(), timeoutMs)
|
? window.setTimeout(() => controller.abort(), timeoutMs)
|
||||||
: null;
|
: null;
|
||||||
|
const requestUrl = resolveBackendApiUrl(url);
|
||||||
|
const headers = await buildBrowserBackendHeaders({
|
||||||
|
Accept: "application/json",
|
||||||
|
});
|
||||||
|
|
||||||
let response: Response;
|
let response: Response;
|
||||||
try {
|
try {
|
||||||
response = await fetch(url, {
|
response = await fetch(requestUrl, {
|
||||||
headers: { Accept: "application/json" },
|
headers,
|
||||||
cache: "no-store",
|
cache: "no-store",
|
||||||
signal: controller?.signal,
|
signal: controller?.signal,
|
||||||
});
|
});
|
||||||
@@ -457,18 +465,18 @@ export const dashboardClient = {
|
|||||||
if (existing) {
|
if (existing) {
|
||||||
return existing;
|
return existing;
|
||||||
}
|
}
|
||||||
const request = fetch("/api/scan/terminal/ai", {
|
const request = buildBrowserBackendHeaders({
|
||||||
|
Accept: "application/json",
|
||||||
|
"Content-Type": "application/json",
|
||||||
|
}).then((headers) => fetch(resolveBackendApiUrl("/api/scan/terminal/ai"), {
|
||||||
method: "POST",
|
method: "POST",
|
||||||
headers: {
|
headers,
|
||||||
Accept: "application/json",
|
|
||||||
"Content-Type": "application/json",
|
|
||||||
},
|
|
||||||
cache: "no-store",
|
cache: "no-store",
|
||||||
body: JSON.stringify({
|
body: JSON.stringify({
|
||||||
filters: payload.filters,
|
filters: payload.filters,
|
||||||
snapshot_id: snapshotId || null,
|
snapshot_id: snapshotId || null,
|
||||||
}),
|
}),
|
||||||
})
|
}))
|
||||||
.then(async (response) => {
|
.then(async (response) => {
|
||||||
if (!response.ok) {
|
if (!response.ok) {
|
||||||
throw new Error(`HTTP ${response.status}`);
|
throw new Error(`HTTP ${response.status}`);
|
||||||
|
|||||||
Reference in New Issue
Block a user