From 1702819b3398e4e6cb20f5a19eb909b0fe23ba73 Mon Sep 17 00:00:00 2001 From: Zack Date: Sat, 30 May 2026 21:17:55 +0800 Subject: [PATCH] =?UTF-8?q?refactor(chat):=20=E4=BC=98=E5=8C=96=E6=B6=88?= =?UTF-8?q?=E6=81=AF=E6=8C=81=E4=B9=85=E5=8C=96=E5=92=8C=E5=AF=8C=E6=96=87?= =?UTF-8?q?=E6=9C=AC=E6=B8=B2=E6=9F=93=E9=80=BB=E8=BE=91?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 调整后台消息持久化异步任务的指数退避时间,缩短至累计3.1秒,避免总耗时过长 - 增加持久化任务超时处理,避免事务锁定导致前端发送按钮卡死 - 修改 SSE 生成器取消逻辑,确保保存操作在后台继续完成 - 重构聊天消息中标签解析,支持多段思考内容合并及未闭合标签剔除 - 在聊天消息和打字机组件中新增表格支持,提供横向滚动和单行显示样式 - 替换状态图标组件中加载动画,使用旋转Loader图标提升视觉反馈体验 - 删除重复的表格边距和样式定义,优化CSS类应用逻辑 --- backend/services/chat_generation.py | 20 +++-- .../ai-assistant/CallTimelinePanel.tsx | 6 +- .../components/ai-assistant/ChatMessage.tsx | 89 +++++++++++++------ .../ai-assistant/TypewriterText.tsx | 33 ++++++- 4 files changed, 112 insertions(+), 36 deletions(-) diff --git a/backend/services/chat_generation.py b/backend/services/chat_generation.py index 2c297b08..e6199b4f 100644 --- a/backend/services/chat_generation.py +++ b/backend/services/chat_generation.py @@ -600,8 +600,10 @@ async def _persist_message_and_billing(): attempt + 1, _MAX_MSG_RETRIES, exc) if fatal: return - # 指数退避:0.5s, 1s, 2s, 4s, 8s - await asyncio.sleep(0.5 * (2 ** attempt)) + # 指数退避(0.1s, 0.2s, 0.4s, 0.8s, 1.6s累计 3.1s)。 + # 与 SQLite busy_timeout=30s 互补:锁父进程较长时依靠 SQLite 自身超时, + # 这里仅处理瞬态并发冲突,避免后台 task 总耗时过长。 + await asyncio.sleep(0.1 * (2 ** attempt)) # -------- Phase 2: 统计 + 扣费 + compaction(独立事务,失败可容忍) -------- try: @@ -672,9 +674,18 @@ async def _persist_message_and_billing(): # Phase 2 失败不影响消息可见性,仅记录告警 logger.warning(f"Background stats/billing save failed (message already persisted): {e}") - # 启动后台保存任务,使用 asyncio.shield 保护任务不被请求取消影响 + # 启动后台保存 task,限时等待以获取 billing/compaction/title 返填; + # 超时后仍以当前 billing_event 继续发送 SSE,task 在后台继续运行。 + # 这避免 SQLite 临时锁定时重试退避(最多 3.1s)叠加事务超时导致 SSE done 事件长时间不发送, + # 从而使前端发送按钮卡在「暂停」样式。 + persist_task = asyncio.create_task(_persist_message_and_billing()) try: - await asyncio.shield(_persist_message_and_billing()) + await asyncio.wait_for(asyncio.shield(persist_task), timeout=5.0) + except asyncio.TimeoutError: + logger.warning( + "Persistence taking >5s (likely SQLite lock); proceeding with current billing state, " + "background task will continue retrying." + ) except asyncio.CancelledError: # generator 被取消,但 shield 内的保存操作仍在运行 logger.warning("SSE generator cancelled during save, persistence continues in background") @@ -720,4 +731,3 @@ async def _persist_message_and_billing(): logger.error(f"Image canvas bridge failed: {e}") yield sse("done", {}) - yield sse("done", {}) diff --git a/frontend/src/components/ai-assistant/CallTimelinePanel.tsx b/frontend/src/components/ai-assistant/CallTimelinePanel.tsx index b9d8327f..b9689c5d 100644 --- a/frontend/src/components/ai-assistant/CallTimelinePanel.tsx +++ b/frontend/src/components/ai-assistant/CallTimelinePanel.tsx @@ -3,7 +3,7 @@ import React, { useState, useMemo } from 'react'; import { CheckCircle2, - CircleDotDashed, + Loader2, Circle, AlertCircle, ChevronDown, @@ -127,8 +127,10 @@ const STATUS_STYLE: Record = { function StatusIcon({ resolved, size = 'sm' }: { resolved: ResolvedStatus; size?: 'sm' | 'xs' }) { const cls = size === 'sm' ? 'h-4 w-4' : 'h-3.5 w-3.5'; const style = STATUS_STYLE[resolved]; + // active 状态使用 Loader2 + animate-spin,提供明确的 loading 进行中反馈; + // 完成后切换为 CheckCircle2,使用者一眼识别状态转换。 const map: Record = { - active: , + active: , success: , error: , pending: , diff --git a/frontend/src/components/ai-assistant/ChatMessage.tsx b/frontend/src/components/ai-assistant/ChatMessage.tsx index 762cc00e..a6a1642b 100644 --- a/frontend/src/components/ai-assistant/ChatMessage.tsx +++ b/frontend/src/components/ai-assistant/ChatMessage.tsx @@ -63,39 +63,46 @@ function parseAttachments(content: string) { // --------------------------------------------------------------------------- // Think content parsing - 解析 ... 标记 // --------------------------------------------------------------------------- -const THINK_TAG_RE = /([\s\S]*?)(?:<\/think>|$)/; +// 说明:在同一轮 AI 回复中,复杂任务可能出现多次思考(例如工具调用后二次推理), +// 需要使用全局标志提取所有思考段落,然后合并到一个面板, +// 同时从正文中剔除所有已匹配的 ... 块,避免未匹配的残留标签被当作普通文本渲染。 +const THINK_TAG_RE = /([\s\S]*?)(?:<\/think>|$)/g; interface ParsedThinkContent { - thinkingContent: string; // 思考内容 + thinkingContent: string; // 思考内容(多段合并后) responseContent: string; // 正式回复内容 - isThinkingComplete: boolean; // 思考是否完成 + isThinkingComplete: boolean; // 思考是否全部闭合完成 } function parseThinkContent(content: string): ParsedThinkContent { - const match = THINK_TAG_RE.exec(content); - - // 没有 标记,所有内容都是正式回复 - const noThinkTag = !match; - if (noThinkTag) { - return { - thinkingContent: '', - responseContent: content, - isThinkingComplete: true, - }; + // 未出现任何 标签:全部为正文 + if (!content.includes('')) { + return { thinkingContent: '', responseContent: content, isThinkingComplete: true }; } - const thinkingContent = match[1] || ''; - const hasClosingTag = content.includes(''); - - // 提取 后的内容作为正式回复 - const responseContent = hasClosingTag - ? content.split('').slice(1).join('').trim() - : ''; + // 提取所有 ... 段落(含未闭合的尾部) + const thinkingSegments: string[] = []; + const re = new RegExp(THINK_TAG_RE.source, 'g'); + let hasUnclosed = false; + for (let m = re.exec(content); m !== null; m = re.exec(content)) { + const matched = content.slice(m.index, re.lastIndex); + const closed = matched.endsWith(''); + thinkingSegments.push((m[1] || '').trim()); + closed || (hasUnclosed = true); + // 未闭合意味着流式在进行中,已到达字符串末尾,后续不会再匹配。 + // 防护:如果 lastIndex 未推进造成死循环,手动 +1。 + closed || (m.index === re.lastIndex && re.lastIndex++); + } + + // 从原串中剔除所有已闭合的 ... 块得到正文; + // 未闭合的尾部(流式中)从起点到文末一并剔除。 + let stripped = content.replace(/[\s\S]*?<\/think>/g, ''); + hasUnclosed && (stripped = stripped.replace(/[\s\S]*$/, '')); return { - thinkingContent: thinkingContent.trim(), - responseContent, - isThinkingComplete: hasClosingTag, + thinkingContent: thinkingSegments.filter(Boolean).join('\n\n---\n\n'), + responseContent: stripped.trim(), + isThinkingComplete: !hasUnclosed, }; } @@ -210,6 +217,36 @@ const createMarkdownComponents = (isStreaming: boolean) => ({ ); }, pre: ({ children }: React.HTMLAttributes) => <>{children}, + // 表格:外包 overflow-x-auto 容器,让表格在气泡宽度不够时横向滚动而不被挤压。 + // th/td 加 whitespace-nowrap 使单元格内容尽可能一行显示。 + table: ({ children, ...props }: React.HTMLAttributes) => ( +
+ + {children} +
+
+ ), + thead: ({ children, ...props }: React.HTMLAttributes) => ( + + {children} + + ), + th: ({ children, ...props }: React.HTMLAttributes) => ( + + {children} + + ), + td: ({ children, ...props }: React.HTMLAttributes) => ( + + {children} + + ), // 使用懒加载图片组件 img: ({ src, alt, ...props }: React.ImgHTMLAttributes) => { const srcString = typeof src === 'string' ? src : ''; @@ -517,8 +554,7 @@ export function ChatMessage({ message, className, onRetry }: ChatMessageProps) { [&_hr]:my-4 [&_hr]:border-border/50 [&_blockquote]:my-3 [&_blockquote]:py-1 [&_blockquote]:px-3 [&_blockquote]:border-l-2 [&_blockquote]:border-primary/30 [&_blockquote]:bg-muted/30 [&_blockquote]:rounded-r [&_pre]:my-3 - [&_ul]:my-2 [&_ol]:my-2 - [&_table]:my-3 [&_th]:px-3 [&_th]:py-2 [&_td]:px-3 [&_td]:py-2 [&_thead]:bg-muted/50"> + [&_ul]:my-2 [&_ol]:my-2"> {chunk} @@ -537,8 +573,7 @@ export function ChatMessage({ message, className, onRetry }: ChatMessageProps) { [&_hr]:my-4 [&_hr]:border-border/50 [&_blockquote]:my-3 [&_blockquote]:py-1 [&_blockquote]:px-3 [&_blockquote]:border-l-2 [&_blockquote]:border-primary/30 [&_blockquote]:bg-muted/30 [&_blockquote]:rounded-r [&_pre]:my-3 - [&_ul]:my-2 [&_ol]:my-2 - [&_table]:my-3 [&_th]:px-3 [&_th]:py-2 [&_td]:px-3 [&_td]:py-2 [&_thead]:bg-muted/50"> + [&_ul]:my-2 [&_ol]:my-2"> {cleanContent} diff --git a/frontend/src/components/ai-assistant/TypewriterText.tsx b/frontend/src/components/ai-assistant/TypewriterText.tsx index 1b3ee15c..51a20726 100644 --- a/frontend/src/components/ai-assistant/TypewriterText.tsx +++ b/frontend/src/components/ai-assistant/TypewriterText.tsx @@ -36,6 +36,36 @@ const markdownComponents = { ); }, pre: ({ children }: React.HTMLAttributes) => <>{children}, + // 表格:外包 overflow-x-auto 容器,让表格在气泡宽度不够时横向滚动而不被挤压。 + // th/td 加 whitespace-nowrap 使单元格内容尽可能一行显示。 + table: ({ children, ...props }: React.HTMLAttributes) => ( +
+ + {children} +
+
+ ), + thead: ({ children, ...props }: React.HTMLAttributes) => ( + + {children} + + ), + th: ({ children, ...props }: React.HTMLAttributes) => ( + + {children} + + ), + td: ({ children, ...props }: React.HTMLAttributes) => ( + + {children} + + ), img: ({ src, alt, ...props }: React.ImgHTMLAttributes) => { const isValidSrc = typeof src === 'string' && src.trim() !== ''; return isValidSrc ? ( @@ -147,8 +177,7 @@ export function TypewriterText({ content, isStreaming, className, onComplete }: "[&_hr]:my-4 [&_hr]:border-border/50", "[&_blockquote]:my-3 [&_blockquote]:py-1 [&_blockquote]:px-3 [&_blockquote]:border-l-2 [&_blockquote]:border-primary/30 [&_blockquote]:bg-muted/30 [&_blockquote]:rounded-r", "[&_pre]:my-3", - "[&_ul]:my-2 [&_ol]:my-2", - "[&_table]:my-3 [&_th]:px-3 [&_th]:py-2 [&_td]:px-3 [&_td]:py-2 [&_thead]:bg-muted/50" + "[&_ul]:my-2 [&_ol]:my-2" ); return (