From f471813b95effca157d0fb30532365bf6c32e070 Mon Sep 17 00:00:00 2001 From: xucheng Date: Mon, 19 Jan 2026 14:49:23 +0800 Subject: [PATCH] feat: Refactor PDF export to display interleaved messages with session context and add real-time search status updates to the frontend. --- .claude/settings.local.json | 7 ++ backend/app/api/chat_routes.py | 109 ++++++++---------- .../src/components/ai-discussion-view.tsx | 96 ++++++++++----- 3 files changed, 121 insertions(+), 91 deletions(-) create mode 100644 .claude/settings.local.json diff --git a/.claude/settings.local.json b/.claude/settings.local.json new file mode 100644 index 0000000..eca6f89 --- /dev/null +++ b/.claude/settings.local.json @@ -0,0 +1,7 @@ +{ + "permissions": { + "allow": [ + "mcp__zai-web-reader__webReader" + ] + } +} diff --git a/backend/app/api/chat_routes.py b/backend/app/api/chat_routes.py index 6fdb176..c408a22 100644 --- a/backend/app/api/chat_routes.py +++ b/backend/app/api/chat_routes.py @@ -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''' -
-

{stock_code}

-

Session ID: {session_id}

- ''' + # 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''' -
-
- Model: {log.model} - {timestamp} -
-
- {response_html} -
+ messages_html += f''' +
+
+ {stock_info} + {timestamp}
- ''' - - sections_html += '
' +
+ Session: {session_info} + Model: {log.model} +
+
+ {response_html} +
+
+ ''' # Complete HTML + # Updated font-family stack to include common Linux/Windows CJK fonts complete_html = f''' @@ -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(

AI 研究讨论导出

导出时间: {datetime.now().strftime('%Y-%m-%d %H:%M:%S')}

- {sections_html} + {messages_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( diff --git a/frontend/src/components/ai-discussion-view.tsx b/frontend/src/components/ai-discussion-view.tsx index fd36b26..b75c540 100644 --- a/frontend/src/components/ai-discussion-view.tsx +++ b/frontend/src/components/ai-discussion-view.tsx @@ -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' ? (
-

{children}

, - strong: ({ node, children, ...props }) => {children}, - em: ({ node, children, ...props }) => {children}, - a: ({ node, href, children, ...props }) => {children}, - ul: ({ node, children, ...props }) =>
    {children}
, - ol: ({ node, children, ...props }) =>
    {children}
, - li: ({ node, children, ...props }) =>
  • {children}
  • , - h1: ({ node, children, ...props }) =>

    {children}

    , - h2: ({ node, children, ...props }) =>

    {children}

    , - h3: ({ node, children, ...props }) =>

    {children}

    , - blockquote: ({ node, children, ...props }) =>
    {children}
    , - pre: ({ node, className, ...props }) =>
    ,
    -                                                code: ({ node, className, children, ...props }) => {
    -                                                    const match = /language-(\w+)/.exec(className || '')
    -                                                    return {children}
    -                                                }
    -                                            }}
    -                                        >
    -                                            {m.content}
    -                                        
    +                                        {/* Search Status Display */}
    +                                        {m.searchStatus && (
    +                                            
    + {m.searchStatus.message} +
    + )} + + {/* Content */} + {m.content && ( +

    {children}

    , + strong: ({ node, children, ...props }) => {children}, + em: ({ node, children, ...props }) => {children}, + a: ({ node, href, children, ...props }) => {children}, + ul: ({ node, children, ...props }) =>
      {children}
    , + ol: ({ node, children, ...props }) =>
      {children}
    , + li: ({ node, children, ...props }) =>
  • {children}
  • , + h1: ({ node, children, ...props }) =>

    {children}

    , + h2: ({ node, children, ...props }) =>

    {children}

    , + h3: ({ node, children, ...props }) =>

    {children}

    , + blockquote: ({ node, children, ...props }) =>
    {children}
    , + pre: ({ node, className, ...props }) =>
    ,
    +                                                    code: ({ node, className, children, ...props }) => (
    +                                                        {children}
    +                                                    )
    +                                                }}
    +                                            >
    +                                                {m.content}
    +                                            
    +                                        )}
     
                                             {/* Grounding Metadata Display */}
                                             {m.groundingMetadata && (