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")
|
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>
|
timestamp = log.timestamp.strftime('%Y-%m-%d %H:%M:%S') if log.timestamp else ""
|
||||||
<p class="session-id">Session ID: {session_id}</p>
|
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
|
# 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(
|
||||||
|
|||||||
@ -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,29 +504,38 @@ 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">
|
||||||
<ReactMarkdown
|
{/* Search Status Display */}
|
||||||
remarkPlugins={[remarkGfm]}
|
{m.searchStatus && (
|
||||||
components={{
|
<div className={`mb-2 text-xs ${m.searchStatus.status === 'searching' ? 'animate-pulse' : ''}`}>
|
||||||
p: ({ node, children, ...props }) => <p className="mb-2 last:mb-0" {...props}>{children}</p>,
|
<span className="font-medium">{m.searchStatus.message}</span>
|
||||||
strong: ({ node, children, ...props }) => <strong className="font-bold text-foreground" {...props}>{children}</strong>,
|
</div>
|
||||||
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>,
|
{/* Content */}
|
||||||
ol: ({ node, children, ...props }) => <ol className="list-decimal list-inside mb-2" {...props}>{children}</ol>,
|
{m.content && (
|
||||||
li: ({ node, children, ...props }) => <li className="mb-1" {...props}>{children}</li>,
|
<ReactMarkdown
|
||||||
h1: ({ node, children, ...props }) => <h1 className="text-lg font-bold mb-2" {...props}>{children}</h1>,
|
remarkPlugins={[remarkGfm]}
|
||||||
h2: ({ node, children, ...props }) => <h2 className="text-base font-bold mb-2" {...props}>{children}</h2>,
|
components={{
|
||||||
h3: ({ node, children, ...props }) => <h3 className="text-sm font-bold mb-1" {...props}>{children}</h3>,
|
p: ({ node, children, ...props }) => <p className="mb-2 last:mb-0" {...props}>{children}</p>,
|
||||||
blockquote: ({ node, children, ...props }) => <blockquote className="border-l-4 border-muted-foreground/30 pl-3 italic text-muted-foreground my-2" {...props}>{children}</blockquote>,
|
strong: ({ node, children, ...props }) => <strong className="font-bold text-foreground" {...props}>{children}</strong>,
|
||||||
pre: ({ node, className, ...props }) => <pre className={`overflow-auto w-full my-2 bg-black/10 dark:bg-black/30 p-2 rounded ${className || ''}`} {...props} />,
|
em: ({ node, children, ...props }) => <em className="italic" {...props}>{children}</em>,
|
||||||
code: ({ node, className, children, ...props }) => {
|
a: ({ node, href, children, ...props }) => <a href={href} className="text-blue-500 hover:underline" target="_blank" rel="noopener noreferrer" {...props}>{children}</a>,
|
||||||
const match = /language-(\w+)/.exec(className || '')
|
ul: ({ node, children, ...props }) => <ul className="list-disc list-inside mb-2" {...props}>{children}</ul>,
|
||||||
return <code className={`${className || ''} bg-black/5 dark:bg-white/10 rounded px-1`} {...props}>{children}</code>
|
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>,
|
||||||
{m.content}
|
h3: ({ node, children, ...props }) => <h3 className="text-sm font-bold mb-1" {...props}>{children}</h3>,
|
||||||
</ReactMarkdown>
|
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 */}
|
{/* Grounding Metadata Display */}
|
||||||
{m.groundingMetadata && (
|
{m.groundingMetadata && (
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user