Compare commits

..

No commits in common. "03c1b6a4877691537e4ba92178046a26da4460e7" and "f2697149f791d3099314f266d711d896f70f98cb" have entirely different histories.

5 changed files with 21 additions and 437 deletions

View File

@ -1,14 +1,10 @@
from fastapi import APIRouter, HTTPException, Depends
from fastapi.responses import StreamingResponse, FileResponse
from fastapi.responses import StreamingResponse
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
@ -200,200 +196,3 @@ 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,9 +1,8 @@
import { useState, useEffect } from "react"
import { Card, CardContent } from "@/components/ui/card"
import { Badge } from "@/components/ui/badge"
import { Loader2, MessageSquare, Calendar, Search, Download, X, FileDown } from "lucide-react"
import { Loader2, MessageSquare, Calendar, Search, Database } 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"
@ -32,11 +31,6 @@ 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()
}, [])
@ -118,55 +112,6 @@ 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>
@ -186,46 +131,10 @@ export function HistoryView() {
className="pl-8 h-9"
/>
</div>
<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 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" />
</div>
)}
</div>
<div className="flex-1 overflow-y-auto pr-2 space-y-1 custom-scrollbar">
{groupedSessions
.filter(group =>
@ -235,33 +144,17 @@ export function HistoryView() {
<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'}
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'}
`}
onClick={() => {
if (isSelectMode) {
toggleSessionSelection(group.session_id)
} else {
setSelectedSessionId(group.session_id)
}
}}
onClick={() => 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">
<div className="flex items-center gap-2">
<span className="font-mono font-medium">{group.stock_code}</span>
<span className="text-xs opacity-70">
{formatDate(group.timestamp)}
</span>
</div>
<span className="text-xs opacity-70 flex items-center gap-1">
{formatDate(group.timestamp)}
</span>
</div>
))}
</div>
@ -323,27 +216,8 @@ 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]}
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 remarkPlugins={[remarkGfm]}>
{log.response}
</ReactMarkdown>
</div>
</div>
@ -365,43 +239,6 @@ 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,11 +28,10 @@ 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 === "CH" || market === "CN") {
if (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 Normal file
View File

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

View File

@ -1,57 +0,0 @@
#!/bin/bash
# FA3-Datafetch 自动更新脚本
# 用于在远程服务器上拉取最新代码并重新部署 Docker 容器
set -e
# 颜色定义
RED='\033[0;31m'
GREEN='\033[0;32m'
YELLOW='\033[1;33m'
NC='\033[0m' # No Color
echo -e "${GREEN}========================================${NC}"
echo -e "${GREEN} FA3-Datafetch 自动更新脚本${NC}"
echo -e "${GREEN}========================================${NC}"
# 切换到脚本所在目录
cd "$(dirname "$0")"
# 1. 拉取最新代码
echo -e "\n${YELLOW}[1/4] 拉取最新代码...${NC}"
git fetch origin
git pull origin main
echo -e "${GREEN}✓ 代码更新完成${NC}"
# 2. 停止旧容器(如果存在)
CONTAINER_NAME="fa3-app"
echo -e "\n${YELLOW}[2/4] 停止旧容器...${NC}"
if docker ps -a --format '{{.Names}}' | grep -q "^${CONTAINER_NAME}$"; then
docker stop $CONTAINER_NAME 2>/dev/null || true
docker rm $CONTAINER_NAME 2>/dev/null || true
echo -e "${GREEN}✓ 旧容器已停止并删除${NC}"
else
echo -e "${GREEN}✓ 没有运行中的旧容器${NC}"
fi
# 3. 重新构建 Docker 镜像
echo -e "\n${YELLOW}[3/4] 重新构建 Docker 镜像...${NC}"
echo -e " 这可能需要几分钟时间..."
docker build -t fa3-datafetch .
echo -e "${GREEN}✓ Docker 镜像构建完成${NC}"
# 4. 启动新容器
echo -e "\n${YELLOW}[4/4] 启动新容器...${NC}"
./docker-run.sh
echo -e "${GREEN}✓ 新容器已启动${NC}"
# 清理未使用的镜像(可选)
echo -e "\n${YELLOW}清理旧镜像...${NC}"
docker image prune -f
echo -e "${GREEN}✓ 清理完成${NC}"
echo -e "\n${GREEN}========================================${NC}"
echo -e "${GREEN} 更新完成!${NC}"
echo -e "${GREEN}========================================${NC}"
echo -e "查看日志: docker logs -f $CONTAINER_NAME"
echo -e "进入容器: docker exec -it $CONTAINER_NAME sh"