feat: Refactor PDF export to display interleaved messages with session context and add real-time search status updates to the frontend.

This commit is contained in:
xucheng 2026-01-19 14:49:23 +08:00
parent 8c53b318da
commit f471813b95
3 changed files with 121 additions and 91 deletions

View File

@ -0,0 +1,7 @@
{
"permissions": {
"allow": [
"mcp__zai-web-reader__webReader"
]
}
}

View File

@ -216,6 +216,7 @@ async def export_chat_pdf(request: ExportPDFRequest, db: AsyncSession = Depends(
raise HTTPException(status_code=400, detail="No sessions selected") raise HTTPException(status_code=400, detail="No sessions selected")
# Fetch logs for selected sessions # Fetch logs for selected sessions
# Sort strictly by timestamp ASC to interleave sessions
query = ( query = (
select(LLMUsageLog) select(LLMUsageLog)
.where(LLMUsageLog.session_id.in_(request.session_ids)) .where(LLMUsageLog.session_id.in_(request.session_ids))
@ -228,50 +229,36 @@ async def export_chat_pdf(request: ExportPDFRequest, db: AsyncSession = Depends(
if not logs: if not logs:
raise HTTPException(status_code=404, detail="No logs found for selected sessions") 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 # Build HTML content
sections_html = "" # No grouping by session anymore; just a flat stream of messages
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''' messages_html = ""
<div class="session-section"> for log in logs:
<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 "" 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']) response_html = markdown.markdown(log.response or "", extensions=['tables', 'fenced_code'])
sections_html += f''' # Add context info (Stock Code / Session) to the header since they are mixed
stock_info = log.stock_code or "Unknown Stock"
session_info = log.session_id or "Unknown Session"
messages_html += f'''
<div class="message"> <div class="message">
<div class="message-meta"> <div class="message-meta">
<span class="model">Model: {log.model}</span> <span class="context-tag">{stock_info}</span>
<span class="time">{timestamp}</span> <span class="time">{timestamp}</span>
</div> </div>
<div class="message-sub-meta">
<span class="session-id">Session: {session_info}</span>
<span class="model">Model: {log.model}</span>
</div>
<div class="message-content"> <div class="message-content">
{response_html} {response_html}
</div> </div>
</div> </div>
''' '''
sections_html += '</div>'
# Complete HTML # Complete HTML
# Updated font-family stack to include common Linux/Windows CJK fonts
complete_html = f''' complete_html = f'''
<!DOCTYPE html> <!DOCTYPE html>
<html> <html>
@ -284,7 +271,7 @@ async def export_chat_pdf(request: ExportPDFRequest, db: AsyncSession = Depends(
margin: 2cm 1.5cm; margin: 2cm 1.5cm;
}} }}
body {{ body {{
font-family: "PingFang SC", "Microsoft YaHei", "SimHei", sans-serif; font-family: "Noto Sans CJK SC", "Noto Sans SC", "PingFang SC", "Microsoft YaHei", "SimHei", "WenQuanYi Micro Hei", sans-serif;
line-height: 1.6; line-height: 1.6;
color: #333; color: #333;
font-size: 10pt; font-size: 10pt;
@ -297,39 +284,37 @@ async def export_chat_pdf(request: ExportPDFRequest, db: AsyncSession = Depends(
padding-bottom: 10px; padding-bottom: 10px;
margin-bottom: 30px; 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 {{ .message {{
margin: 15px 0; margin: 15px 0;
padding: 12px; padding: 12px;
background: #f9f9f9; background: #f9f9f9;
border-radius: 6px; border-radius: 6px;
border-left: 3px solid #4a90e2; border-left: 3px solid #4a90e2;
page-break-inside: avoid;
}} }}
.message-meta {{ .message-meta {{
display: flex; display: flex;
justify-content: space-between; justify-content: space-between;
font-size: 9pt; align-items: center;
color: #666; margin-bottom: 4px;
margin-bottom: 8px;
}} }}
.model {{ .context-tag {{
font-weight: bold; font-weight: bold;
color: #2c3e50;
font-size: 11pt;
}}
.time {{
color: #666;
font-size: 9pt;
}}
.message-sub-meta {{
display: flex;
gap: 15px;
font-size: 8pt;
color: #888;
margin-bottom: 8px;
border-bottom: 1px solid #eee;
padding-bottom: 4px;
}} }}
.message-content {{ .message-content {{
font-size: 10pt; font-size: 10pt;
@ -373,7 +358,7 @@ async def export_chat_pdf(request: ExportPDFRequest, db: AsyncSession = Depends(
<body> <body>
<h1>AI 研究讨论导出</h1> <h1>AI 研究讨论导出</h1>
<p style="text-align: center; color: #666; margin-bottom: 30px;">导出时间: {datetime.now().strftime('%Y-%m-%d %H:%M:%S')}</p> <p style="text-align: center; color: #666; margin-bottom: 30px;">导出时间: {datetime.now().strftime('%Y-%m-%d %H:%M:%S')}</p>
{sections_html} {messages_html}
</body> </body>
</html> </html>
''' '''
@ -384,7 +369,7 @@ async def export_chat_pdf(request: ExportPDFRequest, db: AsyncSession = Depends(
pdf_path = tmp_file.name pdf_path = tmp_file.name
HTML(string=complete_html).write_pdf(pdf_path) HTML(string=complete_html).write_pdf(pdf_path)
filename = f"AI研究讨论_{datetime.now().strftime('%Y%m%d_%H%M%S')}.pdf" filename = f"AI研究讨论汇总_{datetime.now().strftime('%Y%m%d_%H%M%S')}.pdf"
filename_encoded = quote(filename) filename_encoded = quote(filename)
return FileResponse( return FileResponse(

View File

@ -24,6 +24,10 @@ interface Message {
groundingChunks?: Array<{ web?: { uri: string; title: string } }> groundingChunks?: Array<{ web?: { uri: string; title: string } }>
webSearchQueries?: string[] webSearchQueries?: string[]
} }
searchStatus?: {
status: "searching" | "completed" | "no_results"
message: string
}
} }
interface RoleConfig { interface RoleConfig {
@ -136,6 +140,15 @@ export function AiDiscussionView({ companyName, symbol, market }: { companyName:
const saveContent = () => { const saveContent = () => {
if (currentL1 && currentL2) { if (currentL1 && currentL2) {
const contentStr = currentContent.join('\n').trim();
// If this is an L2 intro block (no L3) and it has no content, skip it
// This prevents the L2 title from appearing as a question in the L3 dropdown
if (!currentL3 && !contentStr) {
currentContent = [];
return;
}
// If L3 is missing, use L2 as the question title (support 2-level structure) // If L3 is missing, use L2 as the question title (support 2-level structure)
const l3 = currentL3 || currentL2; const l3 = currentL3 || currentL2;
@ -143,7 +156,7 @@ export function AiDiscussionView({ companyName, symbol, market }: { companyName:
if (!library[currentL1][currentL2]) library[currentL1][currentL2] = {}; if (!library[currentL1][currentL2]) library[currentL1][currentL2] = {};
// If content is empty, use the question title as content // If content is empty, use the question title as content
const content = currentContent.length > 0 ? currentContent.join('\n').trim() : l3; const content = contentStr || l3;
if (content && l3) { if (content && l3) {
library[currentL1][currentL2][l3] = content; library[currentL1][currentL2][l3] = content;
} }
@ -195,6 +208,9 @@ export function AiDiscussionView({ companyName, symbol, market }: { companyName:
const historyWithBot = [...messages, botMsg] const historyWithBot = [...messages, botMsg]
setMessages(historyWithBot) setMessages(historyWithBot)
// Keep a reference to the current messages array
let currentMessages = historyWithBot
try { try {
const res = await fetch("/api/chat", { const res = await fetch("/api/chat", {
method: "POST", method: "POST",
@ -216,11 +232,14 @@ export function AiDiscussionView({ companyName, symbol, market }: { companyName:
const decoder = new TextDecoder() const decoder = new TextDecoder()
let done = false let done = false
let accumulatedContent = "" let accumulatedContent = ""
let chunkCount = 0
while (!done) { while (!done) {
const { value, done: doneReading } = await reader.read() const { value, done: doneReading } = await reader.read()
done = doneReading done = doneReading
if (value) { if (value) {
chunkCount++
console.log(`[Stream] Received chunk #${chunkCount}, size: ${value.length} bytes`)
const chunk = decoder.decode(value, { stream: true }) const chunk = decoder.decode(value, { stream: true })
const lines = chunk.split('\n') const lines = chunk.split('\n')
@ -228,18 +247,27 @@ export function AiDiscussionView({ companyName, symbol, market }: { companyName:
if (line.trim()) { if (line.trim()) {
try { try {
const data = JSON.parse(line) const data = JSON.parse(line)
console.log(`[Stream] Parsed message type: ${data.type}`)
if (data.type === 'content') { if (data.type === 'content') {
accumulatedContent += data.content accumulatedContent += data.content
botMsg = { ...botMsg, content: accumulatedContent } botMsg = { ...botMsg, content: accumulatedContent }
// Update last message // Update local reference and set state
setMessages([...messages, botMsg]) currentMessages = [...currentMessages.slice(0, -1), botMsg]
setMessages(currentMessages)
} else if (data.type === 'metadata') { } else if (data.type === 'metadata') {
botMsg = { ...botMsg, groundingMetadata: data.groundingMetadata } botMsg = { ...botMsg, groundingMetadata: data.groundingMetadata }
setMessages([...messages, botMsg]) currentMessages = [...currentMessages.slice(0, -1), botMsg]
setMessages(currentMessages)
} else if (data.type === 'search_status') {
// Update search status without changing content
botMsg = { ...botMsg, searchStatus: { status: data.status, message: data.message } }
currentMessages = [...currentMessages.slice(0, -1), botMsg]
setMessages(currentMessages)
} else if (data.type === 'error') { } else if (data.type === 'error') {
console.error("Stream error:", data.error) console.error("Stream error:", data.error)
botMsg = { ...botMsg, content: botMsg.content + "\n[Error: " + data.error + "]" } botMsg = { ...botMsg, content: botMsg.content + "\n[Error: " + data.error + "]" }
setMessages([...messages, botMsg]) currentMessages = [...currentMessages.slice(0, -1), botMsg]
setMessages(currentMessages)
} }
} catch (e) { } catch (e) {
console.warn("Failed to parse chunk", line, e) console.warn("Failed to parse chunk", line, e)
@ -248,9 +276,10 @@ export function AiDiscussionView({ companyName, symbol, market }: { companyName:
} }
} }
} }
console.log(`[Stream] Total chunks received: ${chunkCount}`)
} catch (e) { } catch (e) {
console.error(e) console.error(e)
setMessages([...messages, { role: "model", content: "Error: Failed to get response." }]) setMessages([...currentMessages.slice(0, -1), { role: "model", content: "Error: Failed to get response." }])
} finally { } finally {
setLoading(false) setLoading(false)
} }
@ -475,6 +504,15 @@ function ChatPane({
}`}> }`}>
{m.role === 'model' ? ( {m.role === 'model' ? (
<div className="prose prose-sm dark:prose-invert max-w-none break-words"> <div className="prose prose-sm dark:prose-invert max-w-none break-words">
{/* Search Status Display */}
{m.searchStatus && (
<div className={`mb-2 text-xs ${m.searchStatus.status === 'searching' ? 'animate-pulse' : ''}`}>
<span className="font-medium">{m.searchStatus.message}</span>
</div>
)}
{/* Content */}
{m.content && (
<ReactMarkdown <ReactMarkdown
remarkPlugins={[remarkGfm]} remarkPlugins={[remarkGfm]}
components={{ components={{
@ -490,14 +528,14 @@ function ChatPane({
h3: ({ node, children, ...props }) => <h3 className="text-sm font-bold mb-1" {...props}>{children}</h3>, 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>, 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} />, 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 }) => { code: ({ node, className, children, ...props }) => (
const match = /language-(\w+)/.exec(className || '') <code className={`${className || ''} bg-black/5 dark:bg-white/10 rounded px-1`} {...props}>{children}</code>
return <code className={`${className || ''} bg-black/5 dark:bg-white/10 rounded px-1`} {...props}>{children}</code> )
}
}} }}
> >
{m.content} {m.content}
</ReactMarkdown> </ReactMarkdown>
)}
{/* Grounding Metadata Display */} {/* Grounding Metadata Display */}
{m.groundingMetadata && ( {m.groundingMetadata && (