feat: 添加了聊天历史记录导出为 PDF 的功能,并优化了股票图表的中国市场符号识别。

This commit is contained in:
xucheng 2026-01-15 15:22:29 +08:00
parent f2697149f7
commit 383beb3e27
4 changed files with 380 additions and 21 deletions

View File

@ -1,10 +1,14 @@
from fastapi import APIRouter, HTTPException, Depends
from fastapi.responses import StreamingResponse
from fastapi.responses import StreamingResponse, FileResponse
import json
from pydantic import BaseModel
from typing import List, Optional
import logging
import time
import tempfile
import markdown
from weasyprint import HTML
from urllib.parse import quote
from sqlalchemy.ext.asyncio import AsyncSession
from app.services.analysis_service import get_genai_client
from google.genai import types
@ -196,3 +200,200 @@ async def get_chat_history(db: AsyncSession = Depends(get_db)):
"total_tokens": log.total_tokens,
"used_google_search": log.used_google_search
} for log in logs]
class ExportPDFRequest(BaseModel):
session_ids: List[str]
@router.post("/chat/export-pdf")
async def export_chat_pdf(request: ExportPDFRequest, db: AsyncSession = Depends(get_db)):
"""Export selected chat sessions to PDF"""
from sqlalchemy import select, desc, asc
from datetime import datetime
if not request.session_ids:
raise HTTPException(status_code=400, detail="No sessions selected")
# Fetch logs for selected sessions
query = (
select(LLMUsageLog)
.where(LLMUsageLog.session_id.in_(request.session_ids))
.order_by(asc(LLMUsageLog.timestamp))
)
result = await db.execute(query)
logs = result.scalars().all()
if not logs:
raise HTTPException(status_code=404, detail="No logs found for selected sessions")
# Group logs by session
sessions = {}
for log in logs:
sid = log.session_id or "unknown"
if sid not in sessions:
sessions[sid] = {
"stock_code": log.stock_code or "Unknown",
"logs": []
}
sessions[sid]["logs"].append(log)
# Build HTML content
sections_html = ""
for session_id in request.session_ids:
if session_id not in sessions:
continue
session_data = sessions[session_id]
stock_code = session_data["stock_code"]
sections_html += f'''
<div class="session-section">
<h2>{stock_code}</h2>
<p class="session-id">Session ID: {session_id}</p>
'''
for log in session_data["logs"]:
timestamp = log.timestamp.strftime('%Y-%m-%d %H:%M:%S') if log.timestamp else ""
response_html = markdown.markdown(log.response or "", extensions=['tables', 'fenced_code'])
sections_html += f'''
<div class="message">
<div class="message-meta">
<span class="model">Model: {log.model}</span>
<span class="time">{timestamp}</span>
</div>
<div class="message-content">
{response_html}
</div>
</div>
'''
sections_html += '</div>'
# Complete HTML
complete_html = f'''
<!DOCTYPE html>
<html>
<head>
<meta charset="utf-8">
<title>AI Research Discussion Export</title>
<style>
@page {{
size: A4;
margin: 2cm 1.5cm;
}}
body {{
font-family: "PingFang SC", "Microsoft YaHei", "SimHei", sans-serif;
line-height: 1.6;
color: #333;
font-size: 10pt;
background-color: white;
}}
h1 {{
text-align: center;
color: #1a1a1a;
border-bottom: 2px solid #4a90e2;
padding-bottom: 10px;
margin-bottom: 30px;
}}
h2 {{
color: #2c3e50;
font-size: 14pt;
margin-top: 20px;
margin-bottom: 5px;
border-left: 4px solid #4a90e2;
padding-left: 10px;
}}
.session-id {{
color: #888;
font-size: 9pt;
margin-bottom: 15px;
}}
.session-section {{
margin-bottom: 30px;
page-break-inside: avoid;
}}
.message {{
margin: 15px 0;
padding: 12px;
background: #f9f9f9;
border-radius: 6px;
border-left: 3px solid #4a90e2;
}}
.message-meta {{
display: flex;
justify-content: space-between;
font-size: 9pt;
color: #666;
margin-bottom: 8px;
}}
.model {{
font-weight: bold;
}}
.message-content {{
font-size: 10pt;
}}
.message-content p {{
margin: 5px 0;
}}
.message-content ul, .message-content ol {{
margin: 5px 0;
padding-left: 20px;
}}
table {{
border-collapse: collapse;
width: 100%;
margin: 10px 0;
font-size: 9pt;
}}
th, td {{
border: 1px solid #ddd;
padding: 6px;
text-align: left;
}}
th {{
background-color: #f3f4f6;
}}
code {{
background-color: #f4f4f4;
padding: 1px 4px;
border-radius: 3px;
font-size: 9pt;
}}
pre {{
background-color: #f4f4f4;
padding: 10px;
border-radius: 5px;
overflow-x: auto;
font-size: 8pt;
}}
</style>
</head>
<body>
<h1>AI 研究讨论导出</h1>
<p style="text-align: center; color: #666; margin-bottom: 30px;">导出时间: {datetime.now().strftime('%Y-%m-%d %H:%M:%S')}</p>
{sections_html}
</body>
</html>
'''
# Generate PDF
try:
with tempfile.NamedTemporaryFile(delete=False, suffix='.pdf') as tmp_file:
pdf_path = tmp_file.name
HTML(string=complete_html).write_pdf(pdf_path)
filename = f"AI研究讨论_{datetime.now().strftime('%Y%m%d_%H%M%S')}.pdf"
filename_encoded = quote(filename)
return FileResponse(
path=pdf_path,
media_type='application/pdf',
headers={
'Content-Disposition': f"attachment; filename*=UTF-8''{filename_encoded}"
}
)
except Exception as e:
logger.error(f"Failed to generate PDF: {e}")
raise HTTPException(status_code=500, detail=f"Failed to generate PDF: {str(e)}")

