diff --git a/frontend/components/dashboard/Dashboard.module.css b/frontend/components/dashboard/Dashboard.module.css index 24023e4e..ededd61d 100644 --- a/frontend/components/dashboard/Dashboard.module.css +++ b/frontend/components/dashboard/Dashboard.module.css @@ -10399,6 +10399,192 @@ radial-gradient(circle at 0 0, rgba(34, 211, 238, 0.12), transparent 34%); } +.root :global(.scan-ai-analysis-view) { + display: flex; + flex-direction: column; + gap: 14px; + min-height: 520px; +} + +.root :global(.scan-ai-analysis-view.empty) { + min-height: 620px; +} + +.root :global(.scan-ai-summary-card) { + display: flex; + align-items: center; + justify-content: space-between; + gap: 16px; + padding: 16px 18px; + border: 1px solid rgba(34, 211, 238, 0.22); + border-radius: 18px; + background: + linear-gradient(135deg, rgba(8, 32, 52, 0.94), rgba(8, 21, 38, 0.94)), + radial-gradient(circle at 0 0, rgba(45, 212, 191, 0.15), transparent 32%); +} + +.root :global(.scan-ai-summary-card strong) { + color: #ecf8ff; + font-size: 16px; + font-weight: 900; +} + +.root :global(.scan-ai-summary-card p) { + margin: 6px 0 0; + color: #a6bbd7; + font-size: 13px; + font-weight: 700; + line-height: 1.55; +} + +.root :global(.scan-ai-inline-button) { + min-height: 42px; + display: inline-flex; + align-items: center; + gap: 8px; + padding: 0 16px; + border: 1px solid rgba(45, 212, 191, 0.3); + border-radius: 12px; + color: #d9fff6; + background: linear-gradient(135deg, rgba(13, 148, 136, 0.36), rgba(8, 47, 73, 0.4)); + font-size: 13px; + font-weight: 900; + cursor: pointer; +} + +.root :global(.scan-ai-inline-button:disabled) { + cursor: not-allowed; + opacity: 0.48; +} + +.root :global(.scan-ai-log-panel.in-tab) { + margin: 0; +} + +.root :global(.scan-ai-city-list) { + display: flex; + flex-direction: column; + gap: 12px; +} + +.root :global(.scan-ai-city-card) { + border: 1px solid rgba(68, 100, 150, 0.2); + border-radius: 18px; + overflow: hidden; + background: rgba(10, 24, 42, 0.82); +} + +.root :global(.scan-ai-city-head) { + display: flex; + align-items: flex-start; + justify-content: space-between; + gap: 14px; + padding: 16px 18px; + background: rgba(15, 34, 57, 0.9); +} + +.root :global(.scan-ai-city-head strong) { + color: #f2f8ff; + font-size: 17px; + font-weight: 900; +} + +.root :global(.scan-ai-city-head p) { + margin: 6px 0 0; + color: #9fb4d2; + font-size: 13px; + font-weight: 700; + line-height: 1.45; +} + +.root :global(.scan-ai-city-head span) { + flex: 0 0 auto; + padding: 5px 9px; + border-radius: 999px; + color: #68f3c8; + background: rgba(16, 185, 129, 0.12); + border: 1px solid rgba(16, 185, 129, 0.2); + font-size: 11px; + font-weight: 900; +} + +.root :global(.scan-ai-cluster-note) { + margin: 0 18px 14px; + padding: 10px 12px; + border: 1px solid rgba(34, 211, 238, 0.18); + border-radius: 12px; + color: #b7cbe6; + background: rgba(6, 18, 32, 0.5); + font-size: 12px; + font-weight: 800; + line-height: 1.45; +} + +.root :global(.scan-ai-contracts) { + display: flex; + flex-direction: column; + padding: 0 12px 12px; +} + +.root :global(.scan-ai-contract) { + display: grid; + grid-template-columns: minmax(220px, 1fr) auto; + gap: 10px 16px; + align-items: center; + padding: 12px; + border-top: 1px solid rgba(68, 100, 150, 0.16); +} + +.root :global(.scan-ai-contract b) { + display: block; + color: #edf7ff; + font-size: 13px; + font-weight: 900; +} + +.root :global(.scan-ai-contract small) { + display: block; + margin-top: 3px; + color: #8fa8c7; + font-size: 11px; + font-weight: 800; +} + +.root :global(.scan-ai-contract > span) { + justify-self: end; + padding: 5px 9px; + border-radius: 999px; + color: #a8bedc; + background: rgba(68, 100, 150, 0.16); + font-size: 11px; + font-weight: 900; +} + +.root :global(.scan-ai-contract.approve > span) { + color: #23f0a8; + background: rgba(16, 185, 129, 0.14); +} + +.root :global(.scan-ai-contract.veto > span) { + color: #ff8a8a; + background: rgba(248, 113, 113, 0.12); +} + +.root :global(.scan-ai-contract.downgrade > span), +.root :global(.scan-ai-contract.watchlist > span) { + color: #ffd166; + background: rgba(245, 158, 11, 0.12); +} + +.root :global(.scan-ai-contract p) { + grid-column: 1 / -1; + margin: 0; + color: #9fb4d2; + font-size: 12px; + font-weight: 700; + line-height: 1.45; +} + .root :global(.scan-ai-log-head) { display: flex; align-items: center; @@ -11144,6 +11330,45 @@ border-color: rgba(220, 38, 38, 0.28); } +.root :global(.scan-terminal.light .scan-ai-summary-card), +.root :global(.scan-terminal.light .scan-ai-city-card) { + background: rgba(255, 255, 255, 0.92); + border-color: rgba(148, 163, 184, 0.28); +} + +.root :global(.scan-terminal.light .scan-ai-summary-card strong), +.root :global(.scan-terminal.light .scan-ai-city-head strong), +.root :global(.scan-terminal.light .scan-ai-contract b) { + color: #0f172a; +} + +.root :global(.scan-terminal.light .scan-ai-summary-card p), +.root :global(.scan-terminal.light .scan-ai-city-head p), +.root :global(.scan-terminal.light .scan-ai-contract p), +.root :global(.scan-terminal.light .scan-ai-contract small) { + color: #475569; +} + +.root :global(.scan-terminal.light .scan-ai-city-head) { + background: #f8fafc; +} + +.root :global(.scan-terminal.light .scan-ai-cluster-note) { + color: #334155; + background: #f8fafc; + border-color: rgba(148, 163, 184, 0.26); +} + +.root :global(.scan-terminal.light .scan-ai-contract) { + border-top-color: rgba(148, 163, 184, 0.2); +} + +.root :global(.scan-terminal.light .scan-ai-inline-button) { + color: #064e3b; + background: #d1fae5; + border-color: rgba(16, 185, 129, 0.28); +} + .root :global(.scan-terminal.light .scan-cta-ghost), .root :global(.scan-terminal.light .scan-detail-analysis-button) { border-color: rgba(7, 160, 100, 0.24); diff --git a/frontend/components/dashboard/OpportunityTable.tsx b/frontend/components/dashboard/OpportunityTable.tsx index 084630ab..784d0ee4 100644 --- a/frontend/components/dashboard/OpportunityTable.tsx +++ b/frontend/components/dashboard/OpportunityTable.tsx @@ -156,6 +156,16 @@ function getAiMeta(row: ScanOpportunityRow, locale: string) { null, }; } + if (decision === "watchlist") { + return { + label: locale === "en-US" ? "AI watch" : "AI 观察", + tone: "downgrade", + reason: + locale === "en-US" + ? row.ai_watchlist_reason_en || row.ai_watchlist_reason_zh + : row.ai_watchlist_reason_zh || row.ai_watchlist_reason_en, + }; + } return null; } diff --git a/frontend/components/dashboard/ScanTerminalDashboard.tsx b/frontend/components/dashboard/ScanTerminalDashboard.tsx index f25854e5..368666b6 100644 --- a/frontend/components/dashboard/ScanTerminalDashboard.tsx +++ b/frontend/components/dashboard/ScanTerminalDashboard.tsx @@ -58,7 +58,7 @@ const SCAN_AUTO_REFRESH_MS = 5 * 60 * 1000; interface FilterState extends ScanTerminalFilters {} -type ContentView = "list" | "map" | "calendar"; +type ContentView = "list" | "map" | "ai" | "calendar"; type ThemeMode = "dark" | "light"; type ScanAiLogTone = "info" | "success" | "warning" | "error"; @@ -166,6 +166,209 @@ function findRowForCity(rows: ScanOpportunityRow[], cityName?: string | null) { return rows.find((row) => rowMatchesCity(row, cityName || "")) || null; } +function getRowAiReason(row: ScanOpportunityRow, locale: string) { + const isEn = locale === "en-US"; + return ( + (isEn ? row.ai_reason_en || row.ai_reason_zh : row.ai_reason_zh || row.ai_reason_en) || + row.ai_model_cluster_note || + (isEn + ? row.ai_watchlist_reason_en || row.ai_watchlist_reason_zh + : row.ai_watchlist_reason_zh || row.ai_watchlist_reason_en) || + null + ); +} + +function getAiDecisionLabel(row: ScanOpportunityRow, locale: string) { + const decision = String(row.ai_decision || "neutral").toLowerCase(); + if (decision === "veto") return locale === "en-US" ? "Excluded" : "排除"; + if (decision === "downgrade") return locale === "en-US" ? "Downgraded" : "降级"; + if (decision === "watchlist") return locale === "en-US" ? "Watch" : "观察"; + if (row.ai_rank != null || decision === "approve") { + return locale === "en-US" ? `Pick ${row.ai_rank || ""}`.trim() : `推荐 ${row.ai_rank || ""}`.trim(); + } + return locale === "en-US" ? "Unreviewed" : "未复核"; +} + +function ScanAiAnalysisView({ + response, + rows, + logs, + loading, + error, + locale, + onRunAi, +}: { + response: ScanTerminalResponse | null; + rows: ScanOpportunityRow[]; + logs: ScanAiLogEntry[]; + loading: boolean; + error?: string | null; + locale: string; + onRunAi: () => void; +}) { + const isEn = locale === "en-US"; + const aiScan = response?.ai_scan || null; + const theses = aiScan?.city_theses || []; + const thesisByCity = useMemo( + () => new Map(theses.map((item) => [normalizeCityKey(item.city), item])), + [theses], + ); + const groups = useMemo(() => { + const map = new Map(); + rows.forEach((row) => { + const key = normalizeCityKey(row.city || row.city_display_name || row.display_name); + if (!key) return; + const current = map.get(key) || []; + current.push(row); + map.set(key, current); + }); + return Array.from(map.entries()).map(([key, items]) => ({ + key, + rows: items, + city: + items[0]?.city_display_name || + items[0]?.display_name || + items[0]?.city || + key, + thesis: thesisByCity.get(key), + })); + }, [rows, thesisByCity]); + + if (!rows.length) { + return ( +
+
+
+ {isEn ? "Run a rule scan first" : "先完成规则扫描"} +
+
+ {isEn + ? "V4 needs the current city candidates before it can analyze them." + : "V4 需要先拿到当前城市候选,才能做城市级研判。"} +
+
+
+ ); + } + + return ( +
+
+
+ {isEn ? "V4 city analysis" : "V4 城市级分析"} +

+ {aiScan?.status === "ready" + ? (isEn ? aiScan.summary_en || aiScan.summary_zh : aiScan.summary_zh || aiScan.summary_en) || + (isEn ? "V4 has reviewed the grouped city snapshot." : "V4 已复核城市分组快照。") + : error || + aiScan?.reason || + (isEn + ? "Click AI Deep Scan after a rule scan to generate city theses and contract decisions." + : "规则扫描后点击 AI 深度扫描,生成每城 thesis 和合约推荐/排除理由。")} +

+
+ +
+ +
+
+ {isEn ? "Run log" : "运行日志"} + + {aiScan?.status === "ready" + ? isEn + ? `${aiScan.sent_cities ?? "--"} cities · ${aiScan.sent_contracts ?? aiScan.sent_rows ?? "--"} contracts` + : `${aiScan.sent_cities ?? "--"} 城 · ${aiScan.sent_contracts ?? aiScan.sent_rows ?? "--"} 合约` + : isEn + ? "No V4 result yet" + : "暂无 V4 结果"} + +
+
+ {logs.length ? ( + logs.map((entry) => ( +
+ {entry.time} +
+ {entry.title} + {entry.detail ? {entry.detail} : null} +
+
+ )) + ) : ( +
+ {isEn ? "No request has been sent." : "还没有发起请求。"} +
+ )} +
+
+ +
+ {groups.map((group) => { + const cityThesis = + (isEn + ? group.thesis?.thesis_en || group.thesis?.summary_en || group.rows[0]?.ai_city_thesis_en + : group.thesis?.thesis_zh || group.thesis?.summary_zh || group.rows[0]?.ai_city_thesis_zh) || + (isEn ? "Waiting for V4 city thesis." : "等待 V4 城市研判。"); + const clusterNote = + group.thesis?.model_cluster_note || group.rows[0]?.ai_city_model_cluster_note || null; + return ( +
+
+
+ {group.city} +

{cityThesis}

+
+ {group.thesis?.confidence || group.rows[0]?.ai_city_confidence ? ( + {group.thesis?.confidence || group.rows[0]?.ai_city_confidence} + ) : null} +
+ {clusterNote ? ( +
{clusterNote}
+ ) : null} +
+ {group.rows.map((row) => { + const decision = String(row.ai_decision || "neutral").toLowerCase(); + const reason = getRowAiReason(row, locale); + const tempSymbol = row.temp_symbol || row.target_unit || "°C"; + return ( +
+
+ {row.action || row.target_label || row.market_question || row.id} + + {row.deb_prediction != null + ? `DEB ${formatTemperatureValue(row.deb_prediction, tempSymbol)} · ` + : ""} + {row.edge_percent != null ? `edge ${Number(row.edge_percent).toFixed(1)}%` : "--"} + +
+ {getAiDecisionLabel(row, locale)} + {reason ?

{reason}

: null} +
+ ); + })} +
+
+ ); + })} +
+
+ ); +} + function CalendarView({ rows, locale, @@ -441,19 +644,20 @@ function ScanTerminalScreen() { tone: "info", title: isEn ? "V4 review queued" : "V4 复核已排队", detail: isEn - ? `snapshot ${snapshotId || "--"} · ${rowCount} rule candidates` - : `快照 ${snapshotId || "--"} · 规则候选 ${rowCount} 条`, + ? `snapshot ${snapshotId || "--"} · ${rowCount} rule candidates grouped by city` + : `快照 ${snapshotId || "--"} · ${rowCount} 条候选将按城市分组`, }, { id: `send-${Date.now() + 1}`, time: formatLogTime(), tone: "info", - title: isEn ? "Sending compact candidate set" : "正在发送精简候选集", + title: isEn ? "Sending city-level context" : "正在发送城市级上下文", detail: isEn - ? "VPS will call DeepSeek; Vercel only proxies the request." - : "由 VPS 调用 DeepSeek,Vercel 只做短代理。", + ? "Each city includes EMOS distribution, model cluster and contracts." + : "每城包含 EMOS 分布、模型集群和候选合约。", }, ]); + setActiveView("ai"); setAiLoading(true); setAiError(null); try { @@ -493,8 +697,8 @@ function ScanTerminalScreen() { detail: review?.status === "ready" ? isEn - ? `${review.model || "V4"} · ${formatDuration(review.duration_ms)} · sent ${review.sent_rows ?? "--"} rows · ${review.recommended_count ?? 0} picks / ${review.vetoed_count ?? 0} veto` - : `${review.model || "V4"} · ${formatDuration(review.duration_ms)} · 发送 ${review.sent_rows ?? "--"} 条 · 推荐 ${review.recommended_count ?? 0} / 排除 ${review.vetoed_count ?? 0}` + ? `${review.model || "V4"} · ${formatDuration(review.duration_ms)} · ${review.sent_cities ?? "--"} cities / ${review.sent_contracts ?? review.sent_rows ?? "--"} contracts · ${review.recommended_count ?? 0} picks / ${review.vetoed_count ?? 0} veto` + : `${review.model || "V4"} · ${formatDuration(review.duration_ms)} · ${review.sent_cities ?? "--"} 城 / ${review.sent_contracts ?? review.sent_rows ?? "--"} 合约 · 推荐 ${review.recommended_count ?? 0} / 排除 ${review.vetoed_count ?? 0}` : review?.reason || (isEn ? "No AI result returned." : "没有返回 AI 结果。"), }, ...(review?.usage @@ -608,7 +812,8 @@ function ScanTerminalScreen() { setMapSelectedCityName(cityName); const matchedRow = findRowForCity(timeSortedRows, cityName); setSelectedRowId(matchedRow?.id || null); - }, [timeSortedRows]); + void store.selectCity(cityName); + }, [store, timeSortedRows]); const handleSelectRow = useCallback((row: ScanOpportunityRow) => { setSelectedRowId(row.id); @@ -658,6 +863,19 @@ function ScanTerminalScreen() { /> ); } + if (resolvedView === "ai") { + return ( + void runAiReview()} + /> + ); + } return ( <> {isEn ? "Opportunity List" : "机会列表"} +