Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
20 changes: 15 additions & 5 deletions backend/services/chat_generation.py
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down Expand Up @@ -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")
Expand Down Expand Up @@ -720,4 +731,3 @@ async def _persist_message_and_billing():
logger.error(f"Image canvas bridge failed: {e}")

yield sse("done", {})
yield sse("done", {})
6 changes: 4 additions & 2 deletions frontend/src/components/ai-assistant/CallTimelinePanel.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@
import React, { useState, useMemo } from 'react';
import {
CheckCircle2,
CircleDotDashed,
Loader2,
Circle,
AlertCircle,
ChevronDown,
Expand Down Expand Up @@ -127,8 +127,10 @@ const STATUS_STYLE: Record<ResolvedStatus, { icon: string; text: string }> = {
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<ResolvedStatus, React.ReactNode> = {
active: <CircleDotDashed className={cn(cls, style.icon)} />,
active: <Loader2 className={cn(cls, 'animate-spin', style.icon)} />,
success: <CheckCircle2 className={cn(cls, style.icon)} />,
error: <AlertCircle className={cn(cls, style.icon)} />,
pending: <Circle className={cn(cls, style.icon)} />,
Expand Down
89 changes: 62 additions & 27 deletions frontend/src/components/ai-assistant/ChatMessage.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -63,39 +63,46 @@ function parseAttachments(content: string) {
// ---------------------------------------------------------------------------
// Think content parsing - 解析 <think>...</think> 标记
// ---------------------------------------------------------------------------
const THINK_TAG_RE = /<think>([\s\S]*?)(?:<\/think>|$)/;
// 说明:在同一轮 AI 回复中,复杂任务可能出现多次思考(例如工具调用后二次推理),
// 需要使用全局标志提取所有思考段落,然后合并到一个面板,
// 同时从正文中剔除所有已匹配的 <think>...</think> 块,避免未匹配的残留标签被当作普通文本渲染。
const THINK_TAG_RE = /<think>([\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);

// 没有 <think> 标记,所有内容都是正式回复
const noThinkTag = !match;
if (noThinkTag) {
return {
thinkingContent: '',
responseContent: content,
isThinkingComplete: true,
};
// 未出现任何 <think> 标签:全部为正文
if (!content.includes('<think>')) {
return { thinkingContent: '', responseContent: content, isThinkingComplete: true };
}

const thinkingContent = match[1] || '';
const hasClosingTag = content.includes('</think>');

// 提取 </think> 后的内容作为正式回复
const responseContent = hasClosingTag
? content.split('</think>').slice(1).join('</think>').trim()
: '';
// 提取所有 <think>...</think> 段落(含未闭合的尾部)
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('</think>');
thinkingSegments.push((m[1] || '').trim());
closed || (hasUnclosed = true);
// 未闭合意味着流式在进行中,已到达字符串末尾,后续不会再匹配。
// 防护:如果 lastIndex 未推进造成死循环,手动 +1。
closed || (m.index === re.lastIndex && re.lastIndex++);
}

// 从原串中剔除所有已闭合的 <think>...</think> 块得到正文;
// 未闭合的尾部(流式中)从起点到文末一并剔除。
let stripped = content.replace(/<think>[\s\S]*?<\/think>/g, '');
hasUnclosed && (stripped = stripped.replace(/<think>[\s\S]*$/, ''));

return {
thinkingContent: thinkingContent.trim(),
responseContent,
isThinkingComplete: hasClosingTag,
thinkingContent: thinkingSegments.filter(Boolean).join('\n\n---\n\n'),
responseContent: stripped.trim(),
isThinkingComplete: !hasUnclosed,
};
}

Expand Down Expand Up @@ -210,6 +217,36 @@ const createMarkdownComponents = (isStreaming: boolean) => ({
);
},
pre: ({ children }: React.HTMLAttributes<HTMLPreElement>) => <>{children}</>,
// 表格:外包 overflow-x-auto 容器,让表格在气泡宽度不够时横向滚动而不被挤压。
// th/td 加 whitespace-nowrap 使单元格内容尽可能一行显示。
table: ({ children, ...props }: React.HTMLAttributes<HTMLTableElement>) => (
<div className="my-3 overflow-x-auto rounded-md border border-[var(--color-border-light)]">
<table className="min-w-full text-sm border-collapse" {...props}>
{children}
</table>
</div>
),
thead: ({ children, ...props }: React.HTMLAttributes<HTMLTableSectionElement>) => (
<thead className="bg-muted/50" {...props}>
{children}
</thead>
),
th: ({ children, ...props }: React.HTMLAttributes<HTMLTableCellElement>) => (
<th
className="px-3 py-2 text-left font-semibold whitespace-nowrap border-b border-[var(--color-border-light)]"
{...props}
>
{children}
</th>
),
td: ({ children, ...props }: React.HTMLAttributes<HTMLTableCellElement>) => (
<td
className="px-3 py-2 whitespace-nowrap border-b border-[var(--color-border-light)]/60"
{...props}
>
{children}
</td>
),
// 使用懒加载图片组件
img: ({ src, alt, ...props }: React.ImgHTMLAttributes<HTMLImageElement>) => {
const srcString = typeof src === 'string' ? src : '';
Expand Down Expand Up @@ -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">
<ReactMarkdown remarkPlugins={[remarkGfm]} components={markdownComponents}>
{chunk}
</ReactMarkdown>
Expand All @@ -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">
<ReactMarkdown remarkPlugins={[remarkGfm]} components={markdownComponents}>
{cleanContent}
</ReactMarkdown>
Expand Down
33 changes: 31 additions & 2 deletions frontend/src/components/ai-assistant/TypewriterText.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -36,6 +36,36 @@ const markdownComponents = {
);
},
pre: ({ children }: React.HTMLAttributes<HTMLPreElement>) => <>{children}</>,
// 表格:外包 overflow-x-auto 容器,让表格在气泡宽度不够时横向滚动而不被挤压。
// th/td 加 whitespace-nowrap 使单元格内容尽可能一行显示。
table: ({ children, ...props }: React.HTMLAttributes<HTMLTableElement>) => (
<div className="my-3 overflow-x-auto rounded-md border border-border/50">
<table className="min-w-full text-sm border-collapse" {...props}>
{children}
</table>
</div>
),
thead: ({ children, ...props }: React.HTMLAttributes<HTMLTableSectionElement>) => (
<thead className="bg-muted/50" {...props}>
{children}
</thead>
),
th: ({ children, ...props }: React.HTMLAttributes<HTMLTableCellElement>) => (
<th
className="px-3 py-2 text-left font-semibold whitespace-nowrap border-b border-border/50"
{...props}
>
{children}
</th>
),
td: ({ children, ...props }: React.HTMLAttributes<HTMLTableCellElement>) => (
<td
className="px-3 py-2 whitespace-nowrap border-b border-border/30"
{...props}
>
{children}
</td>
),
img: ({ src, alt, ...props }: React.ImgHTMLAttributes<HTMLImageElement>) => {
const isValidSrc = typeof src === 'string' && src.trim() !== '';
return isValidSrc ? (
Expand Down Expand Up @@ -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 (
Expand Down