View File

@ -1,8 +1,9 @@
import { useState, useEffect } from "react"
import { Card, CardContent } from "@/components/ui/card"
import { Badge } from "@/components/ui/badge"
import { Loader2, MessageSquare, Calendar, Search, Database } from "lucide-react"
import { Loader2, MessageSquare, Calendar, Search, Download, X, FileDown } from "lucide-react"
import { Input } from "@/components/ui/input"
import { Button } from "@/components/ui/button"
import ReactMarkdown from "react-markdown"
import remarkGfm from "remark-gfm"
@ -31,6 +32,11 @@ export function HistoryView() {
const [selectedSessionId, setSelectedSessionId] = useState<string | null>(null)
const [filterText, setFilterText] = useState("")
// 多选模式状态
const [isSelectMode, setIsSelectMode] = useState(false)
const [selectedForExport, setSelectedForExport] = useState<Set<string>>(new Set())
const [isExporting, setIsExporting] = useState(false)
useEffect(() => {
fetchHistory()
}, [])
@ -112,6 +118,55 @@ 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
const handleExportPDF = async () => {
if (selectedForExport.size === 0) return
setIsExporting(true)
try {
const response = await fetch("/api/chat/export-pdf", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ session_ids: Array.from(selectedForExport) })
})
if (!response.ok) {
throw new Error("导出失败")
}
const blob = await response.blob()
const url = window.URL.createObjectURL(blob)
const a = document.createElement("a")
a.href = url
a.download = `讨论历史_${new Date().toISOString().slice(0, 10)}.pdf`
document.body.appendChild(a)
a.click()
document.body.removeChild(a)
window.URL.revokeObjectURL(url)
// 导出成功后退出多选模式
setIsSelectMode(false)
setSelectedForExport(new Set())
} catch (error) {
console.error("导出 PDF 失败:", error)
alert("导出 PDF 失败,请稍后重试")
} finally {
setIsExporting(false)
}
}
if (loading) {
return <div className="flex justify-center items-center h-full"><Loader2 className="animate-spin" /></div>
@ -131,10 +186,46 @@ export function HistoryView() {
className="pl-8 h-9"
/>
</div>
<div className="h-9 w-9 flex items-center justify-center rounded-md border text-muted-foreground bg-muted/20" title="Elephant Placeholder">
<Database className="h-4 w-4" />
<button
className={`h-9 w-9 flex items-center justify-center rounded-md border transition-colors ${isSelectMode ? 'bg-primary text-primary-foreground' : 'text-muted-foreground bg-muted/20 hover:bg-muted/40'}`}
title="导出 PDF"
onClick={() => {
if (!isSelectMode) {
setIsSelectMode(true)
setSelectedForExport(new Set())
}
}}
>
<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" />
</Button>
<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
</Button>
</div>
)}
<div className="flex-1 overflow-y-auto pr-2 space-y-1 custom-scrollbar">
{groupedSessions
.filter(group =>
@ -144,18 +235,34 @@ export function HistoryView() {
<div
key={group.session_id}
className={`
cursor-pointer px-3 py-2 rounded-md text-sm transition-colors flex items-center justify-between
${selectedSessionId === group.session_id ? 'bg-secondary font-medium text-foreground' : 'text-muted-foreground hover:bg-muted/50 hover:text-foreground'}
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={() => setSelectedSessionId(group.session_id)}
onClick={() => {
if (isSelectMode) {
toggleSessionSelection(group.session_id)
} else {
setSelectedSessionId(group.session_id)
}
}}
>
<div className="flex items-center gap-2">
{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>
</div>
<span className="text-xs opacity-70 flex items-center gap-1">
<span className="text-xs opacity-70">
{formatDate(group.timestamp)}
</span>
</div>
</div>
))}
</div>
</div>
@ -216,8 +323,27 @@ export function HistoryView() {
)}
</div>
<div className="prose prose-sm dark:prose-invert max-w-none bg-card border p-4 rounded-lg">
<ReactMarkdown remarkPlugins={[remarkGfm]}>
{log.response}
<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>
}
}}
>
{preprocessMarkdown(log.response)}
</ReactMarkdown>
</div>
</div>
@ -239,6 +365,43 @@ export function HistoryView() {
)
}
/**
* Markdown
* **"text"**
*/
function preprocessMarkdown(text: string): string {
if (!text) return ""
// 修复 **"text"** 格式:将引号与星号之间的边界问题
// 使用零宽空格(\u200B来帮助解析器识别边界同时不影响粗体语法
let processed = text
// 零宽空格常量
const ZWS = '\u200B'
// 处理中文左引号 " (U+201C) 和右引号 " (U+201D)
processed = processed.replace(/\*\*\u201C/g, `**${ZWS}\u201C`)
processed = processed.replace(/\u201D\*\*/g, `\u201D${ZWS}**`)
// 处理中文单引号 ' (U+2018) 和 ' (U+2019)
processed = processed.replace(/\*\*\u2018/g, `**${ZWS}\u2018`)
processed = processed.replace(/\u2019\*\*/g, `\u2019${ZWS}**`)
// 处理英文直双引号 " (U+0022)
processed = processed.replace(/\*\*"/g, `**${ZWS}"`)
processed = processed.replace(/"\*\*/g, `"${ZWS}**`)
// 处理英文单引号 ' (U+0027)
processed = processed.replace(/\*\*'/g, `**${ZWS}'`)
processed = processed.replace(/'\*\*/g, `'${ZWS}**`)
// 处理中文方括号 【】
processed = processed.replace(/\*\*【/g, `**${ZWS}`)
processed = processed.replace(/】\*\*/g, `${ZWS}**`)
return processed
}
function parsePrompt(fullPrompt: string) {
if (!fullPrompt) return ""
// Remove system prompt if present using a safer regex

View File

@ -28,10 +28,11 @@ export function StockChart({ symbol, market }: StockChartProps) {
script.async = true
// Map Market/Symbol to TradingView format
console.log("[StockChart] Received market:", JSON.stringify(market), "symbol:", symbol)
let exchange = "NASDAQ"
let tvSymbol = symbol
if (market === "CN") {
if (market === "CH" || market === "CN") {
if (symbol.startsWith("6")) exchange = "SSE"
else if (symbol.startsWith("0") || symbol.startsWith("3")) exchange = "SZSE"
else if (symbol.startsWith("4") || symbol.startsWith("8")) exchange = "BSE"

6
package-lock.json generated
View File

@ -1,6 +0,0 @@
{
"name": "FA3-Datafetch",
"lockfileVersion": 3,
"requires": true,
"packages": {}
}