feat(聊天): 实现 AI 消息重新生成功能

- 消息 API 新增 DELETE 方法支持删除单条消息
- useStreamChat Hook 添加 regenerateMessage 方法
- 聊天页面添加 handleRegenerate 处理逻辑
- MessageBubble 组件添加重新生成按钮(仅 AI 消息显示)
- MessageBubble 使用 Tooltip 替代原生 title 属性
- 移除未使用的 ActionButton 组件和点赞/踩按钮
This commit is contained in:
gaoziman 2025-12-21 14:33:08 +08:00
parent f50766b742
commit d6dc77f63a
4 changed files with 146 additions and 32 deletions

View File

@ -117,3 +117,48 @@ export async function GET(request: Request, { params }: RouteParams) {
);
}
}
/**
* DELETE /api/messages/[messageId] -
* AI
*/
export async function DELETE(request: Request, { params }: RouteParams) {
try {
const { messageId } = await params;
if (!messageId) {
return NextResponse.json(
{ error: 'Message ID is required' },
{ status: 400 }
);
}
// 查找消息
const existingMessage = await db.query.messages.findFirst({
where: eq(messages.messageId, messageId),
});
if (!existingMessage) {
return NextResponse.json(
{ error: 'Message not found' },
{ status: 404 }
);
}
// 删除消息
await db
.delete(messages)
.where(eq(messages.messageId, messageId));
return NextResponse.json({
success: true,
message: 'Message deleted successfully',
});
} catch (error) {
console.error('Delete message error:', error);
return NextResponse.json(
{ error: 'Failed to delete message' },
{ status: 500 }
);
}
}

View File

@ -53,6 +53,7 @@ export default function ChatPage({ params }: PageProps) {
sendMessage,
stopGeneration,
setInitialMessages,
regenerateMessage,
} = useStreamChat();
// 当前选择的模型和工具
@ -265,6 +266,30 @@ export default function ChatPage({ params }: PageProps) {
setEnableThinking(!enableThinking);
};
// 重新生成 AI 消息
const handleRegenerate = async (messageId: string) => {
if (isStreaming) return; // 正在生成时不允许重新生成
const result = await regenerateMessage(messageId);
if (!result) {
console.error('Failed to regenerate message');
return;
}
const { userMessage } = result;
// 重新发送用户消息
// 注意:历史消息中的图片已经是 base64 格式,保存在 uploadedImages 中
// sendMessage 会重新添加用户消息和 AI 消息占位
await sendMessage({
conversationId: chatId,
message: userMessage.content,
model: selectedModelId,
tools: enabledTools,
enableThinking,
});
};
// 切换模型(持久化到数据库)
const handleModelChange = async (modelId: string) => {
if (!conversation || modelId === selectedModelId) return;
@ -506,6 +531,7 @@ export default function ChatPage({ params }: PageProps) {
uploadedImages={message.uploadedImages}
uploadedDocuments={message.uploadedDocuments}
pyodideStatus={message.pyodideStatus}
onRegenerate={message.role === 'assistant' && !isStreaming ? handleRegenerate : undefined}
/>
))
)}

View File

