feat: 新增彭博数据侧边栏功能,并优化历史记录视图以按公司分组显示会话。

This commit is contained in:
xucheng 2026-01-17 22:16:48 +08:00
parent d7b6015c9a
commit 43c21b7658
7 changed files with 512 additions and 174 deletions

View File

@ -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())

View File

@ -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<SearchResult | null>(null)
const [selectedDataSource, setSelectedDataSource] = useState<string>("iFinD")
const [isSidebarOpen, setIsSidebarOpen] = useState(true)
const [isRightSidebarOpen, setIsRightSidebarOpen] = useState(false)
const [companyId, setCompanyId] = useState<number | null>(null)
const [analysisId, setAnalysisId] = useState<number | null>(null)
const [oneTimeModel, setOneTimeModel] = useState<string | undefined>(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 (
<div className="flex h-screen w-full bg-background overflow-hidden">
<div className="flex h-screen w-full bg-background overflow-hidden relative">
{/* Sidebar */}
<div
className={cn(
@ -233,11 +237,36 @@ function HomeInner() {
{isSidebarOpen ? <ChevronLeft className="h-4 w-4" /> : <ChevronRight className="h-4 w-4" />}
</Button>
{/* Right Sidebar Toggle Button */}
{selectedCompany && (
<Button
variant="secondary"
size="icon"
className={cn(
"absolute right-0 top-1/2 -translate-y-1/2 z-20 h-12 w-4 p-0 rounded-r-none rounded-l-lg border border-r-0 shadow-sm hover:w-8 transition-all opacity-50 hover:opacity-100 bg-blue-50 hover:bg-blue-100 dark:bg-blue-900/20 dark:hover:bg-blue-900/40 text-blue-600 dark:text-blue-400",
isRightSidebarOpen && "right-[800px]"
)}
onClick={() => setIsRightSidebarOpen(!isRightSidebarOpen)}
title="Bloomberg Financial Data"
>
{isRightSidebarOpen ? <PanelRightClose className="h-4 w-4" /> : <TrendingUp className="h-4 w-4" />}
</Button>
)}
{/* Scrollable Content */}
<main className="flex-1 overflow-auto">
{renderContent()}
</main>
</div>
{/* Right Sidebar Component */}
{selectedCompany && (
<SidePanelBloomberg
company={selectedCompany}
isOpen={isRightSidebarOpen}
onClose={() => setIsRightSidebarOpen(false)}
/>
)}
</div>
)
}

View File

@ -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})`
})
})

View File

@ -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

View File

@ -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<GroupedSession[]>([])
const [selectedSessionId, setSelectedSessionId] = useState<string | null>(null)
const [groupedCompanies, setGroupedCompanies] = useState<GroupedCompany[]>([])
// Initial selection state
const [selection, setSelection] = useState<{ type: 'company' | 'session', id: string } | null>(null)
const [expandedCompanies, setExpandedCompanies] = useState<Set<string>>(new Set())
const [filterText, setFilterText] = useState("")
// 多选模式状态
// Multi-select mode state
const [isSelectMode, setIsSelectMode] = useState(false)
const [selectedForExport, setSelectedForExport] = useState<Set<string>>(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 <div className="flex justify-center items-center h-full"><Loader2 className="animate-spin" /></div>
}
return (
<div className="flex h-full gap-4 p-4 overflow-hidden">
{/* Left Sidebar: Session List */}
<div className="w-[280px] flex-shrink-0 flex flex-col gap-2 h-full">
{/* Left Sidebar: Tree List */}
<div className="w-[300px] flex-shrink-0 flex flex-col gap-2 h-full">
{/* Tools Header */}
<div className="flex items-center gap-2 mb-4 flex-shrink-0">
<div className="relative flex-1">
<Search className="absolute left-2 top-2.5 h-4 w-4 text-muted-foreground" />
<Input
placeholder="筛选代码..."
placeholder="筛选公司..."
value={filterText}
onChange={(e) => setFilterText(e.target.value)}
className="pl-8 h-9"
@ -204,111 +305,114 @@ export function HistoryView() {
<Download className="h-4 w-4" />
</button>
</div>
{/* 多选模式工具栏 */}
{isSelectMode && (
<div className="flex items-center gap-2 mb-2 p-2 bg-muted/30 rounded-md flex-shrink-0">
<span className="text-sm text-muted-foreground flex-1">
{selectedForExport.size}
</span>
<Button
size="sm"
variant="ghost"
onClick={() => {
setIsSelectMode(false)
setSelectedForExport(new Set())
}}
>
<X className="h-4 w-4 mr-1" />
<span className="text-sm text-muted-foreground flex-1"> {selectedForExport.size}</span>
<Button size="sm" variant="ghost" onClick={() => { setIsSelectMode(false); setSelectedForExport(new Set()) }}>
<X className="h-4 w-4 mr-1" />
</Button>
<Button
size="sm"
disabled={selectedForExport.size === 0 || isExporting}
onClick={handleExportPDF}
>
<Button size="sm" disabled={selectedForExport.size === 0 || isExporting} onClick={handleExportPDF}>
{isExporting ? <Loader2 className="h-4 w-4 mr-1 animate-spin" /> : <FileDown className="h-4 w-4 mr-1" />}
PDF
PDF
</Button>
</div>
)}
<div className="flex-1 overflow-y-auto pr-2 space-y-1 custom-scrollbar">
{groupedSessions
.filter(group =>
group.stock_code.toLowerCase().includes(filterText.toLowerCase())
)
.map(group => (
<div
key={group.session_id}
className={`
cursor-pointer px-3 py-2 rounded-md text-sm transition-colors flex items-center gap-2
${selectedForExport.has(group.session_id) ? 'bg-primary/10 border border-primary/30' : ''}
${!isSelectMode && selectedSessionId === group.session_id ? 'bg-secondary font-medium text-foreground' : 'text-muted-foreground hover:bg-muted/50 hover:text-foreground'}
`}
onClick={() => {
if (isSelectMode) {
toggleSessionSelection(group.session_id)
} else {
setSelectedSessionId(group.session_id)
}
}}
>
{isSelectMode && (
<input
type="checkbox"
checked={selectedForExport.has(group.session_id)}
onChange={() => toggleSessionSelection(group.session_id)}
onClick={(e) => e.stopPropagation()}
className="h-4 w-4 rounded border-gray-300 text-primary focus:ring-primary"
/>
)}
<div className="flex-1 flex items-center justify-between">
<span className="font-mono font-medium">{group.stock_code}</span>
<span className="text-xs opacity-70">
{formatDate(group.timestamp)}
</span>
{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 (
<div key={company.stock_code} className="mb-1">
{/* Company Node */}
<div
className={`
flex items-center gap-2 px-3 py-2 rounded-md cursor-pointer transition-colors
${isCompanySelected && !isSelectMode ? 'bg-secondary font-medium text-foreground' : 'text-muted-foreground hover:bg-muted/50 hover:text-foreground'}
`}
onClick={() => handleSelectCompany(company.stock_code)}
>
{/* Expand Toggle */}
<div
className="p-0.5 rounded-sm hover:bg-muted/80"
onClick={(e) => toggleCompanyExpansion(company.stock_code, e)}
>
<svg
className={`w-4 h-4 transition-transform ${isExpanded ? 'rotate-90' : ''}`}
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="2"
>
<path d="M9 18l6-6-6-6" />
</svg>
</div>
<div className="flex-1 flex items-center justify-between">
<span className="font-medium text-sm">{company.stock_code}</span>
<span className="text-xs opacity-70 scale-90">{formatDate(company.latest_timestamp)}</span>
</div>
</div>
{/* Session Children */}
{isExpanded && (
<div className="pl-4 mt-1 space-y-0.5 border-l-2 border-muted ml-3">
{company.sessions.map(session => {
const isSessionSelected = selection?.type === 'session' && selection.id === session.session_id
return (
<div
key={session.session_id}
className={`
flex items-center gap-2 px-3 py-1.5 rounded-md text-xs cursor-pointer transition-colors
${selectedForExport.has(session.session_id) ? 'bg-primary/10 border-primary/30' : ''}
${isSessionSelected && !isSelectMode ? 'bg-secondary/80 text-foreground font-medium' : 'text-muted-foreground hover:bg-muted/50'}
`}
onClick={(e) => handleSelectSession(session.session_id, e)}
>
{isSelectMode && (
<input
type="checkbox"
checked={selectedForExport.has(session.session_id)}
readOnly
className="h-3 w-3 rounded border-gray-300 text-primary focus:ring-primary mr-1"
/>
)}
<div className="flex-1 truncate">
{formatDate(session.timestamp)}
</div>
</div>
)
})}
</div>
)}
</div>
</div>
))}
)
})}
</div>
</div>
{/* Right Content: Chat Details */}
{/* Right Content */}
<div className="flex-1 flex flex-col h-full bg-card rounded-lg border shadow-sm overflow-hidden">
{selectedSession ? (
{selection ? (
<div className="flex flex-col h-full">
<div className="p-4 border-b bg-muted/20 flex justify-between items-center flex-shrink-0">
<div className="flex items-center gap-3">
<h3 className="font-bold flex items-center gap-2">
<MessageSquare className="w-4 h-4" />
{selectedSession.stock_code}
<h3 className="font-bold flex items-center gap-2 text-lg">
<MessageSquare className="w-5 h-5" />
{displayTitle}
</h3>
<Badge variant="outline">{selectedSession.session_id}</Badge>
<Badge variant="outline" className="font-mono">{displaySubtitle}</Badge>
</div>
<span className="text-sm text-muted-foreground">
{formatFullDate(selectedSession.timestamp)}
</span>
</div>
<div className="flex-1 overflow-y-auto p-6 scroll-smooth">
<div className="space-y-8 max-w-4xl mx-auto pb-8">
{selectedSession.logs.map((log, idx) => (
{displayLogs.map((log, idx) => (
<div key={log.id} className="space-y-4">
{/* User Message - Hidden as requested */}
{/*
<div className="flex gap-4">
<div className="w-8 h-8 rounded-full bg-primary/10 flex items-center justify-center shrink-0">
<span className="text-xs font-bold">U</span>
</div>
<div className="flex-1 space-y-2">
<div className="font-semibold text-sm">User</div>
<div className="bg-muted/50 p-4 rounded-lg text-sm whitespace-pre-wrap">
{parsePrompt(log.prompt)}
</div>
</div>
</div>
*/}
{/* Response / Model Message */}
<div className="flex gap-4">
<div className="w-8 h-8 rounded-full bg-blue-500/10 flex items-center justify-center shrink-0">
<span className="text-xs font-bold text-blue-500">AI</span>
@ -316,14 +420,12 @@ export function HistoryView() {
<div className="flex-1 space-y-2">
<div className="flex items-center gap-2 flex-wrap">
<span className="font-semibold text-sm">Model ({log.model})</span>
<span className="text-xs text-muted-foreground">Tokens: {log.total_tokens}</span>
{log.used_google_search ? (
<Badge variant="secondary" className="text-[10px] bg-green-100 text-green-800 dark:bg-green-900 dark:text-green-100 hover:bg-green-100">
Google Search: ON
</Badge>
) : (
<Badge variant="outline" className="text-[10px] text-muted-foreground">
Google Search: OFF
<span className="text-xs text-muted-foreground">
{formatDate(log.timestamp)}
</span>
{log.used_google_search && (
<Badge variant="secondary" className="text-[10px] bg-green-100 text-green-800">
Search: ON
</Badge>
)}
</div>
@ -331,21 +433,13 @@ export function HistoryView() {
<ReactMarkdown
remarkPlugins={[remarkGfm]}
components={{
p: ({ node, children, ...props }) => <p className="mb-2 last:mb-0" {...props}>{children}</p>,
strong: ({ node, children, ...props }) => <strong className="font-bold text-foreground" {...props}>{children}</strong>,
em: ({ node, children, ...props }) => <em className="italic" {...props}>{children}</em>,
a: ({ node, href, children, ...props }) => <a href={href} className="text-blue-500 hover:underline" target="_blank" rel="noopener noreferrer" {...props}>{children}</a>,
ul: ({ node, children, ...props }) => <ul className="list-disc list-inside mb-2" {...props}>{children}</ul>,
ol: ({ node, children, ...props }) => <ol className="list-decimal list-inside mb-2" {...props}>{children}</ol>,
li: ({ node, children, ...props }) => <li className="mb-1" {...props}>{children}</li>,
h1: ({ node, children, ...props }) => <h1 className="text-lg font-bold mb-2" {...props}>{children}</h1>,
h2: ({ node, children, ...props }) => <h2 className="text-base font-bold mb-2" {...props}>{children}</h2>,
h3: ({ node, children, ...props }) => <h3 className="text-sm font-bold mb-1" {...props}>{children}</h3>,
blockquote: ({ node, children, ...props }) => <blockquote className="border-l-4 border-muted-foreground/30 pl-3 italic text-muted-foreground my-2" {...props}>{children}</blockquote>,
pre: ({ node, className, ...props }) => <pre className={`overflow-auto w-full my-2 bg-black/10 dark:bg-black/30 p-2 rounded ${className || ''}`} {...props} />,
code: ({ node, className, children, ...props }) => {
return <code className={`${className || ''} bg-black/5 dark:bg-white/10 rounded px-1`} {...props}>{children}</code>
}
p: ({ children }) => <p className="mb-2 last:mb-0">{children}</p>,
a: ({ href, children }) => <a href={href} className="text-blue-500 hover:underline" target="_blank" rel="noopener noreferrer">{children}</a>,
pre: ({ className, ...props }) => <pre className={`overflow-auto w-full my-2 bg-black/10 dark:bg-black/30 p-2 rounded ${className || ''}`} {...props} />,
code: ({ className, children, ...props }) => <code className={`${className || ''} bg-black/5 dark:bg-white/10 rounded px-1`} {...props}>{children}</code>,
table: ({ children }) => <div className="overflow-x-auto my-4"><table className="min-w-full divide-y divide-gray-300 dark:divide-gray-700">{children}</table></div>,
th: ({ children }) => <th className="px-3 py-2 bg-gray-100 dark:bg-gray-800 font-semibold text-left">{children}</th>,
td: ({ children }) => <td className="px-3 py-2 border-t border-gray-200 dark:border-gray-700">{children}</td>,
}}
>
{preprocessMarkdown(log.response)}
@ -353,7 +447,7 @@ export function HistoryView() {
</div>
</div>
</div>
{idx < selectedSession.logs.length - 1 && <div className="my-8 h-px bg-border" />}
{idx < displayLogs.length - 1 && <div className="my-8 h-px bg-border" />}
</div>
))}
</div>
@ -362,7 +456,7 @@ export function HistoryView() {
) : (
<div className="flex flex-col items-center justify-center h-full text-muted-foreground">
<MessageSquare className="w-12 h-12 mb-4 opacity-50" />
<p></p>
<p></p>
</div>
)}
</div>

View File

@ -130,7 +130,7 @@ function NavHeaderInner() {
</div>
{/* Portal Target for Dynamic Content */}
<div id="header-portal-target" className="flex-1 flex items-center justify-end px-4 gap-4 min-w-0" />
<div id="header-portal-target" suppressHydrationWarning className="flex-1 flex items-center justify-end px-4 gap-4 min-w-0" />
<nav className="flex items-center gap-4 sm:gap-6 border-l pl-6">
<Link className="text-sm font-medium hover:underline underline-offset-4" href="/">

View File

@ -0,0 +1,119 @@
"use client"
import { useEffect, useState } from "react"
import { cn } from "@/lib/utils"
import { Button } from "@/components/ui/button"
// Removed ScrollArea
import { X, Loader2, AlertCircle } from "lucide-react"
import { useFinancialData } from "@/hooks/use-financial-data"
import { BloombergView } from "@/components/bloomberg-view"
import type { SearchResult } from "@/lib/types"
interface SidePanelBloombergProps {
company: SearchResult
isOpen: boolean
onClose: () => void
}
export function SidePanelBloomberg({ company, isOpen, onClose }: SidePanelBloombergProps) {
// Only fetch when open to save resources? Or pre-fetch?
// Let's fetch when isOpen becomes true or company changes.
// useFinancialData handles fetching internally but depends on us triggering it or checking status.
// Actually useFinancialData runs checkStatus on mount.
const {
status,
loading, // loading status check
fetching, // fetching new data
error,
fetchData,
checkStatus
} = useFinancialData(company, "Bloomberg")
// Effect to trigger status check when opened if not already loaded?
// useFinancialData does it on mount (when company changes).
// so we just rely on that.
const hasData = status?.has_data && status?.company_id
return (
<>
{/* Backdrop */}
{isOpen && (
<div
className="fixed inset-0 z-40 bg-black/20 backdrop-blur-sm transition-opacity"
onClick={onClose}
/>
)}
{/* Panel */}
<div
className={cn(
"fixed inset-y-0 right-0 z-50 w-[800px] bg-background border-l shadow-2xl transition-transform duration-300 ease-in-out transform",
isOpen ? "translate-x-0" : "translate-x-full"
)}
>
<div className="flex flex-col h-full">
{/* Header */}
<div className="flex items-center justify-between p-4 border-b">
<div className="flex flex-col">
<h2 className="text-lg font-semibold">Bloomberg Financial Data</h2>
<span className="text-sm text-muted-foreground">
{company.company_name} ({company.symbol})
</span>
</div>
<Button variant="ghost" size="icon" onClick={onClose}>
<X className="h-5 w-5" />
</Button>
</div>
{/* Content */}
<div className="flex-1 overflow-hidden relative flex flex-col">
{loading ? (
<div className="flex flex-col items-center justify-center h-full space-y-4">
<Loader2 className="h-8 w-8 animate-spin text-primary" />
<p className="text-muted-foreground">Checking data status...</p>
</div>
) : error ? (
<div className="flex flex-col items-center justify-center h-full space-y-4 p-8 text-center">
<AlertCircle className="h-10 w-10 text-destructive" />
<p className="text-destructive font-medium">{error}</p>
<Button variant="outline" onClick={() => checkStatus()}>Retry</Button>
</div>
) : !hasData ? (
<div className="flex flex-col items-center justify-center h-full space-y-4 p-8 text-center">
<p className="text-muted-foreground">No Bloomberg data found for this company.</p>
<Button
onClick={() => fetchData(true)}
disabled={fetching}
>
{fetching ? (
<>
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
Fetching...
</>
) : (
"Fetch from Bloomberg"
)}
</Button>
</div>
) : (
<div className="flex-1 overflow-y-auto">
<div className="p-6">
{status?.company_id && (
<BloombergView
companyId={status.company_id}
userMarket={company.market}
lastUpdate={status.last_update?.date}
selectedCurrency="Auto"
/>
)}
</div>
</div>
)}
</div>
</div>
</div>
</>
)
}