TradingAgents/frontend/app/history/page.tsx

556 lines
22 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

/**
* History page - browse saved analysis reports
*/
"use client";
import { useState, useEffect } from "react";
import { useRouter } from "next/navigation";
import { format } from "date-fns";
import { zhTW } from "date-fns/locale";
import { useAnalysisContext } from "@/context/AnalysisContext";
import { useAuth } from "@/contexts/auth-context";
import { Button } from "@/components/ui/button";
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
import {
Card,
CardContent,
CardHeader,
CardTitle,
CardDescription,
CardFooter,
} from "@/components/ui/card";
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
} from "@/components/ui/dialog";
import { Trash2, Eye, RefreshCw, TrendingUp, Cloud, CloudOff, FileText, Download } from "lucide-react";
import {
getReportsByMarketType,
deleteReport,
getReportCountByMarketType,
type SavedReport,
} from "@/lib/reports-db";
import { getCloudReports, deleteCloudReport, isCloudSyncEnabled } from "@/lib/user-api";
import { LoginPrompt } from "@/components/auth/login-button";
import { PendingTaskRecovery } from "@/components/PendingTaskRecovery";
// Analyst definitions for download
const ANALYSTS = [
{ key: "market", label: "市場分析師", reportKey: "market_report", description: "技術分析與市場趨勢評估" },
{ key: "social", label: "社群媒體分析師", reportKey: "sentiment_report", description: "社群情緒與市場氛圍分析" },
{ key: "news", label: "新聞分析師", reportKey: "news_report", description: "新聞事件與影響分析" },
{ key: "fundamentals", label: "基本面分析師", reportKey: "fundamentals_report", description: "財務數據與基本面分析" },
{ key: "bull", label: "看漲研究員", reportKey: "investment_debate_state.bull_history", description: "看漲觀點與投資論據" },
{ key: "bear", label: "看跌研究員", reportKey: "investment_debate_state.bear_history", description: "看跌觀點與風險警告" },
{ key: "research_manager", label: "研究經理", reportKey: "investment_debate_state.judge_decision", description: "研究團隊綜合決策" },
{ key: "trader", label: "交易員", reportKey: "trader_investment_plan", description: "交易執行計劃與策略" },
{ key: "risky", label: "激進分析師", reportKey: "risk_debate_state.risky_history", description: "高風險高回報策略分析" },
{ key: "safe", label: "保守分析師", reportKey: "risk_debate_state.safe_history", description: "穩健保守策略分析" },
{ key: "neutral", label: "中立分析師", reportKey: "risk_debate_state.neutral_history", description: "中立平衡策略分析" },
{ key: "risk_manager", label: "風險經理", reportKey: "risk_debate_state.judge_decision", description: "風險管理綜合決策" },
];
// Market type labels
const MARKET_LABELS = {
us: { label: "🇺🇸 美股", description: "美國股市分析報告" },
twse: { label: "🇹🇼 上市", description: "台灣證券交易所上市股票" },
tpex: { label: "🇹🇼 上櫃/興櫃", description: "台灣櫃買中心上櫃/興櫃股票" },
};
// Helper function to extract decision from Risk Manager's final decision
const extractDecisionFromReport = (report: SavedReport): { action: string; color: string } => {
// First try the decision.action field (processed decision)
if (report.result.decision?.action) {
const action = report.result.decision.action;
const actionLower = action.toLowerCase();
const color = actionLower.includes("buy")
? "text-green-600"
: actionLower.includes("sell")
? "text-red-600"
: "text-yellow-600";
return { action, color };
}
// Priority: try to extract from Risk Manager's final_trade_decision (最終決策)
const finalDecision = report.result.reports?.final_trade_decision;
if (finalDecision) {
const text = finalDecision.toLowerCase();
// Look for Chinese decision keywords first
if (text.includes("最終決策:買入") || text.includes("最終建議:買入") || text.includes("建議:買入")) {
return { action: "買入", color: "text-green-600" };
}
if (text.includes("最終決策:賣出") || text.includes("最終建議:賣出") || text.includes("建議:賣出")) {
return { action: "賣出", color: "text-red-600" };
}
if (text.includes("最終決策:持有") || text.includes("最終建議:持有") || text.includes("建議:持有")) {
return { action: "持有", color: "text-yellow-600" };
}
// Look for English keywords
if (text.includes("buy") || text.includes("買入")) {
return { action: "買入", color: "text-green-600" };
}
if (text.includes("sell") || text.includes("賣出")) {
return { action: "賣出", color: "text-red-600" };
}
if (text.includes("hold") || text.includes("持有")) {
return { action: "持有", color: "text-yellow-600" };
}
}
// Fallback: try trader_investment_plan if final_trade_decision not available
const traderReport = report.result.reports?.trader_investment_plan;
if (traderReport) {
const text = traderReport.toLowerCase();
// Look for Chinese decision keywords first
if (text.includes("最終交易提案:買入") || text.includes("建議:買入")) {
return { action: "買入", color: "text-green-600" };
}
if (text.includes("最終交易提案:賣出") || text.includes("建議:賣出")) {
return { action: "賣出", color: "text-red-600" };
}
if (text.includes("最終交易提案:持有") || text.includes("建議:持有")) {
return { action: "持有", color: "text-yellow-600" };
}
// Look for English keywords
if (text.includes("buy") && !text.includes("sell")) {
return { action: "買入 (BUY)", color: "text-green-600" };
}
if (text.includes("sell") && !text.includes("buy")) {
return { action: "賣出 (SELL)", color: "text-red-600" };
}
if (text.includes("hold")) {
return { action: "持有 (HOLD)", color: "text-yellow-600" };
}
}
return { action: "N/A", color: "text-gray-500" };
};
export default function HistoryPage() {
const router = useRouter();
const { setAnalysisResult, setTaskId, setMarketType } = useAnalysisContext();
const { isAuthenticated } = useAuth();
const [activeTab, setActiveTab] = useState<"us" | "twse" | "tpex">("us");
const [reports, setReports] = useState<SavedReport[]>([]);
const [loading, setLoading] = useState(true);
const [counts, setCounts] = useState({ us: 0, twse: 0, tpex: 0 });
const [isCloudData, setIsCloudData] = useState(false);
// Delete confirmation dialog
const [deleteDialogOpen, setDeleteDialogOpen] = useState(false);
const [reportToDelete, setReportToDelete] = useState<SavedReport | null>(
null
);
const [deleting, setDeleting] = useState(false);
// Load reports when tab changes or auth state changes
useEffect(() => {
loadReports();
}, [activeTab, isAuthenticated]);
// Load counts on mount or auth change
useEffect(() => {
loadCounts();
}, [isAuthenticated]);
const loadReports = async () => {
setLoading(true);
try {
// If authenticated, try to load from cloud first
if (isAuthenticated && isCloudSyncEnabled()) {
const cloudReports = await getCloudReports();
if (cloudReports.length > 0) {
// Convert cloud reports to SavedReport format and filter by market type
const filtered = cloudReports
.filter(r => r.market_type === activeTab)
.map(r => ({
id: parseInt(r.id.replace(/-/g, '').slice(0, 8), 16), // Convert UUID to number
cloudId: r.id, // Keep cloud ID for deletion
ticker: r.ticker,
market_type: r.market_type as "us" | "twse" | "tpex",
analysis_date: r.analysis_date,
saved_at: new Date(r.created_at),
result: r.result,
})) as (SavedReport & { cloudId?: string })[];
setReports(filtered);
setIsCloudData(true);
return;
}
}
// Fall back to local IndexedDB
const data = await getReportsByMarketType(activeTab);
setReports(data);
setIsCloudData(false);
} catch (error) {
console.error("Failed to load reports:", error);
// Fall back to local on error
const data = await getReportsByMarketType(activeTab);
setReports(data);
setIsCloudData(false);
} finally {
setLoading(false);
}
};
const loadCounts = async () => {
try {
if (isAuthenticated && isCloudSyncEnabled()) {
const cloudReports = await getCloudReports();
const cloudCounts = {
us: cloudReports.filter(r => r.market_type === "us").length,
twse: cloudReports.filter(r => r.market_type === "twse").length,
tpex: cloudReports.filter(r => r.market_type === "tpex").length,
};
if (cloudReports.length > 0) {
setCounts(cloudCounts);
return;
}
}
const data = await getReportCountByMarketType();
setCounts(data);
} catch (error) {
console.error("Failed to load counts:", error);
}
};
const handleViewReport = (report: SavedReport) => {
// Set the context with the saved report data
setAnalysisResult(report.result);
setTaskId(report.task_id || null);
setMarketType(report.market_type);
// Navigate to results page
router.push("/analysis/results");
};
const handleDeleteClick = (report: SavedReport) => {
setReportToDelete(report);
setDeleteDialogOpen(true);
};
const handleConfirmDelete = async () => {
if (!reportToDelete) return;
setDeleting(true);
try {
// If this is cloud data, delete from cloud
const cloudId = (reportToDelete as any).cloudId;
if (isCloudData && cloudId) {
await deleteCloudReport(cloudId);
} else if (reportToDelete.id) {
// Delete from local IndexedDB
await deleteReport(reportToDelete.id);
}
// Refresh reports and counts
await loadReports();
await loadCounts();
setDeleteDialogOpen(false);
setReportToDelete(null);
} catch (error) {
console.error("Failed to delete report:", error);
} finally {
setDeleting(false);
}
};
// Download PDF handler
const [downloadingId, setDownloadingId] = useState<number | null>(null);
const handleDownloadPdf = async (report: SavedReport) => {
setDownloadingId(report.id ?? null);
try {
// Get all available analyst keys
const getNestedValue = (obj: any, path: string) => {
return path.split('.').reduce((current, key) => current?.[key], obj);
};
const availableAnalystKeys = ANALYSTS
.filter(analyst => {
const reportContent = getNestedValue(report.result.reports, analyst.reportKey);
return reportContent && reportContent.trim().length > 0;
})
.map(a => a.key);
if (availableAnalystKeys.length === 0) {
alert('此報告沒有可下載的分析師報告');
return;
}
// Build request body
const requestBody = {
ticker: report.ticker,
analysis_date: report.analysis_date,
analysts: availableAnalystKeys,
reports: report.result.reports,
price_data: report.result.price_data,
price_stats: report.result.price_stats,
};
const response = await fetch('/api/download/reports', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify(requestBody),
});
if (!response.ok) {
const errorData = await response.json().catch(() => ({}));
throw new Error(errorData.detail || `下載失敗 (${response.status})`);
}
// Get the blob
const blob = await response.blob();
// Get filename from header
const contentDisposition = response.headers.get('Content-Disposition');
let filename = `${report.ticker}_Combined_Report_${report.analysis_date}.pdf`;
if (contentDisposition) {
const filenameMatch = contentDisposition.match(/filename=(.+)/);
if (filenameMatch) {
filename = filenameMatch[1];
}
}
// Create download link
const url = window.URL.createObjectURL(blob);
const link = document.createElement('a');
link.href = url;
link.download = filename;
document.body.appendChild(link);
link.click();
// Cleanup
document.body.removeChild(link);
window.URL.revokeObjectURL(url);
} catch (error: any) {
console.error('Download error:', error);
alert(error.message || '下載失敗,請稍後再試');
} finally {
setDownloadingId(null);
}
};
return (
<div className="min-h-screen bg-gradient-to-br from-purple-50/30 via-pink-50/20 to-purple-50/30 dark:from-gray-950 dark:via-purple-950/40 dark:to-gray-950">
<div className="container mx-auto px-4 py-12">
<div className="max-w-6xl mx-auto space-y-8">
{/* Header */}
<div className="text-center relative animate-fade-in">
<div className="absolute inset-0 gradient-bg-radial opacity-40 -z-10" />
<h1 className="text-4xl font-bold mb-2 gradient-text-primary">
</h1>
<p className="text-gray-600 dark:text-gray-400">
</p>
</div>
{/* Pending Task Recovery Notice */}
<PendingTaskRecovery />
{/* Market Type Tabs */}
<Tabs
value={activeTab}
onValueChange={(v) => setActiveTab(v as typeof activeTab)}
className="w-full animate-slide-up animate-delay-200"
>
<TabsList className="grid w-full grid-cols-3 h-auto gap-2">
{(Object.keys(MARKET_LABELS) as Array<keyof typeof MARKET_LABELS>).map(
(key) => (
<TabsTrigger
key={key}
value={key}
className="py-3 text-base transition-all duration-300 hover:scale-105"
>
<span className="mr-2">{MARKET_LABELS[key].label}</span>
<span className="px-2 py-0.5 rounded-full bg-white/20 text-xs">
{counts[key]}
</span>
</TabsTrigger>
)
)}
</TabsList>
{(Object.keys(MARKET_LABELS) as Array<keyof typeof MARKET_LABELS>).map(
(marketType) => (
<TabsContent key={marketType} value={marketType} className="mt-6">
<div className="space-y-4">
{/* Refresh button */}
<div className="flex justify-end">
<Button
variant="outline"
size="sm"
onClick={loadReports}
disabled={loading}
className="gap-2"
>
<RefreshCw
className={`h-4 w-4 ${loading ? "animate-spin" : ""}`}
/>
</Button>
</div>
{/* Report List */}
{loading ? (
<div className="text-center py-12">
<RefreshCw className="h-8 w-8 animate-spin mx-auto text-gray-400" />
<p className="text-gray-500 mt-4">...</p>
</div>
) : reports.length === 0 ? (
<Card className="animate-fade-in">
<CardContent className="py-12 text-center">
<TrendingUp className="h-12 w-12 mx-auto text-gray-300 dark:text-gray-600 mb-4" />
<p className="text-gray-500 dark:text-gray-400">
{MARKET_LABELS[marketType].label}
</p>
<p className="text-sm text-gray-400 dark:text-gray-500 mt-2">
</p>
<Button
variant="outline"
className="mt-4"
onClick={() => router.push("/analysis")}
>
</Button>
</CardContent>
</Card>
) : (
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4">
{reports.map((report) => (
<Card
key={report.id}
className="hover-lift animate-scale-up transition-all duration-300"
>
<CardHeader>
<CardTitle className="flex items-center justify-between">
<span className="text-xl font-bold gradient-text-primary">
{report.ticker}
</span>
<span className="text-xs px-2 py-1 rounded-full bg-gradient-to-r from-blue-100 to-pink-100 dark:from-blue-900 dark:to-purple-900 text-gray-600 dark:text-gray-300">
{MARKET_LABELS[report.market_type].label}
</span>
</CardTitle>
<CardDescription>
{report.analysis_date}
</CardDescription>
</CardHeader>
<CardContent>
<p className="text-sm text-gray-500 dark:text-gray-400">
{format(
new Date(report.saved_at),
"yyyy/MM/dd HH:mm",
{ locale: zhTW }
)}
</p>
{(() => {
const decision = extractDecisionFromReport(report);
return (
<p className="text-sm mt-2">
<span className="font-medium"></span>
<span className={`ml-1 font-semibold ${decision.color}`}>
{decision.action}
</span>
</p>
);
})()}
</CardContent>
<CardFooter className="flex gap-2 flex-wrap">
<Button
variant="default"
size="sm"
className="flex-1 gap-1"
onClick={() => handleViewReport(report)}
>
<Eye className="h-4 w-4" />
</Button>
<Button
variant="outline"
size="sm"
className="gap-1"
onClick={() => handleDownloadPdf(report)}
disabled={downloadingId === report.id}
>
{downloadingId === report.id ? (
<>
<Download className="h-4 w-4 animate-bounce" />
</>
) : (
<>
<FileText className="h-4 w-4" />
PDF
</>
)}
</Button>
<Button
variant="destructive"
size="sm"
className="gap-1"
onClick={() => handleDeleteClick(report)}
>
<Trash2 className="h-4 w-4" />
</Button>
</CardFooter>
</Card>
))}
</div>
)}
</div>
</TabsContent>
)
)}
</Tabs>
</div>
</div>
{/* Delete Confirmation Dialog */}
<Dialog open={deleteDialogOpen} onOpenChange={setDeleteDialogOpen}>
<DialogContent>
<DialogHeader>
<DialogTitle></DialogTitle>
<DialogDescription>
<strong>{reportToDelete?.ticker}</strong> {" "}
<strong>{reportToDelete?.analysis_date}</strong>
<br />
<span className="text-red-500"></span>
</DialogDescription>
</DialogHeader>
<DialogFooter>
<Button
variant="outline"
onClick={() => setDeleteDialogOpen(false)}
disabled={deleting}
>
</Button>
<Button
variant="destructive"
onClick={handleConfirmDelete}
disabled={deleting}
>
{deleting ? "刪除中..." : "確認刪除"}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
</div>
);
}