diff --git a/backend/app/api/chat_routes.py b/backend/app/api/chat_routes.py
index f350f8e..6fdb176 100644
--- a/backend/app/api/chat_routes.py
+++ b/backend/app/api/chat_routes.py
@@ -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'''
+
+
{stock_code}
+
Session ID: {session_id}
+ '''
+
+ 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'''
+
+
+ Model: {log.model}
+ {timestamp}
+
+
+ {response_html}
+
+
+ '''
+
+ sections_html += '
'
+
+ # Complete HTML
+ complete_html = f'''
+
+
+
+
+ AI Research Discussion Export
+
+
+
+ AI 研究讨论导出
+ 导出时间: {datetime.now().strftime('%Y-%m-%d %H:%M:%S')}
+ {sections_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)}")
diff --git a/frontend/src/components/history-view.tsx b/frontend/src/components/history-view.tsx
index 4f9eb80..f74c00d 100644
--- a/frontend/src/components/history-view.tsx
+++ b/frontend/src/components/history-view.tsx
@@ -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(null)
const [filterText, setFilterText] = useState("")
+ // 多选模式状态
+ const [isSelectMode, setIsSelectMode] = useState(false)
+ const [selectedForExport, setSelectedForExport] = useState>(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
@@ -131,10 +186,46 @@ export function HistoryView() {
className="pl-8 h-9"
/>
-
-
-
+
+ {/* 多选模式工具栏 */}
+ {isSelectMode && (
+
+
+ 已选择 {selectedForExport.size} 个会话
+
+
+
+
+ )}
{groupedSessions
.filter(group =>
@@ -144,17 +235,33 @@ export function HistoryView() {
setSelectedSessionId(group.session_id)}
+ onClick={() => {
+ 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)}
+
-
- {formatDate(group.timestamp)}
-
))}
@@ -216,8 +323,27 @@ export function HistoryView() {
)}
-
- {log.response}
+ {children}
,
+ strong: ({ node, children, ...props }) => {children},
+ em: ({ node, children, ...props }) => {children},
+ a: ({ node, href, children, ...props }) => {children},
+ ul: ({ node, children, ...props }) => ,
+ 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}
+ }
+ }}
+ >
+ {preprocessMarkdown(log.response)}
@@ -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
diff --git a/frontend/src/components/stock-chart.tsx b/frontend/src/components/stock-chart.tsx
index 28367e7..20377d7 100644
--- a/frontend/src/components/stock-chart.tsx
+++ b/frontend/src/components/stock-chart.tsx
@@ -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"
diff --git a/package-lock.json b/package-lock.json
deleted file mode 100644
index c289293..0000000
--- a/package-lock.json
+++ /dev/null
@@ -1,6 +0,0 @@
-{
- "name": "FA3-Datafetch",
- "lockfileVersion": 3,
- "requires": true,
- "packages": {}
-}