diff --git a/backend/services/chat_generation.py b/backend/services/chat_generation.py index 2c297b0..e6199b4 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 b9d8327..b9689c5 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 762cc00..a6a1642 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 1b3ee15..51a2072 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 (