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")
# Fetch logs for selected sessions
# Sort strictly by timestamp ASC to interleave sessions
query = (
select(LLMUsageLog)
.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:
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"]
# No grouping by session anymore; just a flat stream of messages
sections_html += f'''
<div class="session-section">
<h2>{stock_code}</h2>
<p class="session-id">Session ID: {session_id}</p>
messages_html = ""
for log in 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'])
# 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-meta">
<span class="context-tag">{stock_info}</span>
<span class="time">{timestamp}</span>
</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">
{response_html}
</div>
</div>
'''
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
# Updated font-family stack to include common Linux/Windows CJK fonts
complete_html = f'''
<!DOCTYPE html>
<html>
@ -284,7 +271,7 @@ async def export_chat_pdf(request: ExportPDFRequest, db: AsyncSession = Depends(
margin: 2cm 1.5cm;
}}
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;
color: #333;
font-size: 10pt;
@ -297,39 +284,37 @@ async def export_chat_pdf(request: ExportPDFRequest, db: AsyncSession = Depends(
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;
page-break-inside: avoid;
}}
.message-meta {{
display: flex;
justify-content: space-between;
font-size: 9pt;
color: #666;
margin-bottom: 8px;
align-items: center;
margin-bottom: 4px;
}}
.model {{
.context-tag {{
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 {{
font-size: 10pt;
@ -373,7 +358,7 @@ async def export_chat_pdf(request: ExportPDFRequest, db: AsyncSession = Depends(
<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}
{messages_html}
</body>
</html>
'''
@ -384,7 +369,7 @@ async def export_chat_pdf(request: ExportPDFRequest, db: AsyncSession = Depends(
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 = f"AI研究讨论汇总_{datetime.now().strftime('%Y%m%d_%H%M%S')}.pdf"
filename_encoded = quote(filename)
return FileResponse(

View File

@ -24,6 +24,10 @@ interface Message {
groundingChunks?: Array<{ web?: { uri: string; title: string } }>
webSearchQueries?: string[]
}
searchStatus?: {
status: "searching" | "completed" | "no_results"
message: string
}
}
interface RoleConfig {
@ -136,6 +140,15 @@ export function AiDiscussionView({ companyName, symbol, market }: { companyName:
const saveContent = () => {
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)
const l3 = currentL3 || currentL2;
@ -143,7 +156,7 @@ export function AiDiscussionView({ companyName, symbol, market }: { companyName:
if (!library[currentL1][currentL2]) library[currentL1][currentL2] = {};
// 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) {
library[currentL1][currentL2][l3] = content;
}
@ -195,6 +208,9 @@ export function AiDiscussionView({ companyName, symbol, market }: { companyName:
const historyWithBot = [...messages, botMsg]
setMessages(historyWithBot)
// Keep a reference to the current messages array
let currentMessages = historyWithBot
try {
const res = await fetch("/api/chat", {
method: "POST",
@ -216,11 +232,14 @@ export function AiDiscussionView({ companyName, symbol, market }: { companyName:
const decoder = new TextDecoder()
let done = false
let accumulatedContent = ""
let chunkCount = 0
while (!done) {
const { value, done: doneReading } = await reader.read()
done = doneReading
if (value) {
chunkCount++
console.log(`[Stream] Received chunk #${chunkCount}, size: ${value.length} bytes`)
const chunk = decoder.decode(value, { stream: true })
const lines = chunk.split('\n')
@ -228,18 +247,27 @@ export function AiDiscussionView({ companyName, symbol, market }: { companyName:
if (line.trim()) {
try {
const data = JSON.parse(line)
console.log(`[Stream] Parsed message type: ${data.type}`)
if (data.type === 'content') {
accumulatedContent += data.content
botMsg = { ...botMsg, content: accumulatedContent }
// Update last message
setMessages([...messages, botMsg])
// Update local reference and set state
currentMessages = [...currentMessages.slice(0, -1), botMsg]
setMessages(currentMessages)
} else if (data.type === 'metadata') {
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') {
console.error("Stream 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) {
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) {
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 {
setLoading(false)
}
@ -475,29 +504,38 @@ function ChatPane({
}`}>
{m.role === 'model' ? (
<div className="prose prose-sm dark:prose-invert max-w-none break-words">
<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 }) => {
const match = /language-(\w+)/.exec(className || '')
return <code className={`${className || ''} bg-black/5 dark:bg-white/10 rounded px-1`} {...props}>{children}</code>
}
}}
>
{m.content}
</ReactMarkdown>
{/* 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
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 }) => (
<code className={`${className || ''} bg-black/5 dark:bg-white/10 rounded px-1`} {...props}>{children}</code>
)
}}
>
{m.content}
</ReactMarkdown>
)}
{/* Grounding Metadata Display */}
{m.groundingMetadata && (