diff --git a/backend/scripts/migrate_stock_codes.py b/backend/scripts/migrate_stock_codes.py new file mode 100644 index 0000000..1ced241 --- /dev/null +++ b/backend/scripts/migrate_stock_codes.py @@ -0,0 +1,93 @@ +import asyncio +import sys +import os +import re +from sqlalchemy import select, update + +# Add parent directory to path to import app modules +sys.path.append(os.path.dirname(os.path.dirname(os.path.abspath(__file__)))) + +from app.database import SessionLocal +from app.models import LLMUsageLog, Company + +async def migrate(): + async with SessionLocal() as session: + print("🚀 Starting migration of stock codes in LLMUsageLogs...") + + # 1. Fetch all companies to build a lookup map + # Map: symbol -> { name, market } + # Note: Symbols might not be unique across markets (e.g. same code in diff markets?), + # but for now we assume symbol allows us to find the main entry or we try to best match. + # To be safe, we might need to be smart about extracting symbol. + + print("📦 Fetching company metadata...") + result = await session.execute(select(Company)) + companies = result.scalars().all() + + # Create lookup: symbol -> Company + # If multiple markets have same symbol, this simple map might be ambiguous. + # We'll use the first one found or maybe we can improve logic if needed. + company_map = {c.symbol: c for c in companies} + + print(f"✅ Loaded {len(company_map)} companies.") + + # 2. Fetch all logs + print("📜 Fetching chat logs...") + result = await session.execute(select(LLMUsageLog)) + logs = result.scalars().all() + + print(f"✅ Found {len(logs)} logs. Processing...") + + updated_count = 0 + skipped_count = 0 + + for log in logs: + if not log.stock_code: + continue + + original_code = log.stock_code + symbol = None + + # Pattern 1: "Name (Symbol)" e.g. "金龙鱼 (300999)" + match1 = re.search(r'\((.*?)\)', original_code) + + # Pattern 2: "Symbol" e.g. "300999" or "AAPL" + # If no brackets, assume the whole string is the symbol (trimmed) + + if match1: + # Extract content inside brackets + content = match1.group(1) + # Check if it already has market info inside brackets e.g. "300999 CH" (space separated) + parts = content.split() + symbol = parts[0] + else: + # No brackets, assume it is just the symbol + symbol = original_code.strip() + + # Lookup + if symbol in company_map: + company = company_map[symbol] + + # Format: "Name (Symbol Market)" + new_code = f"{company.company_name} ({company.symbol} {company.market})" + + if new_code != original_code: + log.stock_code = new_code + updated_count += 1 + # print(f" 🔄 Updating: '{original_code}' -> '{new_code}'") + else: + # print(f" ⚠️ Symbol '{symbol}' not found in companies table. Skipping '{original_code}'.") + skipped_count += 1 + + # 3. Commit changes + if updated_count > 0: + print(f"💾 Committing {updated_count} updates to database...") + await session.commit() + print("✅ Database updated successfully.") + else: + print("✨ No updates needed.") + + print(f"Done. Updated: {updated_count}, Skipped/Unchanged: {skipped_count}") + +if __name__ == "__main__": + asyncio.run(migrate()) diff --git a/frontend/src/app/page.tsx b/frontend/src/app/page.tsx index 7d5cd74..1d2f9fa 100644 --- a/frontend/src/app/page.tsx +++ b/frontend/src/app/page.tsx @@ -13,7 +13,7 @@ import { AnalysisReport } from "@/components/analysis-report" import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card" import { Badge } from "@/components/ui/badge" import { Button } from "@/components/ui/button" -import { ArrowLeft, Building2, RefreshCw, Loader2, CheckCircle2, ChevronLeft, ChevronRight } from "lucide-react" +import { ArrowLeft, Building2, RefreshCw, Loader2, CheckCircle2, ChevronLeft, ChevronRight, TrendingUp, PanelRightClose } from "lucide-react" import type { SearchResult } from "@/lib/types" import { useFinancialData } from "@/hooks/use-financial-data" import { Progress } from "@/components/ui/progress" @@ -24,6 +24,7 @@ import { AppSidebar } from "@/components/app-sidebar" import { StockChart } from "@/components/stock-chart" import { AiDiscussionView } from "@/components/ai-discussion-view" import { HistoryView } from "@/components/history-view" +import { SidePanelBloomberg } from "@/components/side-panel-bloomberg" function HomeInner() { const searchParams = useSearchParams() @@ -32,6 +33,7 @@ function HomeInner() { const [selectedCompany, setSelectedCompany] = useState(null) const [selectedDataSource, setSelectedDataSource] = useState("iFinD") const [isSidebarOpen, setIsSidebarOpen] = useState(true) + const [isRightSidebarOpen, setIsRightSidebarOpen] = useState(false) const [companyId, setCompanyId] = useState(null) const [analysisId, setAnalysisId] = useState(null) const [oneTimeModel, setOneTimeModel] = useState(undefined) @@ -49,6 +51,8 @@ function HomeInner() { // Switch to financial view by default when company is selected setCurrentView("financial") + // Close right sidebar on new selection + setIsRightSidebarOpen(false) // 如果没有传入数据源,则根据市场设置默认值 const targetDataSource = dataSource || (company.market === 'CN' ? 'Tushare' : 'iFinD') @@ -204,7 +208,7 @@ function HomeInner() { } return ( -
+
{/* Sidebar */}
: } + {/* Right Sidebar Toggle Button */} + {selectedCompany && ( + + )} + {/* Scrollable Content */}
{renderContent()}
+ + {/* Right Sidebar Component */} + {selectedCompany && ( + setIsRightSidebarOpen(false)} + /> + )}
) } diff --git a/frontend/src/components/ai-discussion-view.tsx b/frontend/src/components/ai-discussion-view.tsx index 32bf512..fd36b26 100644 --- a/frontend/src/components/ai-discussion-view.tsx +++ b/frontend/src/components/ai-discussion-view.tsx @@ -205,7 +205,7 @@ export function AiDiscussionView({ companyName, symbol, market }: { companyName: system_prompt: systemPrompt, use_google_search: useGoogleSearch, session_id: sessionId, - stock_code: `${companyName} (${symbol})` + stock_code: `${companyName} (${symbol} ${market})` }) }) diff --git a/frontend/src/components/header-portal.tsx b/frontend/src/components/header-portal.tsx index 84d8703..a236fff 100644 --- a/frontend/src/components/header-portal.tsx +++ b/frontend/src/components/header-portal.tsx @@ -7,8 +7,11 @@ export const HeaderPortal = ({ children }: { children: React.ReactNode }) => { const [mounted, setMounted] = useState(false) useEffect(() => { - setMounted(true) - return () => setMounted(false) + const timer = setTimeout(() => setMounted(true), 0) + return () => { + clearTimeout(timer) + setMounted(false) + } }, []) if (!mounted) return null diff --git a/frontend/src/components/history-view.tsx b/frontend/src/components/history-view.tsx index fa2441c..27316c4 100644 --- a/frontend/src/components/history-view.tsx +++ b/frontend/src/components/history-view.tsx @@ -26,13 +26,21 @@ interface GroupedSession { logs: LogEntry[] } +interface GroupedCompany { + stock_code: string + latest_timestamp: string + sessions: GroupedSession[] +} + export function HistoryView() { const [loading, setLoading] = useState(true) - const [groupedSessions, setGroupedSessions] = useState([]) - const [selectedSessionId, setSelectedSessionId] = useState(null) + const [groupedCompanies, setGroupedCompanies] = useState([]) + // Initial selection state + const [selection, setSelection] = useState<{ type: 'company' | 'session', id: string } | null>(null) + const [expandedCompanies, setExpandedCompanies] = useState>(new Set()) const [filterText, setFilterText] = useState("") - // 多选模式状态 + // Multi-select mode state const [isSelectMode, setIsSelectMode] = useState(false) const [selectedForExport, setSelectedForExport] = useState>(new Set()) const [isExporting, setIsExporting] = useState(false) @@ -43,52 +51,75 @@ export function HistoryView() { const fetchHistory = async () => { try { - // Use relative path to leverage Next.js proxy if configured, or assume same origin - // If dev environment needs full URL, use environment variable or fallback const res = await fetch("/api/chat/history") - if (!res.ok) { - console.error("Failed to fetch history:", res.status, res.statusText) + console.error("Failed to fetch history:", res.status) return } const data = await res.json() + if (!Array.isArray(data)) return - if (!Array.isArray(data)) { - console.error("History data is not an array:", data) - return - } - - // Group by session_id - const groups: { [key: string]: GroupedSession } = {} + // 1. Group by Stock Code first + const companyGroups: { [code: string]: { code: string, lastTimestamp: string, sessions: { [sid: string]: GroupedSession } } } = {} data.forEach((log: LogEntry) => { - const sid = log.session_id || "unknown" - if (!groups[sid]) { - groups[sid] = { - session_id: sid, - stock_code: log.stock_code || "Unknown", + const stockCode = log.stock_code || "Unknown" + const sessionId = log.session_id || "unknown" + + if (!companyGroups[stockCode]) { + companyGroups[stockCode] = { + code: stockCode, + lastTimestamp: log.timestamp, + sessions: {} + } + } + + if (!companyGroups[stockCode].sessions[sessionId]) { + companyGroups[stockCode].sessions[sessionId] = { + session_id: sessionId, + stock_code: stockCode, timestamp: log.timestamp, logs: [] } } - groups[sid].logs.push(log) + + companyGroups[stockCode].sessions[sessionId].logs.push(log) + + // Update company last timestamp if this log is newer + if (new Date(log.timestamp) > new Date(companyGroups[stockCode].lastTimestamp)) { + companyGroups[stockCode].lastTimestamp = log.timestamp + } + // Update session timestamp if this log is newer (though usually logs come in order or we sort later) + if (new Date(log.timestamp) > new Date(companyGroups[stockCode].sessions[sessionId].timestamp)) { + companyGroups[stockCode].sessions[sessionId].timestamp = log.timestamp + } }) - // Sort logs within each group by timestamp (ascending) for display - Object.values(groups).forEach(group => { - group.logs.sort((a, b) => new Date(a.timestamp).getTime() - new Date(b.timestamp).getTime()) - }) + // 2. Convert to arrays and sort + const sortedCompanies: GroupedCompany[] = Object.values(companyGroups).map(company => { + // Sort sessions within company: newest first + const sessions = Object.values(company.sessions).map(session => { + // Sort logs within session: oldest first (chronological) + session.logs.sort((a, b) => new Date(a.timestamp).getTime() - new Date(b.timestamp).getTime()) + return session + }).sort((a, b) => new Date(b.timestamp).getTime() - new Date(a.timestamp).getTime()) - // Convert to array and sort by timestamp - const sortedGroups = Object.values(groups).sort((a, b) => - new Date(b.timestamp).getTime() - new Date(a.timestamp).getTime() - ) + return { + stock_code: company.code, + latest_timestamp: company.lastTimestamp, + sessions: sessions + } + }).sort((a, b) => new Date(b.latest_timestamp).getTime() - new Date(a.latest_timestamp).getTime()) - setGroupedSessions(sortedGroups) - if (sortedGroups.length > 0) { - setSelectedSessionId(sortedGroups[0].session_id) + setGroupedCompanies(sortedCompanies) + + // Select the first company by default if nothing is selected + if (sortedCompanies.length > 0 && !selection) { + setSelection({ type: 'company', id: sortedCompanies[0].stock_code }) + setExpandedCompanies(new Set([sortedCompanies[0].stock_code])) } + } catch (error) { console.error("Failed to fetch history:", error) } finally { @@ -96,12 +127,97 @@ export function HistoryView() { } } - const selectedSession = groupedSessions.find(g => g.session_id === selectedSessionId) + const toggleCompanyExpansion = (stockCode: string, e?: React.MouseEvent) => { + if (e) e.stopPropagation() + setExpandedCompanies(prev => { + const next = new Set(prev) + if (next.has(stockCode)) { + next.delete(stockCode) + } else { + next.add(stockCode) + } + return next + }) + } + + const handleSelectCompany = (stockCode: string) => { + if (isSelectMode) return + setSelection({ type: 'company', id: stockCode }) + // Also ensure it is expanded + setExpandedCompanies(prev => new Set(prev).add(stockCode)) + } + + const handleSelectSession = (sessionId: string, e: React.MouseEvent) => { + e.stopPropagation() + if (isSelectMode) { + toggleSessionExportSelection(sessionId) + } else { + setSelection({ type: 'session', id: sessionId }) + } + } + + const toggleSessionExportSelection = (sessionId: string) => { + setSelectedForExport(prev => { + const next = new Set(prev) + if (next.has(sessionId)) next.delete(sessionId) + else next.add(sessionId) + return next + }) + } + + // Prepare logs for display based on selection + const getDisplayLogs = () => { + if (!selection) return [] + + if (selection.type === 'session') { + for (const company of groupedCompanies) { + const session = company.sessions.find(s => s.session_id === selection.id) + if (session) return session.logs + } + } else if (selection.type === 'company') { + const company = groupedCompanies.find(c => c.stock_code === selection.id) + if (company) { + // Flatten all logs from all sessions + const allLogs = company.sessions.flatMap(s => s.logs) + // Sort by timestamp chronological + return allLogs.sort((a, b) => new Date(a.timestamp).getTime() - new Date(b.timestamp).getTime()) + } + } + return [] + } + + const getDisplayTitle = () => { + if (!selection) return "" + if (selection.type === 'company') return selection.id + + // If session, find the stock code + for (const company of groupedCompanies) { + if (company.sessions.some(s => s.session_id === selection.id)) { + return company.stock_code + } + } + return "" + } + + const getDisplaySubtitle = () => { + if (!selection) return "" + if (selection.type === 'company') return "所有讨论记录" + return selection.id + } + + const parseDate = (dateString: string) => { + // Backend returns UTC time without 'Z' (e.g. 2026-01-17T02:31:46.022901) + // We must append 'Z' so the browser parses it as UTC and converts to local time correctly + let safeDateString = dateString + if (dateString && dateString.includes('T') && !dateString.endsWith('Z') && !dateString.includes('+')) { + safeDateString += 'Z' + } + return new Date(safeDateString) + } const formatDate = (dateString: string) => { - const date = new Date(dateString) + const date = parseDate(dateString) return new Intl.DateTimeFormat('zh-CN', { - year: 'numeric', month: '2-digit', day: '2-digit', hour: '2-digit', @@ -111,7 +227,7 @@ export function HistoryView() { } const formatFullDate = (dateString: string) => { - const date = new Date(dateString) + const date = parseDate(dateString) return new Intl.DateTimeFormat('zh-CN', { year: 'numeric', month: '2-digit', @@ -123,23 +239,9 @@ export function HistoryView() { }).format(date) } - // 切换选择某个 session - const toggleSessionSelection = (sessionId: string) => { - setSelectedForExport(prev => { - const next = new Set(prev) - if (next.has(sessionId)) { - next.delete(sessionId) - } else { - next.add(sessionId) - } - return next - }) - } - - // 导出 PDF + // Export PDF logic (reused mostly) const handleExportPDF = async () => { if (selectedForExport.size === 0) return - setIsExporting(true) try { const response = await fetch("/api/chat/export-pdf", { @@ -147,11 +249,7 @@ export function HistoryView() { headers: { "Content-Type": "application/json" }, body: JSON.stringify({ session_ids: Array.from(selectedForExport) }) }) - - if (!response.ok) { - throw new Error("导出失败") - } - + if (!response.ok) throw new Error("导出失败") const blob = await response.blob() const url = window.URL.createObjectURL(blob) const a = document.createElement("a") @@ -161,31 +259,34 @@ export function HistoryView() { a.click() document.body.removeChild(a) window.URL.revokeObjectURL(url) - - // 导出成功后退出多选模式 setIsSelectMode(false) setSelectedForExport(new Set()) } catch (error) { console.error("导出 PDF 失败:", error) - alert("导出 PDF 失败,请稍后重试") + alert("导出 PDF 失败") } finally { setIsExporting(false) } } + const displayLogs = getDisplayLogs() + const displayTitle = getDisplayTitle() + const displaySubtitle = getDisplaySubtitle() + if (loading) { return
} return (
- {/* Left Sidebar: Session List */} -
+ {/* Left Sidebar: Tree List */} +
+ {/* Tools Header */}
setFilterText(e.target.value)} className="pl-8 h-9" @@ -204,111 +305,114 @@ export function HistoryView() {
- {/* 多选模式工具栏 */} + {isSelectMode && (
- - 已选择 {selectedForExport.size} 个会话 - - -
)} +
- {groupedSessions - .filter(group => - group.stock_code.toLowerCase().includes(filterText.toLowerCase()) - ) - .map(group => ( -
{ - if (isSelectMode) { - toggleSessionSelection(group.session_id) - } else { - setSelectedSessionId(group.session_id) - } - }} - > - {isSelectMode && ( - toggleSessionSelection(group.session_id)} - onClick={(e) => e.stopPropagation()} - className="h-4 w-4 rounded border-gray-300 text-primary focus:ring-primary" - /> - )} -
- {group.stock_code} - - {formatDate(group.timestamp)} - + {groupedCompanies + .filter(c => c.stock_code.toLowerCase().includes(filterText.toLowerCase())) + .map(company => { + const isCompanySelected = selection?.type === 'company' && selection.id === company.stock_code + const isExpanded = expandedCompanies.has(company.stock_code) + + return ( +
+ {/* Company Node */} +
handleSelectCompany(company.stock_code)} + > + {/* Expand Toggle */} +
toggleCompanyExpansion(company.stock_code, e)} + > + + + +
+ +
+ {company.stock_code} + {formatDate(company.latest_timestamp)} +
+
+ + {/* Session Children */} + {isExpanded && ( +
+ {company.sessions.map(session => { + const isSessionSelected = selection?.type === 'session' && selection.id === session.session_id + return ( +
handleSelectSession(session.session_id, e)} + > + {isSelectMode && ( + + )} +
+ 会话 {formatDate(session.timestamp)} +
+
+ ) + })} +
+ )}
-
- ))} + ) + })}
- {/* Right Content: Chat Details */} + {/* Right Content */}
- {selectedSession ? ( + {selection ? (
-

- - {selectedSession.stock_code} +

+ + {displayTitle}

- {selectedSession.session_id} + {displaySubtitle}

- - {formatFullDate(selectedSession.timestamp)} -
- {selectedSession.logs.map((log, idx) => ( + {displayLogs.map((log, idx) => (
- {/* User Message - Hidden as requested */} - {/* -
-
- U -
-
-
User
-
- {parsePrompt(log.prompt)} -
-
-
- */} - - {/* Response / Model Message */}
AI @@ -316,14 +420,12 @@ export function HistoryView() {
Model ({log.model}) - Tokens: {log.total_tokens} - {log.used_google_search ? ( - - Google Search: ON - - ) : ( - - Google Search: OFF + + {formatDate(log.timestamp)} + + {log.used_google_search && ( + + Search: ON )}
@@ -331,21 +433,13 @@ export function HistoryView() {

{children}

, - strong: ({ node, children, ...props }) => {children}, - em: ({ node, children, ...props }) => {children}, - a: ({ node, href, children, ...props }) => {children}, - ul: ({ node, children, ...props }) =>
    {children}
, - ol: ({ node, children, ...props }) =>
    {children}
, - li: ({ node, children, ...props }) =>
  • {children}
  • , - h1: ({ node, children, ...props }) =>

    {children}

    , - h2: ({ node, children, ...props }) =>

    {children}

    , - h3: ({ node, children, ...props }) =>

    {children}

    , - blockquote: ({ node, children, ...props }) =>
    {children}
    , - pre: ({ node, className, ...props }) =>
    ,
    -                                                            code: ({ node, className, children, ...props }) => {
    -                                                                return {children}
    -                                                            }
    +                                                            p: ({ children }) => 

    {children}

    , + a: ({ href, children }) => {children}, + pre: ({ className, ...props }) =>
    ,
    +                                                            code: ({ className, children, ...props }) => {children},
    +                                                            table: ({ children }) => 
    {children}
    , + th: ({ children }) => {children}, + td: ({ children }) => {children}, }} > {preprocessMarkdown(log.response)} @@ -353,7 +447,7 @@ export function HistoryView() {
    - {idx < selectedSession.logs.length - 1 &&
    } + {idx < displayLogs.length - 1 &&
    }
    ))}
    @@ -362,7 +456,7 @@ export function HistoryView() { ) : (
    -

    请选择一个会话查看详情

    +

    请选择左侧公司或会话查看详情

    )}
    diff --git a/frontend/src/components/nav-header.tsx b/frontend/src/components/nav-header.tsx index 6205be1..1dfea2b 100644 --- a/frontend/src/components/nav-header.tsx +++ b/frontend/src/components/nav-header.tsx @@ -130,7 +130,7 @@ function NavHeaderInner() {
    {/* Portal Target for Dynamic Content */} -
    +