Compare commits
2 Commits
f2697149f7
...
03c1b6a487
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
03c1b6a487 | ||
|
|
383beb3e27 |
@ -1,10 +1,14 @@
|
|||||||
from fastapi import APIRouter, HTTPException, Depends
|
from fastapi import APIRouter, HTTPException, Depends
|
||||||
from fastapi.responses import StreamingResponse
|
from fastapi.responses import StreamingResponse, FileResponse
|
||||||
import json
|
import json
|
||||||
from pydantic import BaseModel
|
from pydantic import BaseModel
|
||||||
from typing import List, Optional
|
from typing import List, Optional
|
||||||
import logging
|
import logging
|
||||||
import time
|
import time
|
||||||
|
import tempfile
|
||||||
|
import markdown
|
||||||
|
from weasyprint import HTML
|
||||||
|
from urllib.parse import quote
|
||||||
from sqlalchemy.ext.asyncio import AsyncSession
|
from sqlalchemy.ext.asyncio import AsyncSession
|
||||||
from app.services.analysis_service import get_genai_client
|
from app.services.analysis_service import get_genai_client
|
||||||
from google.genai import types
|
from google.genai import types
|
||||||
@ -196,3 +200,200 @@ async def get_chat_history(db: AsyncSession = Depends(get_db)):
|
|||||||
"total_tokens": log.total_tokens,
|
"total_tokens": log.total_tokens,
|
||||||
"used_google_search": log.used_google_search
|
"used_google_search": log.used_google_search
|
||||||
} for log in logs]
|
} 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)}")
|
||||||
|
|||||||
@ -1,8 +1,9 @@
|
|||||||
import { useState, useEffect } from "react"
|
import { useState, useEffect } from "react"
|
||||||
import { Card, CardContent } from "@/components/ui/card"
|
import { Card, CardContent } from "@/components/ui/card"
|
||||||
import { Badge } from "@/components/ui/badge"
|
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 { Input } from "@/components/ui/input"
|
||||||
|
import { Button } from "@/components/ui/button"
|
||||||
import ReactMarkdown from "react-markdown"
|
import ReactMarkdown from "react-markdown"
|
||||||
import remarkGfm from "remark-gfm"
|
import remarkGfm from "remark-gfm"
|
||||||
|
|
||||||
@ -31,6 +32,11 @@ export function HistoryView() {
|
|||||||
const [selectedSessionId, setSelectedSessionId] = useState<string | null>(null)
|
const [selectedSessionId, setSelectedSessionId] = useState<string | null>(null)
|
||||||
const [filterText, setFilterText] = useState("")
|
const [filterText, setFilterText] = useState("")
|
||||||
|
|
||||||
|
// 多选模式状态
|
||||||
|
const [isSelectMode, setIsSelectMode] = useState(false)
|
||||||
|
const [selectedForExport, setSelectedForExport] = useState<Set<string>>(new Set())
|
||||||
|
const [isExporting, setIsExporting] = useState(false)
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
fetchHistory()
|
fetchHistory()
|
||||||
}, [])
|
}, [])
|
||||||
@ -112,6 +118,55 @@ export function HistoryView() {
|
|||||||
}).format(date)
|
}).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) {
|
if (loading) {
|
||||||
return <div className="flex justify-center items-center h-full"><Loader2 className="animate-spin" /></div>
|
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"
|
className="pl-8 h-9"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<div className="h-9 w-9 flex items-center justify-center rounded-md border text-muted-foreground bg-muted/20" title="Elephant Placeholder">
|
<button
|
||||||
<Database className="h-4 w-4" />
|
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>
|
</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>
|
||||||
|
)}
|
||||||
<div className="flex-1 overflow-y-auto pr-2 space-y-1 custom-scrollbar">
|
<div className="flex-1 overflow-y-auto pr-2 space-y-1 custom-scrollbar">
|
||||||
{groupedSessions
|
{groupedSessions
|
||||||
.filter(group =>
|
.filter(group =>
|
||||||
@ -144,18 +235,34 @@ export function HistoryView() {
|
|||||||
<div
|
<div
|
||||||
key={group.session_id}
|
key={group.session_id}
|
||||||
className={`
|
className={`
|
||||||
cursor-pointer px-3 py-2 rounded-md text-sm transition-colors flex items-center justify-between
|
cursor-pointer px-3 py-2 rounded-md text-sm transition-colors flex items-center gap-2
|
||||||
${selectedSessionId === group.session_id ? 'bg-secondary font-medium text-foreground' : 'text-muted-foreground hover:bg-muted/50 hover:text-foreground'}
|
${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>
|
<span className="font-mono font-medium">{group.stock_code}</span>
|
||||||
</div>
|
<span className="text-xs opacity-70">
|
||||||
<span className="text-xs opacity-70 flex items-center gap-1">
|
|
||||||
{formatDate(group.timestamp)}
|
{formatDate(group.timestamp)}
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
|
</div>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@ -216,8 +323,27 @@ export function HistoryView() {
|
|||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
<div className="prose prose-sm dark:prose-invert max-w-none bg-card border p-4 rounded-lg">
|
<div className="prose prose-sm dark:prose-invert max-w-none bg-card border p-4 rounded-lg">
|
||||||
<ReactMarkdown remarkPlugins={[remarkGfm]}>
|
<ReactMarkdown
|
||||||
{log.response}
|
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>
|
</ReactMarkdown>
|
||||||
</div>
|
</div>
|
||||||
</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) {
|
function parsePrompt(fullPrompt: string) {
|
||||||
if (!fullPrompt) return ""
|
if (!fullPrompt) return ""
|
||||||
// Remove system prompt if present using a safer regex
|
// Remove system prompt if present using a safer regex
|
||||||
|
|||||||
@ -28,10 +28,11 @@ export function StockChart({ symbol, market }: StockChartProps) {
|
|||||||
script.async = true
|
script.async = true
|
||||||
|
|
||||||
// Map Market/Symbol to TradingView format
|
// Map Market/Symbol to TradingView format
|
||||||
|
console.log("[StockChart] Received market:", JSON.stringify(market), "symbol:", symbol)
|
||||||
let exchange = "NASDAQ"
|
let exchange = "NASDAQ"
|
||||||
let tvSymbol = symbol
|
let tvSymbol = symbol
|
||||||
|
|
||||||
if (market === "CN") {
|
if (market === "CH" || market === "CN") {
|
||||||
if (symbol.startsWith("6")) exchange = "SSE"
|
if (symbol.startsWith("6")) exchange = "SSE"
|
||||||
else if (symbol.startsWith("0") || symbol.startsWith("3")) exchange = "SZSE"
|
else if (symbol.startsWith("0") || symbol.startsWith("3")) exchange = "SZSE"
|
||||||
else if (symbol.startsWith("4") || symbol.startsWith("8")) exchange = "BSE"
|
else if (symbol.startsWith("4") || symbol.startsWith("8")) exchange = "BSE"
|
||||||
|
|||||||
6
package-lock.json
generated
6
package-lock.json
generated
@ -1,6 +0,0 @@
|
|||||||
{
|
|
||||||
"name": "FA3-Datafetch",
|
|
||||||
"lockfileVersion": 3,
|
|
||||||
"requires": true,
|
|
||||||
"packages": {}
|
|
||||||
}
|
|
||||||
57
update-and-run.sh
Executable file
57
update-and-run.sh
Executable file
@ -0,0 +1,57 @@
|
|||||||
|
#!/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"
|
||||||
Loading…
Reference in New Issue
Block a user