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:
parent
8c53b318da
commit
f471813b95
7
.claude/settings.local.json
Normal file
7
.claude/settings.local.json
Normal file
@ -0,0 +1,7 @@
|
||||
{
|
||||
"permissions": {
|
||||
"allow": [
|
||||
"mcp__zai-web-reader__webReader"
|
||||
]
|
||||
}
|
||||
}
|
||||
@ -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
|
||||
|
||||
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'])
|
||||
|
||||
sections_html += f'''
|
||||
<div class="session-section">
|
||||
<h2>{stock_code}</h2>
|
||||
<p class="session-id">Session ID: {session_id}</p>
|
||||
'''
|
||||
# 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"
|
||||
|
||||
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>
|
||||
messages_html += f'''
|
||||
<div class="message">
|
||||
<div class="message-meta">
|
||||
<span class="context-tag">{stock_info}</span>
|
||||
<span class="time">{timestamp}</span>
|
||||
</div>
|
||||
'''
|
||||
|
||||
sections_html += '</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>
|
||||
'''
|
||||
|
||||
# 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(
|
||||
|
||||
@ -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 && (
|
||||
|
||||
Loading…
Reference in New Issue
Block a user