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 }) =>
    {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}
    +                                                            }
    +                                                        }}
    +                                                    >
    +                                                        {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": {} -}