@ -1,9 +1,10 @@
'use client';
import { useState } from 'react';
import { Copy, ThumbsUp, ThumbsDown, RefreshCw, ChevronDown, ChevronUp, Brain, Loader2, AlertCircle, Check, FileText, FileCode } from 'lucide-react';
import { Copy, RefreshCw, ChevronDown, ChevronUp, Brain, Loader2, AlertCircle, Check, FileText, FileCode } from 'lucide-react';
import { Avatar } from '@/components/ui/Avatar';
import { AILogo } from '@/components/ui/AILogo';
import { Tooltip } from '@/components/ui/Tooltip';
import { MarkdownRenderer } from '@/components/markdown/MarkdownRenderer';
import { CodeExecutionResult, PyodideLoading } from '@/components/features/CodeExecutionResult';
import { ImageLightbox } from '@/components/ui/ImageLightbox';
@ -30,6 +31,8 @@ interface MessageBubbleProps {
message: string;
progress?: number;
};
/** 重新生成回调(仅对 AI 消息有效),传入消息 ID */
onRegenerate?: (messageId: string) => void;
}
// 格式化文件大小
@ -49,7 +52,7 @@ function getDocumentIcon(type: string) {
return FileText;
}
export function MessageBubble({ message, user, thinkingContent, isStreaming, error, images, uploadedImages, uploadedDocuments, pyodideStatus }: MessageBubbleProps) {
export function MessageBubble({ message, user, thinkingContent, isStreaming, error, images, uploadedImages, uploadedDocuments, pyodideStatus, onRegenerate }: MessageBubbleProps) {
const isUser = message.role === 'user';
const [thinkingExpanded, setThinkingExpanded] = useState(false);
const [copied, setCopied] = useState(false);
@ -158,8 +161,7 @@ export function MessageBubble({ message, user, thinkingContent, isStreaming, err
{/* 悬停显示复制按钮 */}
<button
onClick={handleCopy}
className="absolute -bottom-8 right-0 opacity-0 group-hover:opacity-100 transition-opacity duration-200 flex items-center gap-1 px-2 py-1 text-xs text-[var(--color-text-tertiary)] hover:text-[var(--color-text-secondary)] hover:bg-[var(--color-bg-hover)] rounded-md"
title={copied ? '已复制' : '复制'}
className="absolute -bottom-8 right-0 opacity-0 group-hover:opacity-100 transition-opacity duration-200 flex items-center gap-1 px-2 py-1 text-xs text-[var(--color-text-tertiary)] hover:text-[var(--color-text-secondary)] hover:bg-[var(--color-bg-hover)] rounded-md cursor-pointer"
>
{copied ? (
<>
@ -307,16 +309,25 @@ export function MessageBubble({ message, user, thinkingContent, isStreaming, err
{!isStreaming && message.content && (
<>
<div className="flex items-center gap-1 mt-4 pt-3">
<button
onClick={handleCopy}
className="w-8 h-8 flex items-center justify-center rounded-lg text-[var(--color-text-tertiary)] hover:bg-[var(--color-bg-hover)] hover:text-[var(--color-text-secondary)] transition-colors"
title={copied ? '已复制' : '复制'}
>
{copied ? <Check size={16} className="text-green-500" /> : <Copy size={16} />}
</button>
<ActionButton icon={ThumbsUp} title="好的回答" />
<ActionButton icon={ThumbsDown} title="不好的回答" />
<ActionButton icon={RefreshCw} title="重新生成" />
<Tooltip content={copied ? '已复制' : '复制'}>
<button
onClick={handleCopy}
className="w-8 h-8 flex items-center justify-center rounded-lg text-[var(--color-text-tertiary)] hover:bg-[var(--color-bg-hover)] hover:text-[var(--color-text-secondary)] transition-colors cursor-pointer"
>
{copied ? <Check size={16} className="text-green-500" /> : <Copy size={16} />}
</button>
</Tooltip>
{/* 重新生成按钮 */}
{onRegenerate && (
<Tooltip content="重新生成">
<button
onClick={() => onRegenerate(message.id)}
className="w-8 h-8 flex items-center justify-center rounded-lg text-[var(--color-text-tertiary)] hover:bg-[var(--color-bg-hover)] hover:text-[var(--color-text-secondary)] transition-colors cursor-pointer"
>
<RefreshCw size={16} />
</button>
</Tooltip>
)}
</div>
{/* 免责声明 */}
@ -346,24 +357,6 @@ export function MessageBubble({ message, user, thinkingContent, isStreaming, err
);
}
interface ActionButtonProps {
icon: React.ComponentType<{ size?: number }>;
title: string;
onClick?: () => void;
}
function ActionButton({ icon: Icon, title, onClick }: ActionButtonProps) {
return (
<button
onClick={onClick}
className="w-8 h-8 flex items-center justify-center rounded-lg text-[var(--color-text-tertiary)] hover:bg-[var(--color-bg-hover)] hover:text-[var(--color-text-secondary)] transition-colors"
title={title}
>
<Icon size={16} />
</button>
);
}
/**
*
*

View File

@ -527,6 +527,55 @@ export function useStreamChat() {
setMessages(initialMessages);
}, []);
/**
*
* @param assistantMessageId AI ID
* @returns null
*/
const regenerateMessage = useCallback(async (assistantMessageId: string): Promise<{
userMessage: ChatMessage;
} | null> => {
// 1. 在消息列表中找到该 AI 消息的索引
const assistantIndex = messages.findIndex(m => m.id === assistantMessageId);
if (assistantIndex === -1) {
console.error('[regenerateMessage] AI message not found:', assistantMessageId);
return null;
}
// 2. 找到对应的用户消息AI 消息的前一条)
const userIndex = assistantIndex - 1;
if (userIndex < 0 || messages[userIndex].role !== 'user') {
console.error('[regenerateMessage] User message not found for AI message:', assistantMessageId);
return null;
}
const userMessage = { ...messages[userIndex] };
// 3. 尝试从数据库删除 AI 消息(只有保存到数据库的消息才需要删除)
// 临时消息 ID 格式为 'assistant-timestamp',数据库消息 ID 是 nanoid 格式
if (!assistantMessageId.startsWith('assistant-')) {
try {
const response = await fetch(`/api/messages/${assistantMessageId}`, {
method: 'DELETE',
});
if (!response.ok) {
console.warn('[regenerateMessage] Failed to delete message from database:', await response.text());
}
} catch (err) {
console.warn('[regenerateMessage] Error deleting message from database:', err);
}
}
// 4. 从前端状态中移除用户消息、AI 消息及其后的所有消息
// 因为 sendMessage 会重新添加用户消息和 AI 消息占位
setMessages(prev => prev.slice(0, userIndex));
// 5. 返回用户消息信息
return {
userMessage,
};
}, [messages]);
return {
messages,
isStreaming,
@ -535,5 +584,6 @@ export function useStreamChat() {
stopGeneration,
clearMessages,
setInitialMessages,
regenerateMessage,
};
}